From f8f4e4bf049203cbc93c582c2a02020cd59ee5a2 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 11 Jul 2024 13:24:40 +0000 Subject: [PATCH 1/5] feat: add coderd_user resource Co-authored-by: Ethan Dickson --- .gitignore | 4 +- go.mod | 3 +- go.sum | 4 + integration/example-test/main.tf | 12 - integration/integration_test.go | 43 ++- integration/user-test/main.tf | 18 ++ internal/provider/example_function.go | 50 --- internal/provider/example_function_test.go | 78 ----- internal/provider/example_resource.go | 187 ----------- internal/provider/example_resource_test.go | 56 ---- internal/provider/provider.go | 90 +++++- internal/provider/user_resource.go | 348 +++++++++++++++++++++ internal/provider/user_resource_test.go | 103 ++++++ 13 files changed, 596 insertions(+), 400 deletions(-) delete mode 100644 integration/example-test/main.tf create mode 100644 integration/user-test/main.tf delete mode 100644 internal/provider/example_function.go delete mode 100644 internal/provider/example_function_test.go delete mode 100644 internal/provider/example_resource.go delete mode 100644 internal/provider/example_resource_test.go create mode 100644 internal/provider/user_resource.go create mode 100644 internal/provider/user_resource_test.go diff --git a/.gitignore b/.gitignore index b4dee9d..725d809 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,6 @@ website/vendor terraform-provider-coderd # Needs to be written on each invocation -integration/integration.tfrc \ No newline at end of file +integration/integration.tfrc + +*.tfstate diff --git a/go.mod b/go.mod index 946dab7..cfaade0 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/docker/docker v26.1.4+incompatible github.com/docker/go-connections v0.4.0 github.com/hashicorp/terraform-plugin-docs v0.19.4 - github.com/hashicorp/terraform-plugin-framework v1.9.0 + github.com/hashicorp/terraform-plugin-framework v1.10.0 github.com/hashicorp/terraform-plugin-go v0.23.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-testing v1.8.0 @@ -78,6 +78,7 @@ require ( github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.21.0 // indirect github.com/hashicorp/terraform-json v0.22.1 // indirect + github.com/hashicorp/terraform-plugin-framework-validators v0.13.0 // indirect github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.3 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect diff --git a/go.sum b/go.sum index 62c733c..c97b82c 100644 --- a/go.sum +++ b/go.sum @@ -237,6 +237,10 @@ github.com/hashicorp/terraform-plugin-docs v0.19.4 h1:G3Bgo7J22OMtegIgn8Cd/CaSey github.com/hashicorp/terraform-plugin-docs v0.19.4/go.mod h1:4pLASsatTmRynVzsjEhbXZ6s7xBlUw/2Kt0zfrq8HxA= github.com/hashicorp/terraform-plugin-framework v1.9.0 h1:caLcDoxiRucNi2hk8+j3kJwkKfvHznubyFsJMWfZqKU= github.com/hashicorp/terraform-plugin-framework v1.9.0/go.mod h1:qBXLDn69kM97NNVi/MQ9qgd1uWWsVftGSnygYG1tImM= +github.com/hashicorp/terraform-plugin-framework v1.10.0 h1:xXhICE2Fns1RYZxEQebwkB2+kXouLC932Li9qelozrc= +github.com/hashicorp/terraform-plugin-framework v1.10.0/go.mod h1:qBXLDn69kM97NNVi/MQ9qgd1uWWsVftGSnygYG1tImM= +github.com/hashicorp/terraform-plugin-framework-validators v0.13.0 h1:bxZfGo9DIUoLLtHMElsu+zwqI4IsMZQBRRy4iLzZJ8E= +github.com/hashicorp/terraform-plugin-framework-validators v0.13.0/go.mod h1:wGeI02gEhj9nPANU62F2jCaHjXulejm/X+af4PdZaNo= github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= diff --git a/integration/example-test/main.tf b/integration/example-test/main.tf deleted file mode 100644 index 35c3817..0000000 --- a/integration/example-test/main.tf +++ /dev/null @@ -1,12 +0,0 @@ -terraform { - required_providers { - coderd = { - source = "coder/coderd" - version = ">=0.0.0" - } - } -} - -resource "coderd_example" "example" { - configurable_attribute = "some-value" -} diff --git a/integration/integration_test.go b/integration/integration_test.go index 58cfc8b..a038274 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "io" "net" "net/url" "os" @@ -15,6 +16,7 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" "github.com/docker/docker/client" "github.com/docker/go-connections/nat" "github.com/stretchr/testify/assert" @@ -50,11 +52,33 @@ func TestIntegration(t *testing.T) { assertF func(testing.TB, *codersdk.Client) }{ { - name: "example-test", + name: "user-test", assertF: func(t testing.TB, c *codersdk.Client) { - me, err := c.User(ctx, codersdk.Me) + // Check user fields. + user, err := c.User(ctx, "dean") assert.NoError(t, err) - assert.NotEmpty(t, me) + assert.Equal(t, "dean", user.Username) + assert.Equal(t, "Dean Coolguy", user.Name) + assert.Equal(t, "test@coder.com", user.Email) + roles := make([]string, len(user.Roles)) + for i, role := range user.Roles { + roles[i] = role.Name + } + assert.ElementsMatch(t, []string{"owner", "template-admin"}, roles) + assert.Equal(t, codersdk.LoginTypePassword, user.LoginType) + assert.Contains(t, []codersdk.UserStatus{codersdk.UserStatusActive, codersdk.UserStatusDormant}, user.Status) + + // Test password. + newClient := codersdk.New(c.URL) + res, err := newClient.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: "test@coder.com", + Password: "SomeSecurePassword!", + }) + assert.NoError(t, err) + newClient.SetSessionToken(res.SessionToken) + user, err = newClient.User(ctx, codersdk.Me) + assert.NoError(t, err) + assert.Equal(t, "dean", user.Username) }, }, } { @@ -63,6 +87,14 @@ func TestIntegration(t *testing.T) { wd, err := os.Getwd() require.NoError(t, err) srcDir := filepath.Join(wd, tt.name) + // Delete all .tfstate files + err = filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if filepath.Ext(path) == ".tfstate" { + return os.Remove(path) + } + return nil + }) + require.NoError(t, err) tfCmd := exec.CommandContext(ctx, "terraform", "-chdir="+srcDir, "apply", "-auto-approve") tfCmd.Env = append(tfCmd.Env, "TF_CLI_CONFIG_FILE="+tfrcPath) 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 p := randomPort(t) t.Logf("random port is %d", p) // Stand up a temporary Coder instance + puller, err := cli.ImagePull(ctx, coderImg+":"+coderVersion, image.PullOptions{}) + require.NoError(t, err, "pull coder image") + defer puller.Close() + _, err = io.Copy(os.Stderr, puller) + require.NoError(t, err, "pull coder image") ctr, err := cli.ContainerCreate(ctx, &container.Config{ Image: coderImg + ":" + coderVersion, Env: []string{ diff --git a/integration/user-test/main.tf b/integration/user-test/main.tf new file mode 100644 index 0000000..34174b2 --- /dev/null +++ b/integration/user-test/main.tf @@ -0,0 +1,18 @@ +terraform { + required_providers { + coderd = { + source = "coder/coderd" + version = ">=0.0.0" + } + } +} + +resource "coderd_user" "dean" { + username = "dean" + name = "Dean Coolguy" + email = "test@coder.com" + roles = ["owner", "template-admin"] + login_type = "password" + password = "SomeSecurePassword!" + suspended = false +} diff --git a/internal/provider/example_function.go b/internal/provider/example_function.go deleted file mode 100644 index 1106e7d..0000000 --- a/internal/provider/example_function.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package provider - -import ( - "context" - - "github.com/hashicorp/terraform-plugin-framework/function" -) - -var ( - _ function.Function = ExampleFunction{} -) - -func NewExampleFunction() function.Function { - return ExampleFunction{} -} - -type ExampleFunction struct{} - -func (r ExampleFunction) Metadata(_ context.Context, req function.MetadataRequest, resp *function.MetadataResponse) { - resp.Name = "example" -} - -func (r ExampleFunction) Definition(_ context.Context, _ function.DefinitionRequest, resp *function.DefinitionResponse) { - resp.Definition = function.Definition{ - Summary: "Example function", - MarkdownDescription: "Echoes given argument as result", - Parameters: []function.Parameter{ - function.StringParameter{ - Name: "input", - MarkdownDescription: "String to echo", - }, - }, - Return: function.StringReturn{}, - } -} - -func (r ExampleFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) { - var data string - - resp.Error = function.ConcatFuncErrors(req.Arguments.Get(ctx, &data)) - - if resp.Error != nil { - return - } - - resp.Error = function.ConcatFuncErrors(resp.Result.Set(ctx, data)) -} diff --git a/internal/provider/example_function_test.go b/internal/provider/example_function_test.go deleted file mode 100644 index b573c0e..0000000 --- a/internal/provider/example_function_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package provider - -import ( - "regexp" - "testing" - - "github.com/hashicorp/terraform-plugin-testing/helper/resource" - "github.com/hashicorp/terraform-plugin-testing/tfversion" -) - -func TestExampleFunction_Known(t *testing.T) { - resource.UnitTest(t, resource.TestCase{ - TerraformVersionChecks: []tfversion.TerraformVersionCheck{ - tfversion.SkipBelow(tfversion.Version1_8_0), - }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: ` - output "test" { - value = provider::coderd::example("testvalue") - } - `, - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckOutput("test", "testvalue"), - ), - }, - }, - }) -} - -func TestExampleFunction_Null(t *testing.T) { - resource.UnitTest(t, resource.TestCase{ - TerraformVersionChecks: []tfversion.TerraformVersionCheck{ - tfversion.SkipBelow(tfversion.Version1_8_0), - }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: ` - output "test" { - value = provider::coderd::example(null) - } - `, - // The parameter does not enable AllowNullValue - ExpectError: regexp.MustCompile(`argument must not be null`), - }, - }, - }) -} - -func TestExampleFunction_Unknown(t *testing.T) { - resource.UnitTest(t, resource.TestCase{ - TerraformVersionChecks: []tfversion.TerraformVersionCheck{ - tfversion.SkipBelow(tfversion.Version1_8_0), - }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - Config: ` - resource "terraform_data" "test" { - input = "testvalue" - } - - output "test" { - value = provider::coderd::example(terraform_data.test.output) - } - `, - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckOutput("test", "testvalue"), - ), - }, - }, - }) -} diff --git a/internal/provider/example_resource.go b/internal/provider/example_resource.go deleted file mode 100644 index 70e961a..0000000 --- a/internal/provider/example_resource.go +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package provider - -import ( - "context" - "fmt" - "net/http" - - "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 = &ExampleResource{} -var _ resource.ResourceWithImportState = &ExampleResource{} - -func NewExampleResource() resource.Resource { - return &ExampleResource{} -} - -// ExampleResource defines the resource implementation. -type ExampleResource struct { - client *http.Client -} - -// ExampleResourceModel describes the resource data model. -type ExampleResourceModel struct { - ConfigurableAttribute types.String `tfsdk:"configurable_attribute"` - Defaulted types.String `tfsdk:"defaulted"` - Id types.String `tfsdk:"id"` -} - -func (r *ExampleResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_example" -} - -func (r *ExampleResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - // This description is used by the documentation generator and the language server. - MarkdownDescription: "Example resource", - - Attributes: map[string]schema.Attribute{ - "configurable_attribute": schema.StringAttribute{ - MarkdownDescription: "Example configurable attribute", - Optional: true, - }, - "defaulted": schema.StringAttribute{ - MarkdownDescription: "Example configurable attribute with default value", - Optional: true, - Computed: true, - Default: stringdefault.StaticString("example value when not configured"), - }, - "id": schema.StringAttribute{ - Computed: true, - MarkdownDescription: "Example identifier", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - }, - } -} - -func (r *ExampleResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - // Prevent panic if the provider has not been configured. - if req.ProviderData == nil { - return - } - - client, ok := req.ProviderData.(*http.Client) - - if !ok { - resp.Diagnostics.AddError( - "Unexpected Resource Configure Type", - fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), - ) - - return - } - - r.client = client -} - -func (r *ExampleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data ExampleResourceModel - - // Read Terraform plan data into the model - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - - if resp.Diagnostics.HasError() { - return - } - - // If applicable, this is a great opportunity to initialize any necessary - // provider client data and make a call using it. - // httpResp, err := r.client.Do(httpReq) - // if err != nil { - // resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create example, got error: %s", err)) - // return - // } - - // For the purposes of this example code, hardcoding a response value to - // save into the Terraform state. - data.Id = types.StringValue("example-id") - - // Write logs using the tflog package - // Documentation: https://terraform.io/plugin/log - tflog.Trace(ctx, "created a resource") - - // Save data into Terraform state - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *ExampleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var data ExampleResourceModel - - // Read Terraform prior state data into the model - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - - if resp.Diagnostics.HasError() { - return - } - - // If applicable, this is a great opportunity to initialize any necessary - // provider client data and make a call using it. - // httpResp, err := r.client.Do(httpReq) - // if err != nil { - // resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read example, got error: %s", err)) - // return - // } - - // Save updated data into Terraform state - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *ExampleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var data ExampleResourceModel - - // Read Terraform plan data into the model - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - - if resp.Diagnostics.HasError() { - return - } - - // If applicable, this is a great opportunity to initialize any necessary - // provider client data and make a call using it. - // httpResp, err := r.client.Do(httpReq) - // if err != nil { - // resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update example, got error: %s", err)) - // return - // } - - // Save updated data into Terraform state - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *ExampleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var data ExampleResourceModel - - // Read Terraform prior state data into the model - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - - if resp.Diagnostics.HasError() { - return - } - - // If applicable, this is a great opportunity to initialize any necessary - // provider client data and make a call using it. - // httpResp, err := r.client.Do(httpReq) - // if err != nil { - // resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete example, got error: %s", err)) - // return - // } -} - -func (r *ExampleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) -} diff --git a/internal/provider/example_resource_test.go b/internal/provider/example_resource_test.go deleted file mode 100644 index 3c15e5f..0000000 --- a/internal/provider/example_resource_test.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package provider - -import ( - "fmt" - "testing" - - "github.com/hashicorp/terraform-plugin-testing/helper/resource" -) - -func TestAccExampleResource(t *testing.T) { - resource.Test(t, resource.TestCase{ - PreCheck: func() { testAccPreCheck(t) }, - ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - // Create and Read testing - { - Config: testAccExampleResourceConfig("one"), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_example.test", "configurable_attribute", "one"), - resource.TestCheckResourceAttr("coderd_example.test", "defaulted", "example value when not configured"), - resource.TestCheckResourceAttr("coderd_example.test", "id", "example-id"), - ), - }, - // ImportState testing - { - ResourceName: "coderd_example.test", - ImportState: true, - ImportStateVerify: true, - // This is not normally necessary, but is here because this - // example code does not have an actual upstream service. - // Once the Read method is able to refresh information from - // the upstream service, this can be removed. - ImportStateVerifyIgnore: []string{"configurable_attribute", "defaulted"}, - }, - // Update and Read testing - { - Config: testAccExampleResourceConfig("two"), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_example.test", "configurable_attribute", "two"), - ), - }, - // Delete testing automatically occurs in TestCase - }, - }) -} - -func testAccExampleResourceConfig(configurableAttribute string) string { - return fmt.Sprintf(` -resource "coderd_example" "test" { - configurable_attribute = %[1]q -} -`, configurableAttribute) -} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index c67f4ad..abc76ed 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -5,14 +5,20 @@ package provider import ( "context" - "net/http" + "net/url" + "os" + "strings" + "cdr.dev/slog" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/function" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/coder/coder/v2/codersdk" ) // Ensure CoderdProvider satisfies various provider interfaces. @@ -29,7 +35,8 @@ type CoderdProvider struct { // CoderdProviderModel describes the provider data model. type CoderdProviderModel struct { - Endpoint types.String `tfsdk:"endpoint"` + URL types.String `tfsdk:"url"` + Token types.String `tfsdk:"token"` } func (p *CoderdProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { @@ -40,8 +47,12 @@ func (p *CoderdProvider) Metadata(ctx context.Context, req provider.MetadataRequ func (p *CoderdProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ - "endpoint": schema.StringAttribute{ - MarkdownDescription: "Example provider attribute", + "url": schema.StringAttribute{ + MarkdownDescription: "URL to the Coder deployment. Defaults to $CODER_URL.", + Optional: true, + }, + "token": schema.StringAttribute{ + MarkdownDescription: "API token for communicating with the deployment. Most resource types require elevated permissions. Defaults to $CODER_SESSION_TOKEN.", Optional: true, }, }, @@ -57,18 +68,38 @@ func (p *CoderdProvider) Configure(ctx context.Context, req provider.ConfigureRe return } - // Configuration values are now available. - // if data.Endpoint.IsNull() { /* ... */ } + if data.URL.ValueString() == "" { + urlEnv, ok := os.LookupEnv("CODER_URL") + if !ok { + resp.Diagnostics.AddError("url", "url or $CODER_URL is required") + return + } + data.URL = types.StringValue(urlEnv) + } + if data.Token.ValueString() == "" { + tokenEnv, ok := os.LookupEnv("CODER_SESSION_TOKEN") + if !ok { + resp.Diagnostics.AddError("token", "token or $CODER_SESSION_TOKEN is required") + return + } + data.Token = types.StringValue(tokenEnv) + } - // Example client configuration for data sources and resources - client := http.DefaultClient + url, err := url.Parse(data.URL.ValueString()) + if err != nil { + resp.Diagnostics.AddError("url", "url is not a valid URL: "+err.Error()) + return + } + client := codersdk.New(url) + client.SetLogger(slog.Make(tfslog{}).Leveled(slog.LevelDebug)) + client.SetSessionToken(data.Token.ValueString()) resp.DataSourceData = client resp.ResourceData = client } func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ - NewExampleResource, + NewUserResource, } } @@ -79,9 +110,7 @@ func (p *CoderdProvider) DataSources(ctx context.Context) []func() datasource.Da } func (p *CoderdProvider) Functions(ctx context.Context) []func() function.Function { - return []func() function.Function{ - NewExampleFunction, - } + return []func() function.Function{} } func New(version string) func() provider.Provider { @@ -91,3 +120,40 @@ func New(version string) func() provider.Provider { } } } + +// tfslog redirects slog entries to tflog. +type tfslog struct{} + +var _ slog.Sink = tfslog{} + +// LogEntry implements slog.Sink. +func (t tfslog) LogEntry(ctx context.Context, e slog.SinkEntry) { + m := map[string]any{ + "time": e.Time.Unix(), + "func": e.Func, + "file": e.File, + "line": e.Line, + } + for _, f := range e.Fields { + m[f.Name] = f.Value + } + + msg := e.Message + if len(e.LoggerNames) > 0 { + msg = "[" + strings.Join(e.LoggerNames, ".") + "] " + msg + } + + switch e.Level { + case slog.LevelDebug: + tflog.Debug(ctx, msg, m) + case slog.LevelInfo: + tflog.Info(ctx, msg, m) + case slog.LevelWarn: + tflog.Warn(ctx, msg, m) + case slog.LevelError, slog.LevelFatal: + tflog.Error(ctx, msg, m) + } +} + +// Sync implements slog.Sink. +func (t tfslog) Sync() {} diff --git a/internal/provider/user_resource.go b/internal/provider/user_resource.go new file mode 100644 index 0000000..c32da13 --- /dev/null +++ b/internal/provider/user_resource.go @@ -0,0 +1,348 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" + "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/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/coder/coder/v2/codersdk" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &UserResource{} +var _ resource.ResourceWithImportState = &UserResource{} + +func NewUserResource() resource.Resource { + return &UserResource{} +} + +// UserResource defines the resource implementation. +type UserResource struct { + client *codersdk.Client +} + +// UserResourceModel describes the resource data model. +type UserResourceModel struct { + ID types.String `tfsdk:"id"` + + Username types.String `tfsdk:"username"` + Name types.String `tfsdk:"name"` + Email types.String `tfsdk:"email"` + Roles types.List `tfsdk:"roles"` // owner, template-admin, user-admin, auditor (member is implicit) + LoginType types.String `tfsdk:"login_type"` // none, password, github, oidc + Password types.String `tfsdk:"password"` // only when login_type is password + Suspended types.Bool `tfsdk:"suspended"` +} + +func (r *UserResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_user" +} + +func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "A user on the Coder deployment.", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "User ID", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + + "username": schema.StringAttribute{ + MarkdownDescription: "Username of the user.", + Required: true, + }, + "name": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Display name of the user. Defaults to username.", + Required: false, + Optional: true, + }, + "email": schema.StringAttribute{ + MarkdownDescription: "Email address of the user.", + Required: true, + }, + "roles": schema.ListAttribute{ + MarkdownDescription: "Roles assigned to the user. Valid roles are 'owner', 'template-admin', 'user-admin', and 'auditor'.", + Required: false, + Optional: true, + Computed: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.UniqueValues(), + listvalidator.ValueStringsAre( + stringvalidator.OneOf("owner", "template-admin", "user-admin", "auditor"), + ), + }, + Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + }, + "login_type": schema.StringAttribute{ + MarkdownDescription: "Type of login for the user. Valid types are 'none', 'password', 'github', and 'oidc'.", + Required: false, + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf("none", "password", "github", "oidc"), + }, + Default: stringdefault.StaticString("none"), + }, + "password": schema.StringAttribute{ + 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.", + Required: false, + Optional: true, + Sensitive: true, + }, + "suspended": schema.BoolAttribute{ + Computed: true, + MarkdownDescription: "Whether the user is suspended.", + Required: false, + Optional: true, + Default: booldefault.StaticBool(false), + }, + }, + } +} + +func (r *UserResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*codersdk.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *codersdk.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data UserResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + me, err := r.client.User(ctx, codersdk.Me) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err)) + return + } + if len(me.OrganizationIDs) < 1 { + resp.Diagnostics.AddError("Client Error", "User is not associated with any organizations") + return + } + + tflog.Trace(ctx, "creating user") + loginType := codersdk.LoginTypeNone + if data.LoginType.ValueString() != "" { + loginType = codersdk.LoginType(data.LoginType.ValueString()) + } + user, err := r.client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: data.Email.ValueString(), + Username: data.Username.ValueString(), + Password: data.Password.ValueString(), + UserLoginType: loginType, + OrganizationID: me.OrganizationIDs[0], + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create user, got error: %s", err)) + return + } + tflog.Trace(ctx, "successfully created user", map[string]any{ + "id": user.ID.String(), + }) + data.ID = types.StringValue(user.ID.String()) + + tflog.Trace(ctx, "updating user profile") + name := data.Username.ValueString() + if data.Name.ValueString() != "" { + name = data.Name.ValueString() + } + user, err = r.client.UpdateUserProfile(ctx, user.ID.String(), codersdk.UpdateUserProfileRequest{ + Username: data.Username.ValueString(), + Name: name, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update newly created user profile, got error: %s", err)) + return + } + tflog.Trace(ctx, "successfully updated user profile") + + roleElements := data.Roles.Elements() + roles := make([]string, 0, len(roleElements)) + for _, role := range roleElements { + roles = append(roles, role.(types.String).ValueString()) + } + tflog.Trace(ctx, "updating user roles", map[string]any{ + "new_roles": roles, + }) + user, err = r.client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{ + Roles: roles, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update newly created user roles, got error: %s", err)) + return + } + tflog.Trace(ctx, "successfully updated user roles") + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *UserResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data UserResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + user, err := r.client.User(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err)) + return + } + if len(user.OrganizationIDs) < 1 { + resp.Diagnostics.AddError("Client Error", "User is not associated with any organizations") + return + } + + data.Email = types.StringValue(user.Email) + data.Name = types.StringValue(user.Name) + roles := make([]attr.Value, 0, len(user.Roles)) + for _, role := range user.Roles { + roles = append(roles, types.StringValue(role.Name)) + } + data.Roles = types.ListValueMust(types.StringType, roles) + data.LoginType = types.StringValue(string(user.LoginType)) + data.Suspended = types.BoolValue(user.Status == codersdk.UserStatusSuspended) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data UserResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + user, err := r.client.User(ctx, data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err)) + return + } + if len(user.OrganizationIDs) < 1 { + resp.Diagnostics.AddError("Client Error", "User is not associated with any organizations") + return + } + + tflog.Trace(ctx, "updating user", map[string]any{ + "new_username": data.Username.ValueString(), + "new_name": data.Name.ValueString(), + }) + _, err = r.client.UpdateUserProfile(ctx, user.ID.String(), codersdk.UpdateUserProfileRequest{ + Username: data.Username.ValueString(), + Name: data.Name.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user profile, got error: %s", err)) + return + } + tflog.Trace(ctx, "successfully updated user profile") + + roleElements := data.Roles.Elements() + roles := make([]string, 0, len(roleElements)) + for _, role := range roleElements { + roles = append(roles, role.(types.String).ValueString()) + } + tflog.Trace(ctx, "updating user roles", map[string]any{ + "new_roles": roles, + }) + _, err = r.client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{ + Roles: roles, + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user roles, got error: %s", err)) + return + } + tflog.Trace(ctx, "successfully updated user roles") + + tflog.Trace(ctx, "updating password") + err = r.client.UpdateUserPassword(ctx, user.ID.String(), codersdk.UpdateUserPasswordRequest{ + Password: data.Password.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update password, got error: %s", err)) + return + } + tflog.Trace(ctx, "successfully updated password") + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *UserResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data UserResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + id, err := uuid.Parse(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Data Error", fmt.Sprintf("Unable to parse user ID, got error: %s", err)) + return + } + tflog.Trace(ctx, "deleting user") + err = r.client.DeleteUser(ctx, id) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete user, got error: %s", err)) + return + } + tflog.Trace(ctx, "successfully deleted user") +} + +func (r *UserResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/internal/provider/user_resource_test.go b/internal/provider/user_resource_test.go new file mode 100644 index 0000000..f17f1d6 --- /dev/null +++ b/internal/provider/user_resource_test.go @@ -0,0 +1,103 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +/* +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccUserResource(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: testAccUserResourceConfig{ + Username: "example", + Name: "Example User", + Email: "example@coder.com", + Roles: []string{"owner", "auditor"}, + LoginType: "password", + Password: "example-password", + Suspended: true, + }.String(), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_user.test", "username", "example"), + 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", "owner"), + resource.TestCheckResourceAttr("coderd_user.test", "roles.1", "auditor"), + resource.TestCheckResourceAttr("coderd_user.test", "login_type", "password"), + resource.TestCheckResourceAttr("coderd_user.test", "password", "example-password"), + resource.TestCheckResourceAttr("coderd_user.test", "suspended", "true"), + ), + }, + // ImportState testing + { + ResourceName: "coderd_user.test", + ImportState: true, + ImportStateVerify: true, + // This is not normally necessary, but is here because this + // example code does not have an actual upstream service. + // Once the Read method is able to refresh information from + // the upstream service, this can be removed. + ImportStateVerifyIgnore: []string{"configurable_attribute", "defaulted"}, + }, + // Update and Read testing + { + Config: testAccUserResourceConfig{} + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("coderd_example.test", "configurable_attribute", "two"), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +type testAccUserResourceConfig struct { + Username string + Name string + Email string + Roles []string + LoginType string + Password string + Suspended bool +} + +func (c testAccUserResourceConfig) String() string { + sb := strings.Builder{} + sb.WriteString(`resource "coderd_user" "test" {` + "\n") + sb.WriteString(fmt.Sprintf(" username = %q\n", c.Username)) + if c.Name != "" { + sb.WriteString(fmt.Sprintf(" name = %q\n", c.Name)) + } + sb.WriteString(fmt.Sprintf(" email = %q\n", c.Email)) + if len(c.Roles) > 0 { + rolesQuoted := make([]string, len(c.Roles)) + for i, role := range c.Roles { + rolesQuoted[i] = fmt.Sprintf("%q", role) + } + sb.WriteString(fmt.Sprintf(" roles = [%s]\n", strings.Join(rolesQuoted, ", "))) + } + if c.LoginType != "" { + sb.WriteString(fmt.Sprintf(" login_type = %q", c.LoginType)) + } + if c.Password != "" { + sb.WriteString(fmt.Sprintf(" password = %q", c.Password)) + } + if c.Suspended { + sb.WriteString(" suspended = true\n") + } + sb.WriteString(`}`) + return sb.String() +} +*/ \ No newline at end of file From d9d25d56e14652beedb06d017793183758751107 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 12 Jul 2024 04:08:23 +0000 Subject: [PATCH 2/5] use elementsAs --- internal/provider/user_resource.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/internal/provider/user_resource.go b/internal/provider/user_resource.go index c32da13..6ab84b9 100644 --- a/internal/provider/user_resource.go +++ b/internal/provider/user_resource.go @@ -199,11 +199,10 @@ func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, r } tflog.Trace(ctx, "successfully updated user profile") - roleElements := data.Roles.Elements() - roles := make([]string, 0, len(roleElements)) - for _, role := range roleElements { - roles = append(roles, role.(types.String).ValueString()) - } + var roles []string + resp.Diagnostics.Append( + data.Roles.ElementsAs(ctx, &roles, false)..., + ) tflog.Trace(ctx, "updating user roles", map[string]any{ "new_roles": roles, }) @@ -288,11 +287,10 @@ func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, r } tflog.Trace(ctx, "successfully updated user profile") - roleElements := data.Roles.Elements() - roles := make([]string, 0, len(roleElements)) - for _, role := range roleElements { - roles = append(roles, role.(types.String).ValueString()) - } + var roles []string + resp.Diagnostics.Append( + data.Roles.ElementsAs(ctx, &roles, false)..., + ) tflog.Trace(ctx, "updating user roles", map[string]any{ "new_roles": roles, }) From 224ef67548930af14ffe66ad84b0766400307450 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 12 Jul 2024 06:32:05 +0000 Subject: [PATCH 3/5] assorted fixes --- internal/provider/user_resource.go | 40 ++++++++++++++++++------- internal/provider/user_resource_test.go | 39 ++++++++++++++---------- 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/internal/provider/user_resource.go b/internal/provider/user_resource.go index 6ab84b9..c921932 100644 --- a/internal/provider/user_resource.go +++ b/internal/provider/user_resource.go @@ -6,17 +6,18 @@ package provider import ( "context" "fmt" + "strings" "github.com/google/uuid" - "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "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/booldefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -46,7 +47,7 @@ type UserResourceModel struct { Username types.String `tfsdk:"username"` Name types.String `tfsdk:"name"` Email types.String `tfsdk:"email"` - Roles types.List `tfsdk:"roles"` // owner, template-admin, user-admin, auditor (member is implicit) + Roles types.Set `tfsdk:"roles"` // owner, template-admin, user-admin, auditor (member is implicit) LoginType types.String `tfsdk:"login_type"` // none, password, github, oidc Password types.String `tfsdk:"password"` // only when login_type is password Suspended types.Bool `tfsdk:"suspended"` @@ -83,19 +84,18 @@ func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, r MarkdownDescription: "Email address of the user.", Required: true, }, - "roles": schema.ListAttribute{ + "roles": schema.SetAttribute{ MarkdownDescription: "Roles assigned to the user. Valid roles are 'owner', 'template-admin', 'user-admin', and 'auditor'.", Required: false, Optional: true, Computed: true, ElementType: types.StringType, - Validators: []validator.List{ - listvalidator.UniqueValues(), - listvalidator.ValueStringsAre( + Validators: []validator.Set{ + setvalidator.ValueStringsAre( stringvalidator.OneOf("owner", "template-admin", "user-admin", "auditor"), ), }, - Default: listdefault.StaticValue(types.ListValueMust(types.StringType, []attr.Value{})), + Default: setdefault.StaticValue(types.SetValueMust(types.StringType, []attr.Value{})), }, "login_type": schema.StringAttribute{ MarkdownDescription: "Type of login for the user. Valid types are 'none', 'password', 'github', and 'oidc'.", @@ -215,6 +215,13 @@ func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, r } tflog.Trace(ctx, "successfully updated user roles") + if data.Suspended.ValueBool() { + _, err = r.client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("suspended")) + } + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user status, got error: %s", err)) + return + } // Save data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -241,11 +248,12 @@ func (r *UserResource) Read(ctx context.Context, req resource.ReadRequest, resp data.Email = types.StringValue(user.Email) data.Name = types.StringValue(user.Name) + data.Username = types.StringValue(user.Username) roles := make([]attr.Value, 0, len(user.Roles)) for _, role := range user.Roles { roles = append(roles, types.StringValue(role.Name)) } - data.Roles = types.ListValueMust(types.StringType, roles) + data.Roles = types.SetValueMust(types.StringType, roles) data.LoginType = types.StringValue(string(user.LoginType)) data.Suspended = types.BoolValue(user.Status == codersdk.UserStatusSuspended) @@ -307,12 +315,24 @@ func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, r err = r.client.UpdateUserPassword(ctx, user.ID.String(), codersdk.UpdateUserPasswordRequest{ Password: data.Password.ValueString(), }) - if err != nil { + if err != nil && !strings.Contains(err.Error(), "New password cannot match old password.") { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update password, got error: %s", err)) return } tflog.Trace(ctx, "successfully updated password") + var statusErr error + if data.Suspended.ValueBool() { + _, statusErr = r.client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("suspended")) + } + if !data.Suspended.ValueBool() && user.Status == codersdk.UserStatusSuspended { + _, statusErr = r.client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("active")) + } + if statusErr != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user status, got error: %s", err)) + return + } + // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } diff --git a/internal/provider/user_resource_test.go b/internal/provider/user_resource_test.go index f17f1d6..eaf720c 100644 --- a/internal/provider/user_resource_test.go +++ b/internal/provider/user_resource_test.go @@ -20,24 +20,23 @@ func TestAccUserResource(t *testing.T) { // Create and Read testing { Config: testAccUserResourceConfig{ - Username: "example", - Name: "Example User", - Email: "example@coder.com", - Roles: []string{"owner", "auditor"}, + Username: "example", + Name: "Example User", + Email: "example@coder.com", + Roles: []string{"owner", "auditor"}, LoginType: "password", - Password: "example-password", - Suspended: true, + Password: "SomeSecurePassword!", }.String(), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("coderd_user.test", "username", "example"), 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", "owner"), - resource.TestCheckResourceAttr("coderd_user.test", "roles.1", "auditor"), + resource.TestCheckResourceAttr("coderd_user.test", "roles.0", "auditor"), + resource.TestCheckResourceAttr("coderd_user.test", "roles.1", "owner"), resource.TestCheckResourceAttr("coderd_user.test", "login_type", "password"), - resource.TestCheckResourceAttr("coderd_user.test", "password", "example-password"), - resource.TestCheckResourceAttr("coderd_user.test", "suspended", "true"), + resource.TestCheckResourceAttr("coderd_user.test", "password", "SomeSecurePassword!"), + resource.TestCheckResourceAttr("coderd_user.test", "suspended", "false"), ), }, // ImportState testing @@ -49,13 +48,21 @@ func TestAccUserResource(t *testing.T) { // example code does not have an actual upstream service. // Once the Read method is able to refresh information from // the upstream service, this can be removed. - ImportStateVerifyIgnore: []string{"configurable_attribute", "defaulted"}, + ImportStateVerifyIgnore: []string{"configurable_attribute", "defaulted", "password"}, }, // Update and Read testing { - Config: testAccUserResourceConfig{} + Config: testAccUserResourceConfig{ + Username: "exampleNew", + Name: "Example User New", + Email: "example@coder.com", + Roles: []string{"owner", "auditor"}, + LoginType: "password", + Password: "SomeSecurePassword!", + }.String(), Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("coderd_example.test", "configurable_attribute", "two"), + resource.TestCheckResourceAttr("coderd_user.test", "username", "exampleNew"), + resource.TestCheckResourceAttr("coderd_user.test", "name", "Example User New"), ), }, // Delete testing automatically occurs in TestCase @@ -89,10 +96,10 @@ func (c testAccUserResourceConfig) String() string { sb.WriteString(fmt.Sprintf(" roles = [%s]\n", strings.Join(rolesQuoted, ", "))) } if c.LoginType != "" { - sb.WriteString(fmt.Sprintf(" login_type = %q", c.LoginType)) + sb.WriteString(fmt.Sprintf(" login_type = %q\n", c.LoginType)) } if c.Password != "" { - sb.WriteString(fmt.Sprintf(" password = %q", c.Password)) + sb.WriteString(fmt.Sprintf(" password = %q\n", c.Password)) } if c.Suspended { sb.WriteString(" suspended = true\n") @@ -100,4 +107,4 @@ func (c testAccUserResourceConfig) String() string { sb.WriteString(`}`) return sb.String() } -*/ \ No newline at end of file +*/ From e14d3fcd052375357da395ad39a8cc6c5e8904e2 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 12 Jul 2024 06:53:08 +0000 Subject: [PATCH 4/5] provider common data type --- internal/provider/provider.go | 11 ++++++-- internal/provider/user_resource.go | 40 ++++++++++++++++++------------ 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index abc76ed..60bcc6f 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -33,6 +33,10 @@ type CoderdProvider struct { version string } +type CoderdProviderData struct { + Client *codersdk.Client +} + // CoderdProviderModel describes the provider data model. type CoderdProviderModel struct { URL types.String `tfsdk:"url"` @@ -93,8 +97,11 @@ 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()) - resp.DataSourceData = client - resp.ResourceData = client + providerData := &CoderdProviderData{ + Client: client, + } + resp.DataSourceData = providerData + resp.ResourceData = providerData } func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resource { diff --git a/internal/provider/user_resource.go b/internal/provider/user_resource.go index c921932..a3690f8 100644 --- a/internal/provider/user_resource.go +++ b/internal/provider/user_resource.go @@ -37,7 +37,7 @@ func NewUserResource() resource.Resource { // UserResource defines the resource implementation. type UserResource struct { - client *codersdk.Client + data *CoderdProviderData } // UserResourceModel describes the resource data model. @@ -130,7 +130,7 @@ func (r *UserResource) Configure(ctx context.Context, req resource.ConfigureRequ return } - client, ok := req.ProviderData.(*codersdk.Client) + client, ok := req.ProviderData.(*CoderdProviderData) if !ok { resp.Diagnostics.AddError( @@ -141,7 +141,7 @@ func (r *UserResource) Configure(ctx context.Context, req resource.ConfigureRequ return } - r.client = client + r.data = client } func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { @@ -153,7 +153,9 @@ func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, r return } - me, err := r.client.User(ctx, codersdk.Me) + client := r.data.Client + + me, err := client.User(ctx, codersdk.Me) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err)) return @@ -168,7 +170,7 @@ func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, r if data.LoginType.ValueString() != "" { loginType = codersdk.LoginType(data.LoginType.ValueString()) } - user, err := r.client.CreateUser(ctx, codersdk.CreateUserRequest{ + user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ Email: data.Email.ValueString(), Username: data.Username.ValueString(), Password: data.Password.ValueString(), @@ -189,7 +191,7 @@ func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, r if data.Name.ValueString() != "" { name = data.Name.ValueString() } - user, err = r.client.UpdateUserProfile(ctx, user.ID.String(), codersdk.UpdateUserProfileRequest{ + user, err = client.UpdateUserProfile(ctx, user.ID.String(), codersdk.UpdateUserProfileRequest{ Username: data.Username.ValueString(), Name: name, }) @@ -206,7 +208,7 @@ func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, r tflog.Trace(ctx, "updating user roles", map[string]any{ "new_roles": roles, }) - user, err = r.client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{ + user, err = client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{ Roles: roles, }) if err != nil { @@ -216,7 +218,7 @@ func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, r tflog.Trace(ctx, "successfully updated user roles") if data.Suspended.ValueBool() { - _, err = r.client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("suspended")) + _, err = client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("suspended")) } if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user status, got error: %s", err)) @@ -236,7 +238,9 @@ func (r *UserResource) Read(ctx context.Context, req resource.ReadRequest, resp return } - user, err := r.client.User(ctx, data.ID.ValueString()) + client := r.data.Client + + user, err := client.User(ctx, data.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err)) return @@ -271,7 +275,9 @@ func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, r return } - user, err := r.client.User(ctx, data.ID.ValueString()) + client := r.data.Client + + user, err := client.User(ctx, data.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err)) return @@ -285,7 +291,7 @@ func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, r "new_username": data.Username.ValueString(), "new_name": data.Name.ValueString(), }) - _, err = r.client.UpdateUserProfile(ctx, user.ID.String(), codersdk.UpdateUserProfileRequest{ + _, err = client.UpdateUserProfile(ctx, user.ID.String(), codersdk.UpdateUserProfileRequest{ Username: data.Username.ValueString(), Name: data.Name.ValueString(), }) @@ -302,7 +308,7 @@ func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, r tflog.Trace(ctx, "updating user roles", map[string]any{ "new_roles": roles, }) - _, err = r.client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{ + _, err = client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{ Roles: roles, }) if err != nil { @@ -312,7 +318,7 @@ func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, r tflog.Trace(ctx, "successfully updated user roles") tflog.Trace(ctx, "updating password") - err = r.client.UpdateUserPassword(ctx, user.ID.String(), codersdk.UpdateUserPasswordRequest{ + err = client.UpdateUserPassword(ctx, user.ID.String(), codersdk.UpdateUserPasswordRequest{ Password: data.Password.ValueString(), }) if err != nil && !strings.Contains(err.Error(), "New password cannot match old password.") { @@ -323,10 +329,10 @@ func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, r var statusErr error if data.Suspended.ValueBool() { - _, statusErr = r.client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("suspended")) + _, statusErr = client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("suspended")) } if !data.Suspended.ValueBool() && user.Status == codersdk.UserStatusSuspended { - _, statusErr = r.client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("active")) + _, statusErr = client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("active")) } if statusErr != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user status, got error: %s", err)) @@ -347,13 +353,15 @@ func (r *UserResource) Delete(ctx context.Context, req resource.DeleteRequest, r return } + client := r.data.Client + id, err := uuid.Parse(data.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Data Error", fmt.Sprintf("Unable to parse user ID, got error: %s", err)) return } tflog.Trace(ctx, "deleting user") - err = r.client.DeleteUser(ctx, id) + err = client.DeleteUser(ctx, id) if err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete user, got error: %s", err)) return From e9d6a9caa413d5d613e37e1c6b1909be94c711ad Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 12 Jul 2024 06:56:54 +0000 Subject: [PATCH 5/5] gen --- docs/functions/example.md | 26 -------------------------- docs/index.md | 3 ++- docs/resources/example.md | 31 ------------------------------- docs/resources/user.md | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 35 insertions(+), 58 deletions(-) delete mode 100644 docs/functions/example.md delete mode 100644 docs/resources/example.md create mode 100644 docs/resources/user.md diff --git a/docs/functions/example.md b/docs/functions/example.md deleted file mode 100644 index f7791ca..0000000 --- a/docs/functions/example.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -# generated by https://github.com/hashicorp/terraform-plugin-docs -page_title: "example function - coderd" -subcategory: "" -description: |- - Example function ---- - -# function: example - -Echoes given argument as result - - - -## Signature - - -```text -example(input string) string -``` - -## Arguments - - -1. `input` (String) String to echo - diff --git a/docs/index.md b/docs/index.md index de6268f..a5ea0f0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,4 +23,5 @@ provider "coderd" { ### Optional -- `endpoint` (String) Example provider attribute +- `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/example.md b/docs/resources/example.md deleted file mode 100644 index b63b5e3..0000000 --- a/docs/resources/example.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -# generated by https://github.com/hashicorp/terraform-plugin-docs -page_title: "coderd_example Resource - coderd" -subcategory: "" -description: |- - Example resource ---- - -# coderd_example (Resource) - -Example resource - -## Example Usage - -```terraform -resource "coderd_example" "example" { - configurable_attribute = "some-value" -} -``` - - -## Schema - -### Optional - -- `configurable_attribute` (String) Example configurable attribute -- `defaulted` (String) Example configurable attribute with default value - -### Read-Only - -- `id` (String) Example identifier diff --git a/docs/resources/user.md b/docs/resources/user.md new file mode 100644 index 0000000..a0eeac0 --- /dev/null +++ b/docs/resources/user.md @@ -0,0 +1,33 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coderd_user Resource - coderd" +subcategory: "" +description: |- + A user on the Coder deployment. +--- + +# coderd_user (Resource) + +A user on the Coder deployment. + + + + +## Schema + +### Required + +- `email` (String) Email address of the user. +- `username` (String) Username of the user. + +### Optional + +- `login_type` (String) Type of login for the user. Valid types are 'none', 'password', 'github', and 'oidc'. +- `name` (String) Display name of the user. Defaults to username. +- `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. +- `roles` (Set of String) Roles assigned to the user. Valid roles are 'owner', 'template-admin', 'user-admin', and 'auditor'. +- `suspended` (Boolean) Whether the user is suspended. + +### Read-Only + +- `id` (String) User ID