diff --git a/README.md b/README.md index e632b6c8..cbec7099 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# Terraform Provider Coder +# terraform-provider-coder -> This works with a closed-alpha of [Coder](https://coder.com). For access, contact [support@coder.com](mailto:support@coder.com). +See [Coder](https://github.com/coder/coder). diff --git a/docs/data-sources/parameter.md b/docs/data-sources/parameter.md new file mode 100644 index 00000000..628a4740 --- /dev/null +++ b/docs/data-sources/parameter.md @@ -0,0 +1,60 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "coder_parameter Data Source - terraform-provider-coder" +subcategory: "" +description: |- + Use this data source to configure editable options for workspaces. +--- + +# coder_parameter (Data Source) + +Use this data source to configure editable options for workspaces. + + + + +## Schema + +### Required + +- `name` (String) The name of the parameter as it will appear in the interface. If this is changed, developers will be re-prompted for a new value. + +### Optional + +- `default` (String) A default value for the parameter. +- `description` (String) Describe what this parameter does. +- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a built-in icon with `data.coder_workspace.me.access_url + "/icon/"`. +- `mutable` (Boolean) Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution! +- `option` (Block List, Max: 64) Each "option" block defines a value for a user to select from. (see [below for nested schema](#nestedblock--option)) +- `type` (String) The type of this parameter. Must be one of: "number", "string", or "bool". +- `validation` (Block List, Max: 1) Validate the input of a parameter. (see [below for nested schema](#nestedblock--validation)) + +### Read-Only + +- `id` (String) The ID of this resource. +- `value` (String) The output value of the parameter. + + +### Nested Schema for `option` + +Required: + +- `name` (String) The display name of this value in the UI. +- `value` (String) The value of this option set on the parameter if selected. + +Optional: + +- `description` (String) Describe what selecting this value does. +- `icon` (String) A URL to an icon that will display in the dashboard. View built-in icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a built-in icon with `data.coder_workspace.me.access_url + "/icon/"`. + + + +### Nested Schema for `validation` + +Optional: + +- `max` (Number) The maximum of a number parameter. +- `min` (Number) The minimum of a number parameter. +- `regex` (String) A regex for the input parameter to match against. + + diff --git a/examples/resources/coder_parameter/resource.tf b/examples/resources/coder_parameter/resource.tf new file mode 100644 index 00000000..21fb5e4b --- /dev/null +++ b/examples/resources/coder_parameter/resource.tf @@ -0,0 +1,46 @@ +data "coder_parameter" "example" { + display_name = "Region" + description = "Specify a region to place your workspace." + immutable = true + type = "string" + option { + value = "us-central1-a" + label = "US Central" + icon = "/icon/usa.svg" + } + option { + value = "asia-central1-a" + label = "Asia" + icon = "/icon/asia.svg" + } +} + +data "coder_parameter" "ami" { + display_name = "Machine Image" + option { + value = "ami-xxxxxxxx" + label = "Ubuntu" + icon = "/icon/ubuntu.svg" + } +} + +data "coder_parameter" "image" { + display_name = "Docker Image" + icon = "/icon/docker.svg" + type = "bool" +} + +data "coder_parameter" "cores" { + display_name = "CPU Cores" + icon = "/icon/" +} + +data "coder_parameter" "disk_size" { + display_name = "Disk Size" + type = "number" + validation { + # This can apply to number and string types. + min = 0 + max = 10 + } +} diff --git a/provider/parameter.go b/provider/parameter.go new file mode 100644 index 00000000..5858a96a --- /dev/null +++ b/provider/parameter.go @@ -0,0 +1,308 @@ +package provider + +import ( + "context" + "fmt" + "net/url" + "os" + "regexp" + "strconv" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/mitchellh/mapstructure" +) + +type Option struct { + Name string + Description string + Value string + Icon string +} + +type Validation struct { + Min int + Max int + Regex string +} + +type Parameter struct { + Value string + Name string + Description string + Type string + Mutable bool + Default string + Icon string + Option []Option + Validation []Validation +} + +func parameterDataSource() *schema.Resource { + return &schema.Resource{ + Description: "Use this data source to configure editable options for workspaces.", + ReadContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics { + rd.SetId(uuid.NewString()) + + var parameter Parameter + err := mapstructure.Decode(struct { + Value interface{} + Name interface{} + Description interface{} + Type interface{} + Mutable interface{} + Default interface{} + Icon interface{} + Option interface{} + Validation interface{} + }{ + Value: rd.Get("value"), + Name: rd.Get("name"), + Description: rd.Get("description"), + Type: rd.Get("type"), + Mutable: rd.Get("mutable"), + Default: rd.Get("default"), + Icon: rd.Get("icon"), + Option: rd.Get("option"), + Validation: rd.Get("validation"), + }, ¶meter) + if err != nil { + return diag.Errorf("decode parameter: %s", err) + } + var value string + if parameter.Default != "" { + err := valueIsType(parameter.Type, parameter.Default) + if err != nil { + return err + } + value = parameter.Default + } + envValue, ok := os.LookupEnv(fmt.Sprintf("CODER_PARAMETER_%s", parameter.Name)) + if ok { + value = envValue + } + rd.Set("value", value) + + if len(parameter.Validation) == 1 { + validation := ¶meter.Validation[0] + err = validation.Valid(parameter.Type, value) + if err != nil { + return diag.FromErr(err) + } + } + + if len(parameter.Option) > 0 { + names := map[string]interface{}{} + values := map[string]interface{}{} + for _, option := range parameter.Option { + _, exists := names[option.Name] + if exists { + return diag.Errorf("multiple options cannot have the same name %q", option.Name) + } + _, exists = values[option.Value] + if exists { + return diag.Errorf("multiple options cannot have the same value %q", option.Value) + } + err := valueIsType(parameter.Type, option.Value) + if err != nil { + return err + } + values[option.Value] = nil + names[option.Name] = nil + } + } + + return nil + }, + Schema: map[string]*schema.Schema{ + "value": { + Type: schema.TypeString, + Computed: true, + Description: "The output value of the parameter.", + }, + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the parameter as it will appear in the interface. If this is changed, developers will be re-prompted for a new value.", + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "Describe what this parameter does.", + }, + "type": { + Type: schema.TypeString, + Default: "string", + Optional: true, + ValidateFunc: validation.StringInSlice([]string{"number", "string", "bool"}, false), + Description: `The type of this parameter. Must be one of: "number", "string", or "bool".`, + }, + "mutable": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!", + }, + "default": { + Type: schema.TypeString, + Optional: true, + Description: "A default value for the parameter.", + ExactlyOneOf: []string{"option"}, + }, + "icon": { + Type: schema.TypeString, + Description: "A URL to an icon that will display in the dashboard. View built-in " + + "icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a " + + "built-in icon with `data.coder_workspace.me.access_url + \"/icon/\"`.", + ForceNew: true, + Optional: true, + ValidateFunc: func(i interface{}, s string) ([]string, []error) { + _, err := url.Parse(s) + if err != nil { + return nil, []error{err} + } + return nil, nil + }, + }, + "option": { + Type: schema.TypeList, + Description: "Each \"option\" block defines a value for a user to select from.", + ForceNew: true, + Optional: true, + MaxItems: 64, + ConflictsWith: []string{"validation"}, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Description: "The display name of this value in the UI.", + ForceNew: true, + Required: true, + }, + "description": { + Type: schema.TypeString, + Description: "Describe what selecting this value does.", + ForceNew: true, + Optional: true, + }, + "value": { + Type: schema.TypeString, + Description: "The value of this option set on the parameter if selected.", + ForceNew: true, + Required: true, + }, + "icon": { + Type: schema.TypeString, + Description: "A URL to an icon that will display in the dashboard. View built-in " + + "icons here: https://github.com/coder/coder/tree/main/site/static/icon. Use a " + + "built-in icon with `data.coder_workspace.me.access_url + \"/icon/\"`.", + ForceNew: true, + Optional: true, + ValidateFunc: func(i interface{}, s string) ([]string, []error) { + _, err := url.Parse(s) + if err != nil { + return nil, []error{err} + } + return nil, nil + }, + }, + }, + }, + }, + "validation": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Validate the input of a parameter.", + ConflictsWith: []string{"option"}, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "min": { + Type: schema.TypeInt, + Optional: true, + Default: 0, + Description: "The minimum of a number parameter.", + RequiredWith: []string{"validation.0.max"}, + }, + "max": { + Type: schema.TypeInt, + Optional: true, + Description: "The maximum of a number parameter.", + RequiredWith: []string{"validation.0.min"}, + }, + "regex": { + Type: schema.TypeString, + ConflictsWith: []string{"validation.0.min", "validation.0.max"}, + Description: "A regex for the input parameter to match against.", + Optional: true, + }, + }, + }, + }, + }, + } +} + +func valueIsType(typ, value string) diag.Diagnostics { + switch typ { + case "number": + _, err := strconv.ParseFloat(value, 64) + if err != nil { + return diag.Errorf("%q is not a number", value) + } + case "bool": + _, err := strconv.ParseBool(value) + if err != nil { + return diag.Errorf("%q is not a bool", value) + } + case "string": + // Anything is a string! + default: + return diag.Errorf("invalid type %q", typ) + } + return nil +} + +func (v *Validation) Valid(typ, value string) error { + if typ != "number" { + if v.Min != 0 { + return fmt.Errorf("a min cannot be specified for a %s type", typ) + } + if v.Max != 0 { + return fmt.Errorf("a max cannot be specified for a %s type", typ) + } + } + if typ != "string" && v.Regex != "" { + return fmt.Errorf("a regex cannot be specified for a %s type", typ) + } + switch typ { + case "bool": + return nil + case "string": + if v.Regex == "" { + return nil + } + regex, err := regexp.Compile(v.Regex) + if err != nil { + return fmt.Errorf("compile regex %q: %s", regex, err) + } + matched := regex.MatchString(value) + if !matched { + return fmt.Errorf("value %q does not match %q", value, regex) + } + case "number": + num, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("parse value %s as int: %s", value, err) + } + if num < v.Min { + return fmt.Errorf("provided value %d is less than the minimum %d", num, v.Min) + } + if num > v.Max { + return fmt.Errorf("provided value %d is more than the maximum %d", num, v.Max) + } + } + return nil +} diff --git a/provider/parameter_test.go b/provider/parameter_test.go new file mode 100644 index 00000000..874dbbc4 --- /dev/null +++ b/provider/parameter_test.go @@ -0,0 +1,314 @@ +package provider_test + +import ( + "regexp" + "testing" + + "github.com/coder/terraform-provider-coder/provider" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/stretchr/testify/require" +) + +func TestParameter(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + Name string + Config string + ExpectError *regexp.Regexp + Check func(state *terraform.ResourceState) + }{{ + Name: "FieldsExist", + Config: ` +data "coder_parameter" "region" { + name = "Region" + type = "string" + description = "Some option!" + mutable = true + icon = "/icon/region.svg" + option { + name = "US Central" + value = "us-central1-a" + icon = "/icon/central.svg" + description = "Select for central!" + } + option { + name = "US East" + value = "us-east1-a" + icon = "/icon/east.svg" + description = "Select for east!" + } +} +`, + Check: func(state *terraform.ResourceState) { + attrs := state.Primary.Attributes + for key, value := range map[string]interface{}{ + "name": "Region", + "type": "string", + "description": "Some option!", + "mutable": "true", + "icon": "/icon/region.svg", + "option.0.name": "US Central", + "option.0.value": "us-central1-a", + "option.0.icon": "/icon/central.svg", + "option.0.description": "Select for central!", + "option.1.name": "US East", + "option.1.value": "us-east1-a", + "option.1.icon": "/icon/east.svg", + "option.1.description": "Select for east!", + } { + require.Equal(t, value, attrs[key]) + } + }, + }, { + Name: "ValidationWithOptions", + Config: ` +data "coder_parameter" "region" { + name = "Region" + type = "number" + option { + name = "1" + value = "1" + } + validation { + regex = "1" + } +} +`, + ExpectError: regexp.MustCompile("conflicts with option"), + }, { + Name: "NumberValidation", + Config: ` +data "coder_parameter" "region" { + name = "Region" + type = "number" + default = 2 + validation { + min = 1 + max = 5 + } +} +`, + Check: func(state *terraform.ResourceState) { + + }, + }, { + Name: "DefaultNotNumber", + Config: ` +data "coder_parameter" "region" { + name = "Region" + type = "number" + default = true +} +`, + ExpectError: regexp.MustCompile("is not a number"), + }, { + Name: "DefaultNotBool", + Config: ` +data "coder_parameter" "region" { + name = "Region" + type = "bool" + default = 5 +} +`, + ExpectError: regexp.MustCompile("is not a bool"), + }, { + Name: "OptionNotBool", + Config: ` +data "coder_parameter" "region" { + name = "Region" + type = "bool" + option { + value = 1 + name = 1 + } + option { + value = 2 + name = 2 + } +}`, + ExpectError: regexp.MustCompile("\"2\" is not a bool"), + }, { + Name: "MultipleOptions", + Config: ` +data "coder_parameter" "region" { + name = "Region" + type = "string" + option { + name = "1" + value = "1" + icon = "/icon/code.svg" + description = "Something!" + } + option { + name = "2" + value = "2" + } +} +`, + Check: func(state *terraform.ResourceState) { + for key, expected := range map[string]string{ + "name": "Region", + "option.#": "2", + "option.0.name": "1", + "option.0.value": "1", + "option.0.icon": "/icon/code.svg", + "option.0.description": "Something!", + } { + require.Equal(t, expected, state.Primary.Attributes[key]) + } + }, + }, { + Name: "DefaultWithOption", + Config: ` +data "coder_parameter" "region" { + name = "Region" + default = "hi" + option { + name = "1" + value = "1" + } + option { + name = "2" + value = "2" + } +} +`, + ExpectError: regexp.MustCompile("Invalid combination of arguments"), + }, { + Name: "SingleOption", + Config: ` +data "coder_parameter" "region" { + name = "Region" + option { + name = "1" + value = "1" + } +} +`, + }, { + Name: "DuplicateOptionName", + Config: ` +data "coder_parameter" "region" { + name = "Region" + type = "string" + option { + name = "1" + value = "1" + } + option { + name = "1" + value = "2" + } +} +`, + ExpectError: regexp.MustCompile("cannot have the same name"), + }, { + Name: "DuplicateOptionValue", + Config: ` +data "coder_parameter" "region" { + name = "Region" + type = "string" + option { + name = "1" + value = "1" + } + option { + name = "2" + value = "1" + } +} +`, + ExpectError: regexp.MustCompile("cannot have the same value"), + }} { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + resource.Test(t, resource.TestCase{ + Providers: map[string]*schema.Provider{ + "coder": provider.New(), + }, + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: tc.Config, + ExpectError: tc.ExpectError, + Check: func(state *terraform.State) error { + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + param := state.Modules[0].Resources["data.coder_parameter.region"] + require.NotNil(t, param) + t.Logf("parameter attributes: %#v", param.Primary.Attributes) + if tc.Check != nil { + tc.Check(param) + } + return nil + }, + }}, + }) + }) + } +} + +func TestValueValidatesType(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + Name, + Type, + Value, + Regex string + Min, + Max int + Error *regexp.Regexp + }{{ + Name: "StringWithMin", + Type: "string", + Min: 1, + Error: regexp.MustCompile("cannot be specified"), + }, { + Name: "StringWithMax", + Type: "string", + Max: 1, + Error: regexp.MustCompile("cannot be specified"), + }, { + Name: "NonStringWithRegex", + Type: "number", + Regex: "banana", + Error: regexp.MustCompile("a regex cannot be specified"), + }, { + Name: "Bool", + Type: "bool", + Value: "true", + }, { + Name: "InvalidNumber", + Type: "number", + Value: "hi", + Error: regexp.MustCompile("parse value hi as int"), + }, { + Name: "NumberBelowMin", + Type: "number", + Value: "0", + Min: 1, + Error: regexp.MustCompile("is less than the minimum"), + }, { + Name: "NumberAboveMax", + Type: "number", + Value: "1", + Max: 0, + Error: regexp.MustCompile("is more than the maximum"), + }} { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + v := &provider.Validation{ + Min: tc.Min, + Max: tc.Max, + Regex: tc.Regex, + } + err := v.Valid(tc.Type, tc.Value) + if tc.Error != nil { + require.True(t, tc.Error.MatchString(err.Error())) + } + }) + } +} diff --git a/provider/provider.go b/provider/provider.go index adc0635a..2032cec3 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -63,6 +63,7 @@ func New() *schema.Provider { DataSourcesMap: map[string]*schema.Resource{ "coder_workspace": workspaceDataSource(), "coder_provisioner": provisionerDataSource(), + "coder_parameter": parameterDataSource(), }, ResourcesMap: map[string]*schema.Resource{ "coder_agent": agentResource(),