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/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 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..60bcc6f 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. @@ -27,9 +33,14 @@ type CoderdProvider struct { version string } +type CoderdProviderData struct { + Client *codersdk.Client +} + // 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 +51,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 +72,41 @@ 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 - resp.DataSourceData = client - resp.ResourceData = client + 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()) + providerData := &CoderdProviderData{ + Client: client, + } + resp.DataSourceData = providerData + resp.ResourceData = providerData } func (p *CoderdProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ - NewExampleResource, + NewUserResource, } } @@ -79,9 +117,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 +127,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..a3690f8 --- /dev/null +++ b/internal/provider/user_resource.go @@ -0,0 +1,374 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + "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/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" + "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 { + data *CoderdProviderData +} + +// 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.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"` +} + +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.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.Set{ + setvalidator.ValueStringsAre( + stringvalidator.OneOf("owner", "template-admin", "user-admin", "auditor"), + ), + }, + 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'.", + 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.(*CoderdProviderData) + + 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.data = 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 + } + + 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 + } + 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 := 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 = 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") + + var roles []string + resp.Diagnostics.Append( + data.Roles.ElementsAs(ctx, &roles, false)..., + ) + tflog.Trace(ctx, "updating user roles", map[string]any{ + "new_roles": roles, + }) + user, err = 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") + + if data.Suspended.ValueBool() { + _, 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)) + return + } + // 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 + } + + 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 + } + 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) + 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.SetValueMust(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 + } + + 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 + } + 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 = 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") + + var roles []string + resp.Diagnostics.Append( + data.Roles.ElementsAs(ctx, &roles, false)..., + ) + tflog.Trace(ctx, "updating user roles", map[string]any{ + "new_roles": roles, + }) + _, err = 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 = 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.") { + 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 = client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("suspended")) + } + if !data.Suspended.ValueBool() && user.Status == codersdk.UserStatusSuspended { + _, 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)) + return + } + + // 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 + } + + 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 = 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..eaf720c --- /dev/null +++ b/internal/provider/user_resource_test.go @@ -0,0 +1,110 @@ +// 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: "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", "auditor"), + resource.TestCheckResourceAttr("coderd_user.test", "roles.1", "owner"), + resource.TestCheckResourceAttr("coderd_user.test", "login_type", "password"), + resource.TestCheckResourceAttr("coderd_user.test", "password", "SomeSecurePassword!"), + resource.TestCheckResourceAttr("coderd_user.test", "suspended", "false"), + ), + }, + // 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", "password"}, + }, + // Update and Read testing + { + 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_user.test", "username", "exampleNew"), + resource.TestCheckResourceAttr("coderd_user.test", "name", "Example User New"), + ), + }, + // 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\n", c.LoginType)) + } + if c.Password != "" { + sb.WriteString(fmt.Sprintf(" password = %q\n", c.Password)) + } + if c.Suspended { + sb.WriteString(" suspended = true\n") + } + sb.WriteString(`}`) + return sb.String() +} +*/