From f555328f87928272ac15f3f3789e78c6d9f4ad47 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 16 Jul 2024 05:59:57 +0000 Subject: [PATCH] feat: add coderd_group resource --- docs/index.md | 1 + docs/resources/group.md | 32 ++ integration/integration.go | 7 +- integration/integration_test.go | 2 +- internal/provider/group_resource.go | 357 +++++++++++++++++++++ internal/provider/group_resource_test.go | 151 +++++++++ internal/provider/provider.go | 21 +- internal/provider/user_data_source_test.go | 69 ++-- internal/provider/user_resource_test.go | 42 +-- internal/provider/util.go | 43 +++ 10 files changed, 645 insertions(+), 80 deletions(-) create mode 100644 docs/resources/group.md create mode 100644 internal/provider/group_resource.go create mode 100644 internal/provider/group_resource_test.go diff --git a/docs/index.md b/docs/index.md index a5ea0f0..974962c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,5 +23,6 @@ provider "coderd" { ### Optional +- `default_organization_id` (String) Default organization ID to use when creating resources. Defaults to the first organization the token has access to. - `token` (String) API token for communicating with the deployment. Most resource types require elevated permissions. Defaults to $CODER_SESSION_TOKEN. - `url` (String) URL to the Coder deployment. Defaults to $CODER_URL. diff --git a/docs/resources/group.md b/docs/resources/group.md new file mode 100644 index 0000000..1972265 --- /dev/null +++ b/docs/resources/group.md @@ -0,0 +1,32 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coderd_group Resource - coderd" +subcategory: "" +description: |- + A group on the Coder deployment. If you want to have a group resource with unmanaged members, but still want to read the members in Terraform, use the data.coderd_group data source. +--- + +# coderd_group (Resource) + +A group on the Coder deployment. If you want to have a group resource with unmanaged members, but still want to read the members in Terraform, use the `data.coderd_group` data source. + + + + +## Schema + +### Required + +- `name` (String) The unique name of the group. + +### Optional + +- `avatar_url` (String) The URL of the group's avatar. +- `display_name` (String) The display name of the group. Defaults to the group name. +- `members` (Set of String) Members of the group, by ID. If null, members will not be added or removed. +- `organization_id` (String) The organization ID that the group belongs to. Defaults to the provider default organization ID. +- `quota_allowance` (Number) The number of quota credits to allocate to each user in the group. + +### Read-Only + +- `id` (String) Group ID. diff --git a/integration/integration.go b/integration/integration.go index 08ea4ed..622b015 100644 --- a/integration/integration.go +++ b/integration/integration.go @@ -19,7 +19,7 @@ import ( "github.com/stretchr/testify/require" ) -func StartCoder(ctx context.Context, t *testing.T, name string) *codersdk.Client { +func StartCoder(ctx context.Context, t *testing.T, name string, useTrial bool) *codersdk.Client { coderImg := os.Getenv("CODER_IMAGE") if coderImg == "" { coderImg = "ghcr.io/coder/coder" @@ -75,9 +75,9 @@ func StartCoder(ctx context.Context, t *testing.T, name string) *codersdk.Client // nolint:gosec // For testing only. var ( - testEmail = "testing@coder.com" + testEmail = "admin@coder.com" testPassword = "InsecurePassw0rd!" - testUsername = "testing" + testUsername = "admin" ) // Perform first time setup @@ -96,6 +96,7 @@ func StartCoder(ctx context.Context, t *testing.T, name string) *codersdk.Client Email: testEmail, Username: testUsername, Password: testPassword, + Trial: useTrial, }) require.NoError(t, err, "create first user") resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ diff --git a/integration/integration_test.go b/integration/integration_test.go index fa58a5a..f9774c2 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -90,7 +90,7 @@ func TestIntegration(t *testing.T) { }, } { t.Run(tt.name, func(t *testing.T) { - client := StartCoder(ctx, t, tt.name) + client := StartCoder(ctx, t, tt.name, true) wd, err := os.Getwd() require.NoError(t, err) srcDir := filepath.Join(wd, tt.name) diff --git a/internal/provider/group_resource.go b/internal/provider/group_resource.go new file mode 100644 index 0000000..05d84d0 --- /dev/null +++ b/internal/provider/group_resource.go @@ -0,0 +1,357 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "fmt" + + "github.com/coder/coder/v2/codersdk" + "github.com/google/uuid" + "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/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &GroupResource{} +var _ resource.ResourceWithImportState = &GroupResource{} + +func NewGroupResource() resource.Resource { + return &GroupResource{} +} + +// GroupResource defines the resource implementation. +type GroupResource struct { + data *CoderdProviderData +} + +// GroupResourceModel describes the resource data model. +type GroupResourceModel struct { + ID types.String `tfsdk:"id"` + + Name types.String `tfsdk:"name"` + DisplayName types.String `tfsdk:"display_name"` + AvatarURL types.String `tfsdk:"avatar_url"` + QuotaAllowance types.Int32 `tfsdk:"quota_allowance"` + OrganizationID types.String `tfsdk:"organization_id"` + Members types.Set `tfsdk:"members"` +} + +func (r *GroupResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_group" +} + +func (r *GroupResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "A group on the Coder deployment. If you want to have a group resource with unmanaged members, but still want to read the members in Terraform, use the `data.coderd_group` data source.", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "Group ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The unique name of the group.", + Required: true, + }, + "display_name": schema.StringAttribute{ + MarkdownDescription: "The display name of the group. Defaults to the group name.", + Computed: true, + Optional: true, + // Defaulted in Create + }, + "avatar_url": schema.StringAttribute{ + MarkdownDescription: "The URL of the group's avatar.", + Computed: true, + Optional: true, + Default: stringdefault.StaticString(""), + }, + // Int32 in the db + "quota_allowance": schema.Int32Attribute{ + MarkdownDescription: "The number of quota credits to allocate to each user in the group.", + Optional: true, + }, + "organization_id": schema.StringAttribute{ + MarkdownDescription: "The organization ID that the group belongs to. Defaults to the provider default organization ID.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplaceIfConfigured(), + }, + }, + "members": schema.SetAttribute{ + MarkdownDescription: "Members of the group, by ID. If null, members will not be added or removed.", + ElementType: types.StringType, + Optional: true, + }, + }, + } +} + +func (r *GroupResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + data, ok := req.ProviderData.(*CoderdProviderData) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *CoderdProviderData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.data = data +} + +func (r *GroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data GroupResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + client := r.data.Client + + if data.OrganizationID.IsUnknown() { + data.OrganizationID = types.StringValue(r.data.DefaultOrganizationID) + } + + orgID, err := uuid.Parse(data.OrganizationID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied organization ID as UUID, got error: %s", err)) + return + } + + displayName := data.Name.ValueString() + if data.DisplayName.ValueString() != "" { + displayName = data.DisplayName.ValueString() + } + + tflog.Trace(ctx, "creating group") + group, err := client.CreateGroup(ctx, orgID, codersdk.CreateGroupRequest{ + Name: data.Name.ValueString(), + DisplayName: displayName, + AvatarURL: data.AvatarURL.ValueString(), + QuotaAllowance: int(data.QuotaAllowance.ValueInt32()), + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create group, got error: %s", err)) + return + } + tflog.Trace(ctx, "successfully created group", map[string]any{ + "id": group.ID.String(), + }) + data.ID = types.StringValue(group.ID.String()) + data.DisplayName = types.StringValue(group.DisplayName) + + tflog.Trace(ctx, "setting group members") + var members []string + resp.Diagnostics.Append( + data.Members.ElementsAs(ctx, &members, false)..., + ) + if resp.Diagnostics.HasError() { + return + } + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: members, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to add members to group, got error: %s", err)) + return + } + tflog.Trace(ctx, "successfully set group members") + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *GroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data GroupResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + client := r.data.Client + + groupID, err := uuid.Parse(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err)) + return + } + + group, err := client.Group(ctx, groupID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get group, got error: %s", err)) + return + } + + data.Name = types.StringValue(group.Name) + data.DisplayName = types.StringValue(group.DisplayName) + data.AvatarURL = types.StringValue(group.AvatarURL) + data.QuotaAllowance = types.Int32Value(int32(group.QuotaAllowance)) + data.OrganizationID = types.StringValue(group.OrganizationID.String()) + if !data.Members.IsNull() { + members := make([]attr.Value, 0, len(group.Members)) + for _, member := range group.Members { + members = append(members, types.StringValue(member.ID.String())) + } + data.Members = types.SetValueMust(types.StringType, members) + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *GroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data GroupResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + client := r.data.Client + if data.OrganizationID.IsUnknown() { + data.OrganizationID = types.StringValue(r.data.DefaultOrganizationID) + } + groupID, err := uuid.Parse(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err)) + return + } + + group, err := client.Group(ctx, groupID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get group, got error: %s", err)) + return + } + var newMembers []string + resp.Diagnostics.Append( + data.Members.ElementsAs(ctx, &newMembers, false)..., + ) + if resp.Diagnostics.HasError() { + return + } + var add []string + var remove []string + if !data.Members.IsNull() { + add, remove = memberDiff(group.Members, newMembers) + } + tflog.Trace(ctx, "updating group", map[string]any{ + "new_members": add, + "removed_members": remove, + "new_name": data.Name, + "new_displayname": data.DisplayName, + "new_avatarurl": data.AvatarURL, + "new_quota": data.QuotaAllowance, + }) + + quotaAllowance := int(data.QuotaAllowance.ValueInt32()) + _, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: add, + RemoveUsers: remove, + Name: data.Name.ValueString(), + DisplayName: data.DisplayName.ValueStringPointer(), + AvatarURL: data.AvatarURL.ValueStringPointer(), + QuotaAllowance: "aAllowance, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update group, got error: %s", err)) + return + } + tflog.Trace(ctx, "successfully updated group") + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *GroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data GroupResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + client := r.data.Client + groupID, err := uuid.Parse(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse supplied group ID as UUID, got error: %s", err)) + return + } + + tflog.Trace(ctx, "deleting group") + err = client.DeleteGroup(ctx, groupID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete group, got error: %s", err)) + return + } + tflog.Trace(ctx, "successfully deleted group") +} + +func (r *GroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + client := r.data.Client + groupID, err := uuid.Parse(req.ID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse import group ID as UUID, got error: %s", err)) + return + } + group, err := client.Group(ctx, groupID) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get imported group, got error: %s", err)) + return + } + if group.Source == "oidc" { + resp.Diagnostics.AddError("Client Error", "Cannot import groups created via OIDC") + return + } + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func memberDiff(curMembers []codersdk.ReducedUser, newMembers []string) (add, remove []string) { + curSet := make(map[string]struct{}, len(curMembers)) + newSet := make(map[string]struct{}, len(newMembers)) + + for _, user := range curMembers { + curSet[user.ID.String()] = struct{}{} + } + for _, userID := range newMembers { + newSet[userID] = struct{}{} + if _, exists := curSet[userID]; !exists { + add = append(add, userID) + } + } + for _, user := range curMembers { + if _, exists := newSet[user.ID.String()]; !exists { + remove = append(remove, user.ID.String()) + } + } + return add, remove +} diff --git a/internal/provider/group_resource_test.go b/internal/provider/group_resource_test.go new file mode 100644 index 0000000..2b90868 --- /dev/null +++ b/internal/provider/group_resource_test.go @@ -0,0 +1,151 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "os" + "strings" + "testing" + "text/template" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/terraform-provider-coderd/integration" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/stretchr/testify/require" +) + +func TestAccGroupResource(t *testing.T) { + if os.Getenv("TF_ACC") == "" { + t.Skip("Acceptance tests are disabled.") + } + + ctx := context.Background() + client := integration.StartCoder(ctx, t, "group_acc", true) + firstUser, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + + user1, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "example@coder.com", + Username: "example", + Password: "SomeSecurePassword!", + UserLoginType: "password", + OrganizationID: firstUser.OrganizationIDs[0], + }) + require.NoError(t, err) + + user2, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "example2@coder.com", + Username: "example2", + Password: "SomeSecurePassword!", + UserLoginType: "password", + OrganizationID: firstUser.OrganizationIDs[0], + }) + require.NoError(t, err) + + cfg1 := testAccGroupResourceconfig{ + URL: client.URL.String(), + Token: client.SessionToken(), + Name: PtrTo("example-group"), + DisplayName: PtrTo("Example Group"), + AvatarUrl: PtrTo("https://google.com"), + QuotaAllowance: PtrTo(int32(100)), + Members: PtrTo([]string{user1.ID.String()}), + } + + cfg2 := cfg1 + cfg2.Name = PtrTo("example-group-new") + cfg2.DisplayName = PtrTo("Example Group New") + cfg2.Members = PtrTo([]string{user2.ID.String()}) + + cfg3 := cfg2 + cfg3.Members = nil + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read + { + Config: cfg1.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_group.test", "name", "example-group"), + resource.TestCheckResourceAttr("coderd_group.test", "display_name", "Example Group"), + resource.TestCheckResourceAttr("coderd_group.test", "avatar_url", "https://google.com"), + resource.TestCheckResourceAttr("coderd_group.test", "quota_allowance", "100"), + resource.TestCheckResourceAttr("coderd_group.test", "organization_id", firstUser.OrganizationIDs[0].String()), + resource.TestCheckResourceAttr("coderd_group.test", "members.#", "1"), + resource.TestCheckResourceAttr("coderd_group.test", "members.0", user1.ID.String()), + ), + }, + // Import + { + Config: cfg1.String(t), + ResourceName: "coderd_group.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"members"}, + }, + // Update and Read + { + Config: cfg2.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_group.test", "name", "example-group-new"), + resource.TestCheckResourceAttr("coderd_group.test", "display_name", "Example Group New"), + resource.TestCheckResourceAttr("coderd_group.test", "members.#", "1"), + resource.TestCheckResourceAttr("coderd_group.test", "members.0", user2.ID.String()), + ), + }, + // Unmanaged members + { + Config: cfg3.String(t), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckNoResourceAttr("coderd_group.test", "members"), + ), + }, + }, + }) +} + +type testAccGroupResourceconfig struct { + URL string + Token string + + Name *string + DisplayName *string + AvatarUrl *string + QuotaAllowance *int32 + OrganizationID *string + Members *[]string +} + +func (c testAccGroupResourceconfig) String(t *testing.T) string { + t.Helper() + tpl := ` +provider coderd { + url = "{{.URL}}" + token = "{{.Token}}" +} + +resource "coderd_group" "test" { + name = {{orNull .Name}} + display_name = {{orNull .DisplayName}} + avatar_url = {{orNull .AvatarUrl}} + quota_allowance = {{orNull .QuotaAllowance}} + organization_id = {{orNull .OrganizationID}} + members = {{orNull .Members}} +} +` + funcMap := template.FuncMap{ + "orNull": PrintOrNull, + } + + buf := strings.Builder{} + tmpl, err := template.New("groupResource").Funcs(funcMap).Parse(tpl) + require.NoError(t, err) + + err = tmpl.Execute(&buf, c) + require.NoError(t, err) + return buf.String() +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 40bdff3..8cfe4c7 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -32,12 +32,17 @@ type CoderdProvider struct { type CoderdProviderData struct { Client *codersdk.Client + // TODO(ethanndickson): We should use a custom TFPF type for UUIDs everywhere + // possible, instead of `string` and `types.String`. + DefaultOrganizationID string } // CoderdProviderModel describes the provider data model. type CoderdProviderModel struct { URL types.String `tfsdk:"url"` Token types.String `tfsdk:"token"` + + DefaultOrganizationID types.String `tfsdk:"default_organization_id"` } func (p *CoderdProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { @@ -56,6 +61,10 @@ func (p *CoderdProvider) Schema(ctx context.Context, req provider.SchemaRequest, MarkdownDescription: "API token for communicating with the deployment. Most resource types require elevated permissions. Defaults to $CODER_SESSION_TOKEN.", Optional: true, }, + "default_organization_id": schema.StringAttribute{ + MarkdownDescription: "Default organization ID to use when creating resources. Defaults to the first organization the token has access to.", + Optional: true, + }, }, } } @@ -94,8 +103,17 @@ func (p *CoderdProvider) Configure(ctx context.Context, req provider.ConfigureRe client := codersdk.New(url) client.SetLogger(slog.Make(tfslog{}).Leveled(slog.LevelDebug)) client.SetSessionToken(data.Token.ValueString()) + if data.DefaultOrganizationID.IsNull() { + user, err := client.User(ctx, codersdk.Me) + if err != nil { + resp.Diagnostics.AddError("default_organization_id", "failed to get default organization ID: "+err.Error()) + return + } + data.DefaultOrganizationID = types.StringValue(user.OrganizationIDs[0].String()) + } providerData := &CoderdProviderData{ - Client: client, + Client: client, + DefaultOrganizationID: data.DefaultOrganizationID.ValueString(), } resp.DataSourceData = providerData resp.ResourceData = providerData @@ -104,6 +122,7 @@ func (p *CoderdProvider) Configure(ctx context.Context, req provider.ConfigureRe func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ NewUserResource, + NewGroupResource, } } diff --git a/internal/provider/user_data_source_test.go b/internal/provider/user_data_source_test.go index b3e3987..2d69d13 100644 --- a/internal/provider/user_data_source_test.go +++ b/internal/provider/user_data_source_test.go @@ -2,11 +2,11 @@ package provider import ( "context" - "html/template" "os" "regexp" "strings" "testing" + "text/template" "github.com/coder/coder/v2/codersdk" "github.com/coder/terraform-provider-coderd/integration" @@ -19,7 +19,7 @@ func TestAccUserDataSource(t *testing.T) { t.Skip("Acceptance tests are disabled.") } ctx := context.Background() - client := integration.StartCoder(ctx, t, "user_data_acc") + client := integration.StartCoder(ctx, t, "user_data_acc", false) firstUser, err := client.User(ctx, codersdk.Me) require.NoError(t, err) user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ @@ -39,11 +39,21 @@ func TestAccUserDataSource(t *testing.T) { Name: "Example User", }) require.NoError(t, err) - t.Run("UserByUsername", func(t *testing.T) { + + checkFn := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.coderd_user.test", "username", "example"), + resource.TestCheckResourceAttr("data.coderd_user.test", "name", "Example User"), + resource.TestCheckResourceAttr("data.coderd_user.test", "email", "example@coder.com"), + resource.TestCheckResourceAttr("data.coderd_user.test", "roles.#", "1"), + resource.TestCheckResourceAttr("data.coderd_user.test", "roles.0", "auditor"), + resource.TestCheckResourceAttr("data.coderd_user.test", "login_type", "password"), + resource.TestCheckResourceAttr("data.coderd_user.test", "suspended", "false"), + ) + t.Run("UserByUsernameOk", func(t *testing.T) { cfg := testAccUserDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - Username: user.Username, + Username: PtrTo(user.Username), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -51,25 +61,17 @@ func TestAccUserDataSource(t *testing.T) { Steps: []resource.TestStep{ { Config: cfg.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("data.coderd_user.test", "username", "example"), - resource.TestCheckResourceAttr("data.coderd_user.test", "name", "Example User"), - resource.TestCheckResourceAttr("data.coderd_user.test", "email", "example@coder.com"), - resource.TestCheckResourceAttr("data.coderd_user.test", "roles.#", "1"), - resource.TestCheckResourceAttr("data.coderd_user.test", "roles.0", "auditor"), - resource.TestCheckResourceAttr("data.coderd_user.test", "login_type", "password"), - resource.TestCheckResourceAttr("data.coderd_user.test", "suspended", "false"), - ), + Check: checkFn, }, }, }) }) - t.Run("UserByID", func(t *testing.T) { + t.Run("UserByIDOk", func(t *testing.T) { cfg := testAccUserDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), - ID: user.ID.String(), + ID: PtrTo(user.ID.String()), } resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, @@ -78,20 +80,12 @@ func TestAccUserDataSource(t *testing.T) { Steps: []resource.TestStep{ { Config: cfg.String(t), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("data.coderd_user.test", "username", "example"), - resource.TestCheckResourceAttr("data.coderd_user.test", "name", "Example User"), - resource.TestCheckResourceAttr("data.coderd_user.test", "email", "example@coder.com"), - resource.TestCheckResourceAttr("data.coderd_user.test", "roles.#", "1"), - resource.TestCheckResourceAttr("data.coderd_user.test", "roles.0", "auditor"), - resource.TestCheckResourceAttr("data.coderd_user.test", "login_type", "password"), - resource.TestCheckResourceAttr("data.coderd_user.test", "suspended", "false"), - ), + Check: checkFn, }, }, }) }) - t.Run("NeitherIDNorUsername", func(t *testing.T) { + t.Run("NeitherIDNorUsernameError", func(t *testing.T) { cfg := testAccUserDataSourceConfig{ URL: client.URL.String(), Token: client.SessionToken(), @@ -115,11 +109,12 @@ type testAccUserDataSourceConfig struct { URL string Token string - ID string - Username string + ID *string + Username *string } func (c testAccUserDataSourceConfig) String(t *testing.T) string { + t.Helper() tpl := ` provider coderd { url = "{{.URL}}" @@ -127,21 +122,19 @@ provider coderd { } data "coderd_user" "test" { -{{- if .ID }} - id = "{{ .ID }}" -{{- end }} -{{- if .Username }} - username = "{{ .Username }}" -{{- end }} + id = {{orNull .ID}} + username = {{orNull .Username}} }` - tmpl := template.Must(template.New("userDataSource").Parse(tpl)) + funcMap := template.FuncMap{ + "orNull": PrintOrNull, + } buf := strings.Builder{} - err := tmpl.Execute(&buf, c) - if err != nil { - panic(err) - } + tmpl, err := template.New("userDataSource").Funcs(funcMap).Parse(tpl) + require.NoError(t, err) + err = tmpl.Execute(&buf, c) + require.NoError(t, err) return buf.String() } diff --git a/internal/provider/user_resource_test.go b/internal/provider/user_resource_test.go index f955310..27f09a8 100644 --- a/internal/provider/user_resource_test.go +++ b/internal/provider/user_resource_test.go @@ -2,7 +2,6 @@ package provider import ( "context" - "fmt" "os" "strings" "testing" @@ -18,7 +17,7 @@ func TestAccUserResource(t *testing.T) { t.Skip("Acceptance tests are disabled.") } ctx := context.Background() - client := integration.StartCoder(ctx, t, "user_acc") + client := integration.StartCoder(ctx, t, "user_acc", false) cfg1 := testAccUserResourceConfig{ URL: client.URL.String(), @@ -47,8 +46,8 @@ func TestAccUserResource(t *testing.T) { resource.TestCheckResourceAttr("coderd_user.test", "name", "Example User"), resource.TestCheckResourceAttr("coderd_user.test", "email", "example@coder.com"), resource.TestCheckResourceAttr("coderd_user.test", "roles.#", "2"), - resource.TestCheckResourceAttr("coderd_user.test", "roles.0", "auditor"), - resource.TestCheckResourceAttr("coderd_user.test", "roles.1", "owner"), + resource.TestCheckTypeSetElemAttr("coderd_user.test", "roles.*", "auditor"), + resource.TestCheckTypeSetElemAttr("coderd_user.test", "roles.*", "owner"), resource.TestCheckResourceAttr("coderd_user.test", "login_type", "password"), resource.TestCheckResourceAttr("coderd_user.test", "password", "SomeSecurePassword!"), resource.TestCheckResourceAttr("coderd_user.test", "suspended", "false"), @@ -89,6 +88,7 @@ type testAccUserResourceConfig struct { } func (c testAccUserResourceConfig) String(t *testing.T) string { + t.Helper() tpl := ` provider coderd { url = "{{.URL}}" @@ -107,39 +107,7 @@ resource "coderd_user" "test" { ` // Define template functions funcMap := template.FuncMap{ - "orNull": func(v interface{}) string { - if v == nil { - return "null" - } - switch value := v.(type) { - case *string: - if value == nil { - return "null" - } - return fmt.Sprintf("%q", *value) - case *bool: - if value == nil { - return "null" - } - return fmt.Sprintf(`%t`, *value) - case *[]string: - if value == nil { - return "null" - } - var result string - for i, role := range *value { - if i > 0 { - result += ", " - } - result += fmt.Sprintf("%q", role) - } - return fmt.Sprintf("[%s]", result) - - default: - require.NoError(t, fmt.Errorf("unknown type in template: %T", value)) - return "" - } - }, + "orNull": PrintOrNull, } buf := strings.Builder{} diff --git a/internal/provider/util.go b/internal/provider/util.go index c9fbc9f..c0c8161 100644 --- a/internal/provider/util.go +++ b/internal/provider/util.go @@ -1,5 +1,48 @@ package provider +import ( + "fmt" +) + func PtrTo[T any](v T) *T { return &v } + +func PrintOrNull(v any) string { + if v == nil { + return "null" + } + switch value := v.(type) { + case *int32: + if value == nil { + return "null" + } + return fmt.Sprintf("%d", *value) + case *string: + if value == nil { + return "null" + } + out := fmt.Sprintf("%q", *value) + return out + case *bool: + if value == nil { + return "null" + } + return fmt.Sprintf(`%t`, *value) + case *[]string: + if value == nil { + return "null" + } + var result string + for i, role := range *value { + if i > 0 { + result += ", " + } + result += fmt.Sprintf("%q", role) + } + return fmt.Sprintf("[%s]", result) + + default: + panic(fmt.Errorf("unknown type in template: %T", value)) + } +}