Skip to content

Commit 8c23074

Browse files
deansheatherethanndickson
andauthoredJul 12, 2024··
feat: add coderd_user resource (#18)
--------- Co-authored-by: Ethan Dickson <[email protected]>
1 parent 31e99a7 commit 8c23074

File tree

17 files changed

+673
-460
lines changed

17 files changed

+673
-460
lines changed
 

‎.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,6 @@ website/vendor
3636
terraform-provider-coderd
3737

3838
# Needs to be written on each invocation
39-
integration/integration.tfrc
39+
integration/integration.tfrc
40+
41+
*.tfstate

‎docs/functions/example.md

Lines changed: 0 additions & 26 deletions
This file was deleted.

‎docs/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ provider "coderd" {
2323

2424
### Optional
2525

26-
- `endpoint` (String) Example provider attribute
26+
- `token` (String) API token for communicating with the deployment. Most resource types require elevated permissions. Defaults to $CODER_SESSION_TOKEN.
27+
- `url` (String) URL to the Coder deployment. Defaults to $CODER_URL.

‎docs/resources/example.md

Lines changed: 0 additions & 31 deletions
This file was deleted.

‎docs/resources/user.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "coderd_user Resource - coderd"
4+
subcategory: ""
5+
description: |-
6+
A user on the Coder deployment.
7+
---
8+
9+
# coderd_user (Resource)
10+
11+
A user on the Coder deployment.
12+
13+
14+
15+
<!-- schema generated by tfplugindocs -->
16+
## Schema
17+
18+
### Required
19+
20+
- `email` (String) Email address of the user.
21+
- `username` (String) Username of the user.
22+
23+
### Optional
24+
25+
- `login_type` (String) Type of login for the user. Valid types are 'none', 'password', 'github', and 'oidc'.
26+
- `name` (String) Display name of the user. Defaults to username.
27+
- `password` (String, Sensitive) Password for the user. Required when login_type is 'password'. Passwords are saved into the state as plain text and should only be used for testing purposes.
28+
- `roles` (Set of String) Roles assigned to the user. Valid roles are 'owner', 'template-admin', 'user-admin', and 'auditor'.
29+
- `suspended` (Boolean) Whether the user is suspended.
30+
31+
### Read-Only
32+
33+
- `id` (String) User ID

‎go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ require (
77
github.com/docker/docker v26.1.4+incompatible
88
github.com/docker/go-connections v0.4.0
99
github.com/hashicorp/terraform-plugin-docs v0.19.4
10-
github.com/hashicorp/terraform-plugin-framework v1.9.0
10+
github.com/hashicorp/terraform-plugin-framework v1.10.0
1111
github.com/hashicorp/terraform-plugin-go v0.23.0
1212
github.com/hashicorp/terraform-plugin-log v0.9.0
1313
github.com/hashicorp/terraform-plugin-testing v1.8.0
@@ -78,6 +78,7 @@ require (
7878
github.com/hashicorp/logutils v1.0.0 // indirect
7979
github.com/hashicorp/terraform-exec v0.21.0 // indirect
8080
github.com/hashicorp/terraform-json v0.22.1 // indirect
81+
github.com/hashicorp/terraform-plugin-framework-validators v0.13.0 // indirect
8182
github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0 // indirect
8283
github.com/hashicorp/terraform-registry-address v0.2.3 // indirect
8384
github.com/hashicorp/terraform-svchost v0.1.1 // indirect

‎go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,10 @@ github.com/hashicorp/terraform-plugin-docs v0.19.4 h1:G3Bgo7J22OMtegIgn8Cd/CaSey
237237
github.com/hashicorp/terraform-plugin-docs v0.19.4/go.mod h1:4pLASsatTmRynVzsjEhbXZ6s7xBlUw/2Kt0zfrq8HxA=
238238
github.com/hashicorp/terraform-plugin-framework v1.9.0 h1:caLcDoxiRucNi2hk8+j3kJwkKfvHznubyFsJMWfZqKU=
239239
github.com/hashicorp/terraform-plugin-framework v1.9.0/go.mod h1:qBXLDn69kM97NNVi/MQ9qgd1uWWsVftGSnygYG1tImM=
240+
github.com/hashicorp/terraform-plugin-framework v1.10.0 h1:xXhICE2Fns1RYZxEQebwkB2+kXouLC932Li9qelozrc=
241+
github.com/hashicorp/terraform-plugin-framework v1.10.0/go.mod h1:qBXLDn69kM97NNVi/MQ9qgd1uWWsVftGSnygYG1tImM=
242+
github.com/hashicorp/terraform-plugin-framework-validators v0.13.0 h1:bxZfGo9DIUoLLtHMElsu+zwqI4IsMZQBRRy4iLzZJ8E=
243+
github.com/hashicorp/terraform-plugin-framework-validators v0.13.0/go.mod h1:wGeI02gEhj9nPANU62F2jCaHjXulejm/X+af4PdZaNo=
240244
github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co=
241245
github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ=
242246
github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0=

‎integration/example-test/main.tf

Lines changed: 0 additions & 12 deletions
This file was deleted.

‎integration/integration_test.go

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"fmt"
7+
"io"
78
"net"
89
"net/url"
910
"os"
@@ -15,6 +16,7 @@ import (
1516

1617
"github.com/coder/coder/v2/codersdk"
1718
"github.com/docker/docker/api/types/container"
19+
"github.com/docker/docker/api/types/image"
1820
"github.com/docker/docker/client"
1921
"github.com/docker/go-connections/nat"
2022
"github.com/stretchr/testify/assert"
@@ -50,11 +52,33 @@ func TestIntegration(t *testing.T) {
5052
assertF func(testing.TB, *codersdk.Client)
5153
}{
5254
{
53-
name: "example-test",
55+
name: "user-test",
5456
assertF: func(t testing.TB, c *codersdk.Client) {
55-
me, err := c.User(ctx, codersdk.Me)
57+
// Check user fields.
58+
user, err := c.User(ctx, "dean")
5659
assert.NoError(t, err)
57-
assert.NotEmpty(t, me)
60+
assert.Equal(t, "dean", user.Username)
61+
assert.Equal(t, "Dean Coolguy", user.Name)
62+
assert.Equal(t, "test@coder.com", user.Email)
63+
roles := make([]string, len(user.Roles))
64+
for i, role := range user.Roles {
65+
roles[i] = role.Name
66+
}
67+
assert.ElementsMatch(t, []string{"owner", "template-admin"}, roles)
68+
assert.Equal(t, codersdk.LoginTypePassword, user.LoginType)
69+
assert.Contains(t, []codersdk.UserStatus{codersdk.UserStatusActive, codersdk.UserStatusDormant}, user.Status)
70+
71+
// Test password.
72+
newClient := codersdk.New(c.URL)
73+
res, err := newClient.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
74+
Email: "test@coder.com",
75+
Password: "SomeSecurePassword!",
76+
})
77+
assert.NoError(t, err)
78+
newClient.SetSessionToken(res.SessionToken)
79+
user, err = newClient.User(ctx, codersdk.Me)
80+
assert.NoError(t, err)
81+
assert.Equal(t, "dean", user.Username)
5882
},
5983
},
6084
} {
@@ -63,6 +87,14 @@ func TestIntegration(t *testing.T) {
6387
wd, err := os.Getwd()
6488
require.NoError(t, err)
6589
srcDir := filepath.Join(wd, tt.name)
90+
// Delete all .tfstate files
91+
err = filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
92+
if filepath.Ext(path) == ".tfstate" {
93+
return os.Remove(path)
94+
}
95+
return nil
96+
})
97+
require.NoError(t, err)
6698
tfCmd := exec.CommandContext(ctx, "terraform", "-chdir="+srcDir, "apply", "-auto-approve")
6799
tfCmd.Env = append(tfCmd.Env, "TF_CLI_CONFIG_FILE="+tfrcPath)
68100
tfCmd.Env = append(tfCmd.Env, "CODER_URL="+client.URL.String())
@@ -124,6 +156,11 @@ func startCoder(ctx context.Context, t *testing.T, name string) *codersdk.Client
124156
p := randomPort(t)
125157
t.Logf("random port is %d", p)
126158
// Stand up a temporary Coder instance
159+
puller, err := cli.ImagePull(ctx, coderImg+":"+coderVersion, image.PullOptions{})
160+
require.NoError(t, err, "pull coder image")
161+
defer puller.Close()
162+
_, err = io.Copy(os.Stderr, puller)
163+
require.NoError(t, err, "pull coder image")
127164
ctr, err := cli.ContainerCreate(ctx, &container.Config{
128165
Image: coderImg + ":" + coderVersion,
129166
Env: []string{

‎integration/user-test/main.tf

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
terraform {
2+
required_providers {
3+
coderd = {
4+
source = "coder/coderd"
5+
version = ">=0.0.0"
6+
}
7+
}
8+
}
9+
10+
resource "coderd_user" "dean" {
11+
username = "dean"
12+
name = "Dean Coolguy"
13+
email = "test@coder.com"
14+
roles = ["owner", "template-admin"]
15+
login_type = "password"
16+
password = "SomeSecurePassword!"
17+
suspended = false
18+
}

‎internal/provider/example_function.go

Lines changed: 0 additions & 50 deletions
This file was deleted.

‎internal/provider/example_function_test.go

Lines changed: 0 additions & 78 deletions
This file was deleted.

‎internal/provider/example_resource.go

Lines changed: 0 additions & 187 deletions
This file was deleted.

‎internal/provider/example_resource_test.go

Lines changed: 0 additions & 56 deletions
This file was deleted.

‎internal/provider/provider.go

Lines changed: 87 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@ package provider
55

66
import (
77
"context"
8-
"net/http"
8+
"net/url"
9+
"os"
10+
"strings"
911

12+
"cdr.dev/slog"
1013
"github.com/hashicorp/terraform-plugin-framework/datasource"
1114
"github.com/hashicorp/terraform-plugin-framework/function"
1215
"github.com/hashicorp/terraform-plugin-framework/provider"
1316
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
1417
"github.com/hashicorp/terraform-plugin-framework/resource"
1518
"github.com/hashicorp/terraform-plugin-framework/types"
19+
"github.com/hashicorp/terraform-plugin-log/tflog"
20+
21+
"github.com/coder/coder/v2/codersdk"
1622
)
1723

1824
// Ensure CoderdProvider satisfies various provider interfaces.
@@ -27,9 +33,14 @@ type CoderdProvider struct {
2733
version string
2834
}
2935

36+
type CoderdProviderData struct {
37+
Client *codersdk.Client
38+
}
39+
3040
// CoderdProviderModel describes the provider data model.
3141
type CoderdProviderModel struct {
32-
Endpoint types.String `tfsdk:"endpoint"`
42+
URL types.String `tfsdk:"url"`
43+
Token types.String `tfsdk:"token"`
3344
}
3445

3546
func (p *CoderdProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
@@ -40,8 +51,12 @@ func (p *CoderdProvider) Metadata(ctx context.Context, req provider.MetadataRequ
4051
func (p *CoderdProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) {
4152
resp.Schema = schema.Schema{
4253
Attributes: map[string]schema.Attribute{
43-
"endpoint": schema.StringAttribute{
44-
MarkdownDescription: "Example provider attribute",
54+
"url": schema.StringAttribute{
55+
MarkdownDescription: "URL to the Coder deployment. Defaults to $CODER_URL.",
56+
Optional: true,
57+
},
58+
"token": schema.StringAttribute{
59+
MarkdownDescription: "API token for communicating with the deployment. Most resource types require elevated permissions. Defaults to $CODER_SESSION_TOKEN.",
4560
Optional: true,
4661
},
4762
},
@@ -57,18 +72,41 @@ func (p *CoderdProvider) Configure(ctx context.Context, req provider.ConfigureRe
5772
return
5873
}
5974

60-
// Configuration values are now available.
61-
// if data.Endpoint.IsNull() { /* ... */ }
75+
if data.URL.ValueString() == "" {
76+
urlEnv, ok := os.LookupEnv("CODER_URL")
77+
if !ok {
78+
resp.Diagnostics.AddError("url", "url or $CODER_URL is required")
79+
return
80+
}
81+
data.URL = types.StringValue(urlEnv)
82+
}
83+
if data.Token.ValueString() == "" {
84+
tokenEnv, ok := os.LookupEnv("CODER_SESSION_TOKEN")
85+
if !ok {
86+
resp.Diagnostics.AddError("token", "token or $CODER_SESSION_TOKEN is required")
87+
return
88+
}
89+
data.Token = types.StringValue(tokenEnv)
90+
}
6291

63-
// Example client configuration for data sources and resources
64-
client := http.DefaultClient
65-
resp.DataSourceData = client
66-
resp.ResourceData = client
92+
url, err := url.Parse(data.URL.ValueString())
93+
if err != nil {
94+
resp.Diagnostics.AddError("url", "url is not a valid URL: "+err.Error())
95+
return
96+
}
97+
client := codersdk.New(url)
98+
client.SetLogger(slog.Make(tfslog{}).Leveled(slog.LevelDebug))
99+
client.SetSessionToken(data.Token.ValueString())
100+
providerData := &CoderdProviderData{
101+
Client: client,
102+
}
103+
resp.DataSourceData = providerData
104+
resp.ResourceData = providerData
67105
}
68106

69107
func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resource {
70108
return []func() resource.Resource{
71-
NewExampleResource,
109+
NewUserResource,
72110
}
73111
}
74112

@@ -79,9 +117,7 @@ func (p *CoderdProvider) DataSources(ctx context.Context) []func() datasource.Da
79117
}
80118

81119
func (p *CoderdProvider) Functions(ctx context.Context) []func() function.Function {
82-
return []func() function.Function{
83-
NewExampleFunction,
84-
}
120+
return []func() function.Function{}
85121
}
86122

87123
func New(version string) func() provider.Provider {
@@ -91,3 +127,40 @@ func New(version string) func() provider.Provider {
91127
}
92128
}
93129
}
130+
131+
// tfslog redirects slog entries to tflog.
132+
type tfslog struct{}
133+
134+
var _ slog.Sink = tfslog{}
135+
136+
// LogEntry implements slog.Sink.
137+
func (t tfslog) LogEntry(ctx context.Context, e slog.SinkEntry) {
138+
m := map[string]any{
139+
"time": e.Time.Unix(),
140+
"func": e.Func,
141+
"file": e.File,
142+
"line": e.Line,
143+
}
144+
for _, f := range e.Fields {
145+
m[f.Name] = f.Value
146+
}
147+
148+
msg := e.Message
149+
if len(e.LoggerNames) > 0 {
150+
msg = "[" + strings.Join(e.LoggerNames, ".") + "] " + msg
151+
}
152+
153+
switch e.Level {
154+
case slog.LevelDebug:
155+
tflog.Debug(ctx, msg, m)
156+
case slog.LevelInfo:
157+
tflog.Info(ctx, msg, m)
158+
case slog.LevelWarn:
159+
tflog.Warn(ctx, msg, m)
160+
case slog.LevelError, slog.LevelFatal:
161+
tflog.Error(ctx, msg, m)
162+
}
163+
}
164+
165+
// Sync implements slog.Sink.
166+
func (t tfslog) Sync() {}

‎internal/provider/user_resource.go

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package provider
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"strings"
10+
11+
"github.com/google/uuid"
12+
"github.com/hashicorp/terraform-plugin-framework-validators/setvalidator"
13+
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
14+
"github.com/hashicorp/terraform-plugin-framework/attr"
15+
"github.com/hashicorp/terraform-plugin-framework/path"
16+
"github.com/hashicorp/terraform-plugin-framework/resource"
17+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
18+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
19+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
20+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault"
21+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
22+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
23+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
24+
"github.com/hashicorp/terraform-plugin-framework/types"
25+
"github.com/hashicorp/terraform-plugin-log/tflog"
26+
27+
"github.com/coder/coder/v2/codersdk"
28+
)
29+
30+
// Ensure provider defined types fully satisfy framework interfaces.
31+
var _ resource.Resource = &UserResource{}
32+
var _ resource.ResourceWithImportState = &UserResource{}
33+
34+
func NewUserResource() resource.Resource {
35+
return &UserResource{}
36+
}
37+
38+
// UserResource defines the resource implementation.
39+
type UserResource struct {
40+
data *CoderdProviderData
41+
}
42+
43+
// UserResourceModel describes the resource data model.
44+
type UserResourceModel struct {
45+
ID types.String `tfsdk:"id"`
46+
47+
Username types.String `tfsdk:"username"`
48+
Name types.String `tfsdk:"name"`
49+
Email types.String `tfsdk:"email"`
50+
Roles types.Set `tfsdk:"roles"` // owner, template-admin, user-admin, auditor (member is implicit)
51+
LoginType types.String `tfsdk:"login_type"` // none, password, github, oidc
52+
Password types.String `tfsdk:"password"` // only when login_type is password
53+
Suspended types.Bool `tfsdk:"suspended"`
54+
}
55+
56+
func (r *UserResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
57+
resp.TypeName = req.ProviderTypeName + "_user"
58+
}
59+
60+
func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
61+
resp.Schema = schema.Schema{
62+
MarkdownDescription: "A user on the Coder deployment.",
63+
64+
Attributes: map[string]schema.Attribute{
65+
"id": schema.StringAttribute{
66+
Computed: true,
67+
MarkdownDescription: "User ID",
68+
PlanModifiers: []planmodifier.String{
69+
stringplanmodifier.UseStateForUnknown(),
70+
},
71+
},
72+
73+
"username": schema.StringAttribute{
74+
MarkdownDescription: "Username of the user.",
75+
Required: true,
76+
},
77+
"name": schema.StringAttribute{
78+
Computed: true,
79+
MarkdownDescription: "Display name of the user. Defaults to username.",
80+
Required: false,
81+
Optional: true,
82+
},
83+
"email": schema.StringAttribute{
84+
MarkdownDescription: "Email address of the user.",
85+
Required: true,
86+
},
87+
"roles": schema.SetAttribute{
88+
MarkdownDescription: "Roles assigned to the user. Valid roles are 'owner', 'template-admin', 'user-admin', and 'auditor'.",
89+
Required: false,
90+
Optional: true,
91+
Computed: true,
92+
ElementType: types.StringType,
93+
Validators: []validator.Set{
94+
setvalidator.ValueStringsAre(
95+
stringvalidator.OneOf("owner", "template-admin", "user-admin", "auditor"),
96+
),
97+
},
98+
Default: setdefault.StaticValue(types.SetValueMust(types.StringType, []attr.Value{})),
99+
},
100+
"login_type": schema.StringAttribute{
101+
MarkdownDescription: "Type of login for the user. Valid types are 'none', 'password', 'github', and 'oidc'.",
102+
Required: false,
103+
Optional: true,
104+
Computed: true,
105+
Validators: []validator.String{
106+
stringvalidator.OneOf("none", "password", "github", "oidc"),
107+
},
108+
Default: stringdefault.StaticString("none"),
109+
},
110+
"password": schema.StringAttribute{
111+
MarkdownDescription: "Password for the user. Required when login_type is 'password'. Passwords are saved into the state as plain text and should only be used for testing purposes.",
112+
Required: false,
113+
Optional: true,
114+
Sensitive: true,
115+
},
116+
"suspended": schema.BoolAttribute{
117+
Computed: true,
118+
MarkdownDescription: "Whether the user is suspended.",
119+
Required: false,
120+
Optional: true,
121+
Default: booldefault.StaticBool(false),
122+
},
123+
},
124+
}
125+
}
126+
127+
func (r *UserResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
128+
// Prevent panic if the provider has not been configured.
129+
if req.ProviderData == nil {
130+
return
131+
}
132+
133+
client, ok := req.ProviderData.(*CoderdProviderData)
134+
135+
if !ok {
136+
resp.Diagnostics.AddError(
137+
"Unexpected Resource Configure Type",
138+
fmt.Sprintf("Expected *codersdk.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData),
139+
)
140+
141+
return
142+
}
143+
144+
r.data = client
145+
}
146+
147+
func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
148+
var data UserResourceModel
149+
150+
// Read Terraform plan data into the model
151+
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
152+
if resp.Diagnostics.HasError() {
153+
return
154+
}
155+
156+
client := r.data.Client
157+
158+
me, err := client.User(ctx, codersdk.Me)
159+
if err != nil {
160+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err))
161+
return
162+
}
163+
if len(me.OrganizationIDs) < 1 {
164+
resp.Diagnostics.AddError("Client Error", "User is not associated with any organizations")
165+
return
166+
}
167+
168+
tflog.Trace(ctx, "creating user")
169+
loginType := codersdk.LoginTypeNone
170+
if data.LoginType.ValueString() != "" {
171+
loginType = codersdk.LoginType(data.LoginType.ValueString())
172+
}
173+
user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
174+
Email: data.Email.ValueString(),
175+
Username: data.Username.ValueString(),
176+
Password: data.Password.ValueString(),
177+
UserLoginType: loginType,
178+
OrganizationID: me.OrganizationIDs[0],
179+
})
180+
if err != nil {
181+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create user, got error: %s", err))
182+
return
183+
}
184+
tflog.Trace(ctx, "successfully created user", map[string]any{
185+
"id": user.ID.String(),
186+
})
187+
data.ID = types.StringValue(user.ID.String())
188+
189+
tflog.Trace(ctx, "updating user profile")
190+
name := data.Username.ValueString()
191+
if data.Name.ValueString() != "" {
192+
name = data.Name.ValueString()
193+
}
194+
user, err = client.UpdateUserProfile(ctx, user.ID.String(), codersdk.UpdateUserProfileRequest{
195+
Username: data.Username.ValueString(),
196+
Name: name,
197+
})
198+
if err != nil {
199+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update newly created user profile, got error: %s", err))
200+
return
201+
}
202+
tflog.Trace(ctx, "successfully updated user profile")
203+
204+
var roles []string
205+
resp.Diagnostics.Append(
206+
data.Roles.ElementsAs(ctx, &roles, false)...,
207+
)
208+
tflog.Trace(ctx, "updating user roles", map[string]any{
209+
"new_roles": roles,
210+
})
211+
user, err = client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{
212+
Roles: roles,
213+
})
214+
if err != nil {
215+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update newly created user roles, got error: %s", err))
216+
return
217+
}
218+
tflog.Trace(ctx, "successfully updated user roles")
219+
220+
if data.Suspended.ValueBool() {
221+
_, err = client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("suspended"))
222+
}
223+
if err != nil {
224+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user status, got error: %s", err))
225+
return
226+
}
227+
// Save data into Terraform state
228+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
229+
}
230+
231+
func (r *UserResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
232+
var data UserResourceModel
233+
234+
// Read Terraform prior state data into the model
235+
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
236+
237+
if resp.Diagnostics.HasError() {
238+
return
239+
}
240+
241+
client := r.data.Client
242+
243+
user, err := client.User(ctx, data.ID.ValueString())
244+
if err != nil {
245+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err))
246+
return
247+
}
248+
if len(user.OrganizationIDs) < 1 {
249+
resp.Diagnostics.AddError("Client Error", "User is not associated with any organizations")
250+
return
251+
}
252+
253+
data.Email = types.StringValue(user.Email)
254+
data.Name = types.StringValue(user.Name)
255+
data.Username = types.StringValue(user.Username)
256+
roles := make([]attr.Value, 0, len(user.Roles))
257+
for _, role := range user.Roles {
258+
roles = append(roles, types.StringValue(role.Name))
259+
}
260+
data.Roles = types.SetValueMust(types.StringType, roles)
261+
data.LoginType = types.StringValue(string(user.LoginType))
262+
data.Suspended = types.BoolValue(user.Status == codersdk.UserStatusSuspended)
263+
264+
// Save updated data into Terraform state
265+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
266+
}
267+
268+
func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
269+
var data UserResourceModel
270+
271+
// Read Terraform plan data into the model
272+
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
273+
274+
if resp.Diagnostics.HasError() {
275+
return
276+
}
277+
278+
client := r.data.Client
279+
280+
user, err := client.User(ctx, data.ID.ValueString())
281+
if err != nil {
282+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err))
283+
return
284+
}
285+
if len(user.OrganizationIDs) < 1 {
286+
resp.Diagnostics.AddError("Client Error", "User is not associated with any organizations")
287+
return
288+
}
289+
290+
tflog.Trace(ctx, "updating user", map[string]any{
291+
"new_username": data.Username.ValueString(),
292+
"new_name": data.Name.ValueString(),
293+
})
294+
_, err = client.UpdateUserProfile(ctx, user.ID.String(), codersdk.UpdateUserProfileRequest{
295+
Username: data.Username.ValueString(),
296+
Name: data.Name.ValueString(),
297+
})
298+
if err != nil {
299+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user profile, got error: %s", err))
300+
return
301+
}
302+
tflog.Trace(ctx, "successfully updated user profile")
303+
304+
var roles []string
305+
resp.Diagnostics.Append(
306+
data.Roles.ElementsAs(ctx, &roles, false)...,
307+
)
308+
tflog.Trace(ctx, "updating user roles", map[string]any{
309+
"new_roles": roles,
310+
})
311+
_, err = client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{
312+
Roles: roles,
313+
})
314+
if err != nil {
315+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user roles, got error: %s", err))
316+
return
317+
}
318+
tflog.Trace(ctx, "successfully updated user roles")
319+
320+
tflog.Trace(ctx, "updating password")
321+
err = client.UpdateUserPassword(ctx, user.ID.String(), codersdk.UpdateUserPasswordRequest{
322+
Password: data.Password.ValueString(),
323+
})
324+
if err != nil && !strings.Contains(err.Error(), "New password cannot match old password.") {
325+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update password, got error: %s", err))
326+
return
327+
}
328+
tflog.Trace(ctx, "successfully updated password")
329+
330+
var statusErr error
331+
if data.Suspended.ValueBool() {
332+
_, statusErr = client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("suspended"))
333+
}
334+
if !data.Suspended.ValueBool() && user.Status == codersdk.UserStatusSuspended {
335+
_, statusErr = client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("active"))
336+
}
337+
if statusErr != nil {
338+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user status, got error: %s", err))
339+
return
340+
}
341+
342+
// Save updated data into Terraform state
343+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
344+
}
345+
346+
func (r *UserResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
347+
var data UserResourceModel
348+
349+
// Read Terraform prior state data into the model
350+
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
351+
352+
if resp.Diagnostics.HasError() {
353+
return
354+
}
355+
356+
client := r.data.Client
357+
358+
id, err := uuid.Parse(data.ID.ValueString())
359+
if err != nil {
360+
resp.Diagnostics.AddError("Data Error", fmt.Sprintf("Unable to parse user ID, got error: %s", err))
361+
return
362+
}
363+
tflog.Trace(ctx, "deleting user")
364+
err = client.DeleteUser(ctx, id)
365+
if err != nil {
366+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete user, got error: %s", err))
367+
return
368+
}
369+
tflog.Trace(ctx, "successfully deleted user")
370+
}
371+
372+
func (r *UserResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
373+
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
374+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package provider
5+
6+
/*
7+
import (
8+
"fmt"
9+
"strings"
10+
"testing"
11+
12+
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
13+
)
14+
15+
func TestAccUserResource(t *testing.T) {
16+
resource.Test(t, resource.TestCase{
17+
PreCheck: func() { testAccPreCheck(t) },
18+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
19+
Steps: []resource.TestStep{
20+
// Create and Read testing
21+
{
22+
Config: testAccUserResourceConfig{
23+
Username: "example",
24+
Name: "Example User",
25+
Email: "example@coder.com",
26+
Roles: []string{"owner", "auditor"},
27+
LoginType: "password",
28+
Password: "SomeSecurePassword!",
29+
}.String(),
30+
Check: resource.ComposeAggregateTestCheckFunc(
31+
resource.TestCheckResourceAttr("coderd_user.test", "username", "example"),
32+
resource.TestCheckResourceAttr("coderd_user.test", "name", "Example User"),
33+
resource.TestCheckResourceAttr("coderd_user.test", "email", "example@coder.com"),
34+
resource.TestCheckResourceAttr("coderd_user.test", "roles.#", "2"),
35+
resource.TestCheckResourceAttr("coderd_user.test", "roles.0", "auditor"),
36+
resource.TestCheckResourceAttr("coderd_user.test", "roles.1", "owner"),
37+
resource.TestCheckResourceAttr("coderd_user.test", "login_type", "password"),
38+
resource.TestCheckResourceAttr("coderd_user.test", "password", "SomeSecurePassword!"),
39+
resource.TestCheckResourceAttr("coderd_user.test", "suspended", "false"),
40+
),
41+
},
42+
// ImportState testing
43+
{
44+
ResourceName: "coderd_user.test",
45+
ImportState: true,
46+
ImportStateVerify: true,
47+
// This is not normally necessary, but is here because this
48+
// example code does not have an actual upstream service.
49+
// Once the Read method is able to refresh information from
50+
// the upstream service, this can be removed.
51+
ImportStateVerifyIgnore: []string{"configurable_attribute", "defaulted", "password"},
52+
},
53+
// Update and Read testing
54+
{
55+
Config: testAccUserResourceConfig{
56+
Username: "exampleNew",
57+
Name: "Example User New",
58+
Email: "example@coder.com",
59+
Roles: []string{"owner", "auditor"},
60+
LoginType: "password",
61+
Password: "SomeSecurePassword!",
62+
}.String(),
63+
Check: resource.ComposeAggregateTestCheckFunc(
64+
resource.TestCheckResourceAttr("coderd_user.test", "username", "exampleNew"),
65+
resource.TestCheckResourceAttr("coderd_user.test", "name", "Example User New"),
66+
),
67+
},
68+
// Delete testing automatically occurs in TestCase
69+
},
70+
})
71+
}
72+
73+
type testAccUserResourceConfig struct {
74+
Username string
75+
Name string
76+
Email string
77+
Roles []string
78+
LoginType string
79+
Password string
80+
Suspended bool
81+
}
82+
83+
func (c testAccUserResourceConfig) String() string {
84+
sb := strings.Builder{}
85+
sb.WriteString(`resource "coderd_user" "test" {` + "\n")
86+
sb.WriteString(fmt.Sprintf(" username = %q\n", c.Username))
87+
if c.Name != "" {
88+
sb.WriteString(fmt.Sprintf(" name = %q\n", c.Name))
89+
}
90+
sb.WriteString(fmt.Sprintf(" email = %q\n", c.Email))
91+
if len(c.Roles) > 0 {
92+
rolesQuoted := make([]string, len(c.Roles))
93+
for i, role := range c.Roles {
94+
rolesQuoted[i] = fmt.Sprintf("%q", role)
95+
}
96+
sb.WriteString(fmt.Sprintf(" roles = [%s]\n", strings.Join(rolesQuoted, ", ")))
97+
}
98+
if c.LoginType != "" {
99+
sb.WriteString(fmt.Sprintf(" login_type = %q\n", c.LoginType))
100+
}
101+
if c.Password != "" {
102+
sb.WriteString(fmt.Sprintf(" password = %q\n", c.Password))
103+
}
104+
if c.Suspended {
105+
sb.WriteString(" suspended = true\n")
106+
}
107+
sb.WriteString(`}`)
108+
return sb.String()
109+
}
110+
*/

0 commit comments

Comments
 (0)
Please sign in to comment.