From 6b516cf7ba6b5339e7db937f54a378efee726620 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 28 Sep 2022 23:16:23 +0000 Subject: [PATCH 1/5] Add parameter --- .../resources/coder_parameter/resource.tf | 47 ++++ internal/provider/provider.go | 218 +++++++++++++++++- internal/provider/provider_test.go | 199 ++++++++++++++++ param.tf | 47 ++++ 4 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 examples/resources/coder_parameter/resource.tf create mode 100644 param.tf diff --git a/examples/resources/coder_parameter/resource.tf b/examples/resources/coder_parameter/resource.tf new file mode 100644 index 00000000..fd612337 --- /dev/null +++ b/examples/resources/coder_parameter/resource.tf @@ -0,0 +1,47 @@ +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" + increase_only = true + validation { + # This can apply to number and string types. + min = 0 + max = 10 + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 472b84bb..c771ea1e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -178,6 +178,198 @@ func New() *schema.Provider { }, }, }, + "coder_parameter": { + 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()) + + name := rd.Get("name").(string) + typ := rd.Get("type").(string) + var value string + rawDefaultValue, ok := rd.GetOk("default") + if ok { + defaultValue := rawDefaultValue.(string) + err := valueIsType(typ, defaultValue) + if err != nil { + return err + } + value = defaultValue + } + envValue, ok := os.LookupEnv(fmt.Sprintf("CODER_PARAMETER_%s", name)) + if ok { + value = envValue + } + rd.Set("value", value) + + rawOptions, exists := rd.GetOk("option") + if exists { + rawArrayOptions, valid := rawOptions.([]interface{}) + if !valid { + return diag.Errorf("options is of wrong type %T", rawArrayOptions) + } + optionDisplayNames := map[string]interface{}{} + optionValues := map[string]interface{}{} + for _, rawOption := range rawArrayOptions { + option, valid := rawOption.(map[string]interface{}) + if !valid { + return diag.Errorf("option is of wrong type %T", rawOption) + } + rawName, ok := option["name"] + if !ok { + return diag.Errorf("no name for %+v", option) + } + displayName, ok := rawName.(string) + if !ok { + return diag.Errorf("display name is of wrong type %T", displayName) + } + _, exists := optionDisplayNames[displayName] + if exists { + return diag.Errorf("multiple options cannot have the same display name %q", displayName) + } + + rawValue, ok := option["value"] + if !ok { + return diag.Errorf("no value for %+v\n", option) + } + value, ok := rawValue.(string) + if !ok { + return diag.Errorf("") + } + _, exists = optionValues[value] + if exists { + return diag.Errorf("multiple options cannot have the same value %q", value) + } + err := valueIsType(typ, value) + if err != nil { + return err + } + + optionValues[value] = nil + optionDisplayNames[displayName] = nil + } + } + + return nil + }, + Schema: map[string]*schema.Schema{ + "value": { + Type: schema.TypeString, + Computed: true, + Description: "The output value of a parameter.", + }, + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the parameter as it appears in the interface. If this is changed, the parameter will need to be re-updated by developers.", + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "Explain what the parameter does.", + }, + "type": { + Type: schema.TypeString, + Default: "string", + Optional: true, + ValidateFunc: validation.StringInSlice([]string{"number", "string", "bool"}, false), + }, + "immutable": { + Type: schema.TypeBool, + Optional: true, + Description: "Whether the value can be changed after it's set initially.", + }, + "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 single displayable value for a user to select.", + ForceNew: true, + Optional: true, + MaxItems: 64, + 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: "Add a description to select this item.", + ForceNew: true, + Optional: true, + }, + "value": { + Type: schema.TypeString, + Description: "The value of this option.", + 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.TypeSet, + MaxItems: 1, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "min": { + Type: schema.TypeInt, + Optional: true, + Default: 0, + Description: "The minimum for a number to be.", + }, + "max": { + Type: schema.TypeInt, + Optional: true, + Description: "The maximum for a number to be.", + }, + "regex": { + Type: schema.TypeString, + ExactlyOneOf: []string{"min", "max"}, + Optional: true, + }, + }, + }, + }, + }, + }, "coder_provisioner": { Description: "Use this data source to get information about the Coder provisioner.", ReadContext: func(c context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics { @@ -339,8 +531,8 @@ func New() *schema.Provider { "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/icons. Use a " + - "built-in icon with `data.coder_workspace.me.access_url + \"/icons/\"`.", + "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) { @@ -445,7 +637,7 @@ func New() *schema.Provider { 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 + \"/icons/\"`.", + "built-in icon with `data.coder_workspace.me.access_url + \"/icon/\"`.", ForceNew: true, Optional: true, ValidateFunc: func(i interface{}, s string) ([]string, []error) { @@ -499,6 +691,26 @@ func New() *schema.Provider { } } +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 +} + // updateInitScript fetches parameters from a "coder_agent" to produce the // agent script from environment variables. func updateInitScript(resourceData *schema.ResourceData, i interface{}) diag.Diagnostics { diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 635f636a..80a2c03e 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -368,3 +368,202 @@ func TestMetadataDuplicateKeys(t *testing.T) { }}, }) } + +func TestParameter(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + Name string + Config string + ExpectError *regexp.Regexp + Check func(state *terraform.ResourceState) + }{{ + Name: "NumberValidation", + Config: ` +provider "coder" {} +data "coder_parameter" "region" { + name = "Region" + type = "number" +} +`, + }, { + Name: "DefaultNotNumber", + Config: ` +provider "coder" {} +data "coder_parameter" "region" { + name = "Region" + type = "number" + default = true +} +`, + ExpectError: regexp.MustCompile("is not a number"), + }, { + Name: "DefaultNotBool", + Config: ` +provider "coder" {} +data "coder_parameter" "region" { + name = "Region" + type = "bool" + default = 5 +} +`, + ExpectError: regexp.MustCompile("is not a bool"), + }, { + Name: "OptionNotBool", + Config: ` +provider "coder" {} +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: ` +provider "coder" {} +data "coder_parameter" "region" { + name = "Region" + type = "string" + option { + name = "1" + value = "1" + icon = "/icon/code.svg" + description = "Something!" + } + option { + name = "2" + value = "2" + } +} + +data "google_compute_regions" "regions" {} + +data "coder_parameter" "region" { + name = "Region" + type = "string" + icon = "/icon/asdasd.svg" + option { + name = "United States" + value = "us-central1-a" + icon = "/icon/usa.svg" + description = "If you live in America, select this!" + } + option { + name = "Europe" + 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: ` +provider "coder" {} +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: ` +provider "coder" {} +data "coder_parameter" "region" { + name = "Region" + option { + name = "1" + value = "1" + } +} +`, + }, { + Name: "DuplicateOptionDisplayName", + Config: ` +provider "coder" {} +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 display name"), + }, { + Name: "DuplicateOptionValue", + Config: ` +provider "coder" {} +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 + }, + }}, + }) + }) + } +} diff --git a/param.tf b/param.tf new file mode 100644 index 00000000..fd612337 --- /dev/null +++ b/param.tf @@ -0,0 +1,47 @@ +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" + increase_only = true + validation { + # This can apply to number and string types. + min = 0 + max = 10 + } +} From f4a3ebc9eb114d241bc70be1803b6f41f29a96ae Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 29 Sep 2022 02:24:38 +0000 Subject: [PATCH 2/5] add parameter package --- internal/provider/parameter.go | 142 +++++++++++++++++++++----- internal/provider/parameter_test.go | 152 +++++++++++++++++++++++----- parameter/parameter.go | 26 +++++ 3 files changed, 269 insertions(+), 51 deletions(-) create mode 100644 parameter/parameter.go diff --git a/internal/provider/parameter.go b/internal/provider/parameter.go index aa9f4dad..75d5b136 100644 --- a/internal/provider/parameter.go +++ b/internal/provider/parameter.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "regexp" "strconv" "github.com/google/uuid" @@ -37,6 +38,49 @@ func parameterDataSource() *schema.Resource { } rd.Set("value", value) + rawValidation, exists := rd.GetOk("validation") + var ( + validationRegex string + validationMin int + validationMax int + ) + if exists { + validationArray, valid := rawValidation.([]interface{}) + if !valid { + return diag.Errorf("validation is of wrong type %T", rawValidation) + } + validation, valid := validationArray[0].(map[string]interface{}) + if !valid { + return diag.Errorf("validation is of wrong type %T", validation) + } + rawRegex, ok := validation["regex"] + if ok { + validationRegex, ok = rawRegex.(string) + if !ok { + return diag.Errorf("validation regex is of wrong type %T", rawRegex) + } + } + rawMin, ok := validation["min"] + if ok { + validationMin, ok = rawMin.(int) + if !ok { + return diag.Errorf("validation min is wrong type %T", rawMin) + } + } + rawMax, ok := validation["max"] + if ok { + validationMax, ok = rawMax.(int) + if !ok { + return diag.Errorf("validation max is wrong type %T", rawMax) + } + } + } + + err := ValueValidatesType(typ, value, validationRegex, validationMin, validationMax) + if err != nil { + return diag.FromErr(err) + } + rawOptions, exists := rd.GetOk("option") if exists { rawArrayOptions, valid := rawOptions.([]interface{}) @@ -91,28 +135,30 @@ func parameterDataSource() *schema.Resource { "value": { Type: schema.TypeString, Computed: true, - Description: "The output value of a parameter.", + Description: "The output value of the parameter.", }, "name": { Type: schema.TypeString, Required: true, - Description: "The name of the parameter as it appears in the interface. If this is changed, the parameter will need to be re-updated by developers.", + 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: "Explain what the parameter does.", + 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".`, }, "immutable": { Type: schema.TypeBool, Optional: true, - Description: "Whether the value can be changed after it's set initially.", + Default: true, + 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, @@ -136,11 +182,12 @@ func parameterDataSource() *schema.Resource { }, }, "option": { - Type: schema.TypeList, - Description: "Each \"option\" block defines a single displayable value for a user to select.", - ForceNew: true, - Optional: true, - MaxItems: 64, + 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": { @@ -151,13 +198,13 @@ func parameterDataSource() *schema.Resource { }, "description": { Type: schema.TypeString, - Description: "Add a description to select this item.", + Description: "Describe what selecting this value does.", ForceNew: true, Optional: true, }, "value": { Type: schema.TypeString, - Description: "The value of this option.", + Description: "The value of this option set on the parameter if selected.", ForceNew: true, Required: true, }, @@ -180,26 +227,31 @@ func parameterDataSource() *schema.Resource { }, }, "validation": { - Type: schema.TypeSet, - MaxItems: 1, - Optional: true, + 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 for a number to be.", + 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 for a number to be.", + Type: schema.TypeInt, + Optional: true, + Description: "The maximum of a number parameter.", + RequiredWith: []string{"validation.0.min"}, }, "regex": { - Type: schema.TypeString, - ExactlyOneOf: []string{"min", "max"}, - Optional: true, + Type: schema.TypeString, + ConflictsWith: []string{"validation.0.min", "validation.0.max"}, + Description: "A regex for the input parameter to match against.", + Optional: true, }, }, }, @@ -227,3 +279,45 @@ func valueIsType(typ, value string) diag.Diagnostics { } return nil } + +func ValueValidatesType(typ, value, regex string, min, max int) error { + if typ != "number" { + if min != 0 { + return fmt.Errorf("a min cannot be specified for a %s type", typ) + } + if max != 0 { + return fmt.Errorf("a max cannot be specified for a %s type", typ) + } + } + if typ != "string" && regex != "" { + return fmt.Errorf("a regex cannot be specified for a %s type", typ) + } + switch typ { + case "bool": + return nil + case "string": + if regex == "" { + return nil + } + regex, err := regexp.Compile(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 < min { + return fmt.Errorf("provided value %d is less than the minimum %d", num, min) + } + if num > max { + return fmt.Errorf("provided value %d is more than the maximum %d", num, max) + } + } + return nil +} diff --git a/internal/provider/parameter_test.go b/internal/provider/parameter_test.go index 5eef5040..7a41b518 100644 --- a/internal/provider/parameter_test.go +++ b/internal/provider/parameter_test.go @@ -19,18 +19,83 @@ func TestParameter(t *testing.T) { ExpectError *regexp.Regexp Check func(state *terraform.ResourceState) }{{ + Name: "FieldsExist", + Config: ` +data "coder_parameter" "region" { + name = "Region" + type = "string" + description = "Some option!" + immutable = false + 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!", + "immutable": "false", + "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: ` -provider "coder" {} data "coder_parameter" "region" { name = "Region" type = "number" + default = 2 + validation { + min = 1 + max = 5 + } } `, + Check: func(state *terraform.ResourceState) { + + }, }, { Name: "DefaultNotNumber", Config: ` -provider "coder" {} data "coder_parameter" "region" { name = "Region" type = "number" @@ -41,7 +106,6 @@ data "coder_parameter" "region" { }, { Name: "DefaultNotBool", Config: ` -provider "coder" {} data "coder_parameter" "region" { name = "Region" type = "bool" @@ -52,7 +116,6 @@ data "coder_parameter" "region" { }, { Name: "OptionNotBool", Config: ` -provider "coder" {} data "coder_parameter" "region" { name = "Region" type = "bool" @@ -69,7 +132,6 @@ data "coder_parameter" "region" { }, { Name: "MultipleOptions", Config: ` -provider "coder" {} data "coder_parameter" "region" { name = "Region" type = "string" @@ -84,24 +146,6 @@ data "coder_parameter" "region" { value = "2" } } - -data "google_compute_regions" "regions" {} - -data "coder_parameter" "region" { - name = "Region" - type = "string" - icon = "/icon/asdasd.svg" - option { - name = "United States" - value = "us-central1-a" - icon = "/icon/usa.svg" - description = "If you live in America, select this!" - } - option { - name = "Europe" - value = "2" - } -} `, Check: func(state *terraform.ResourceState) { for key, expected := range map[string]string{ @@ -118,7 +162,6 @@ data "coder_parameter" "region" { }, { Name: "DefaultWithOption", Config: ` -provider "coder" {} data "coder_parameter" "region" { name = "Region" default = "hi" @@ -136,7 +179,6 @@ data "coder_parameter" "region" { }, { Name: "SingleOption", Config: ` -provider "coder" {} data "coder_parameter" "region" { name = "Region" option { @@ -148,7 +190,6 @@ data "coder_parameter" "region" { }, { Name: "DuplicateOptionDisplayName", Config: ` -provider "coder" {} data "coder_parameter" "region" { name = "Region" type = "string" @@ -166,7 +207,6 @@ data "coder_parameter" "region" { }, { Name: "DuplicateOptionValue", Config: ` -provider "coder" {} data "coder_parameter" "region" { name = "Region" type = "string" @@ -209,3 +249,61 @@ data "coder_parameter" "region" { }) } } + +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() + err := provider.ValueValidatesType(tc.Type, tc.Value, tc.Regex, tc.Min, tc.Max) + if tc.Error != nil { + require.True(t, tc.Error.MatchString(err.Error())) + } + }) + } +} diff --git a/parameter/parameter.go b/parameter/parameter.go new file mode 100644 index 00000000..31c0b005 --- /dev/null +++ b/parameter/parameter.go @@ -0,0 +1,26 @@ +package parameter + +type Option struct { + Name string `mapstructure:"name"` + Description string `mapstructure:"description"` + Value string `mapstructure:"value"` + Icon string `mapstructure:"icon"` +} + +type Validation struct { + Min int `mapstructure:"min"` + Max int `mapstructure:"max"` + Regex string `mapstructure:"regex"` +} + +type Parameter struct { + Value string `mapstructure:"value"` + Name string `mapstructure:"name"` + Description string `mapstructure:"description"` + Type string `mapstructure:"type"` + Immutable bool `mapstructure:"bool"` + Default string `mapstructure:"default"` + Icon string `mapstructure:"icon"` + Option []Option `mapstructure:"option"` + Validation []Validation `mapstructure:"validation"` +} From f218aabd3ff7a36c486d058e52a91df00c994bca Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 29 Sep 2022 03:24:35 +0000 Subject: [PATCH 3/5] Use mapstructure for parsing parameters --- provider/parameter.go | 177 +++++++++++++++++-------------------- provider/parameter_test.go | 11 ++- 2 files changed, 89 insertions(+), 99 deletions(-) diff --git a/provider/parameter.go b/provider/parameter.go index 75d5b136..5fa8e753 100644 --- a/provider/parameter.go +++ b/provider/parameter.go @@ -12,120 +12,105 @@ import ( "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 + Immutable 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()) - name := rd.Get("name").(string) - typ := rd.Get("type").(string) + var parameter Parameter + err := mapstructure.Decode(struct { + Value interface{} + Name interface{} + Description interface{} + Type interface{} + Immutable 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"), + Immutable: rd.Get("immutable"), + 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 - rawDefaultValue, ok := rd.GetOk("default") - if ok { - defaultValue := rawDefaultValue.(string) - err := valueIsType(typ, defaultValue) + if parameter.Default != "" { + err := valueIsType(parameter.Type, parameter.Default) if err != nil { return err } - value = defaultValue + value = parameter.Default } - envValue, ok := os.LookupEnv(fmt.Sprintf("CODER_PARAMETER_%s", name)) + envValue, ok := os.LookupEnv(fmt.Sprintf("CODER_PARAMETER_%s", parameter.Name)) if ok { value = envValue } rd.Set("value", value) - rawValidation, exists := rd.GetOk("validation") - var ( - validationRegex string - validationMin int - validationMax int - ) - if exists { - validationArray, valid := rawValidation.([]interface{}) - if !valid { - return diag.Errorf("validation is of wrong type %T", rawValidation) - } - validation, valid := validationArray[0].(map[string]interface{}) - if !valid { - return diag.Errorf("validation is of wrong type %T", validation) - } - rawRegex, ok := validation["regex"] - if ok { - validationRegex, ok = rawRegex.(string) - if !ok { - return diag.Errorf("validation regex is of wrong type %T", rawRegex) - } - } - rawMin, ok := validation["min"] - if ok { - validationMin, ok = rawMin.(int) - if !ok { - return diag.Errorf("validation min is wrong type %T", rawMin) - } - } - rawMax, ok := validation["max"] - if ok { - validationMax, ok = rawMax.(int) - if !ok { - return diag.Errorf("validation max is wrong type %T", rawMax) - } + if len(parameter.Validation) == 1 { + validation := ¶meter.Validation[0] + err = validation.Valid(parameter.Type, value) + if err != nil { + return diag.FromErr(err) } } - err := ValueValidatesType(typ, value, validationRegex, validationMin, validationMax) - if err != nil { - return diag.FromErr(err) - } - - rawOptions, exists := rd.GetOk("option") - if exists { - rawArrayOptions, valid := rawOptions.([]interface{}) - if !valid { - return diag.Errorf("options is of wrong type %T", rawArrayOptions) - } - optionDisplayNames := map[string]interface{}{} - optionValues := map[string]interface{}{} - for _, rawOption := range rawArrayOptions { - option, valid := rawOption.(map[string]interface{}) - if !valid { - return diag.Errorf("option is of wrong type %T", rawOption) - } - rawName, ok := option["name"] - if !ok { - return diag.Errorf("no name for %+v", option) - } - displayName, ok := rawName.(string) - if !ok { - return diag.Errorf("display name is of wrong type %T", displayName) - } - _, exists := optionDisplayNames[displayName] + 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 display name %q", displayName) + return diag.Errorf("multiple options cannot have the same name %q", option.Name) } - - rawValue, ok := option["value"] - if !ok { - return diag.Errorf("no value for %+v\n", option) - } - value, ok := rawValue.(string) - if !ok { - return diag.Errorf("") - } - _, exists = optionValues[value] + _, exists = values[option.Value] if exists { - return diag.Errorf("multiple options cannot have the same value %q", value) + return diag.Errorf("multiple options cannot have the same value %q", option.Value) } - err := valueIsType(typ, value) + err := valueIsType(parameter.Type, option.Value) if err != nil { return err } - - optionValues[value] = nil - optionDisplayNames[displayName] = nil + values[option.Value] = nil + names[option.Name] = nil } } @@ -280,26 +265,26 @@ func valueIsType(typ, value string) diag.Diagnostics { return nil } -func ValueValidatesType(typ, value, regex string, min, max int) error { +func (v *Validation) Valid(typ, value string) error { if typ != "number" { - if min != 0 { + if v.Min != 0 { return fmt.Errorf("a min cannot be specified for a %s type", typ) } - if max != 0 { + if v.Max != 0 { return fmt.Errorf("a max cannot be specified for a %s type", typ) } } - if typ != "string" && regex != "" { + 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 regex == "" { + if v.Regex == "" { return nil } - regex, err := regexp.Compile(regex) + regex, err := regexp.Compile(v.Regex) if err != nil { return fmt.Errorf("compile regex %q: %s", regex, err) } @@ -312,11 +297,11 @@ func ValueValidatesType(typ, value, regex string, min, max int) error { if err != nil { return fmt.Errorf("parse value %s as int: %s", value, err) } - if num < min { - return fmt.Errorf("provided value %d is less than the minimum %d", num, min) + if num < v.Min { + return fmt.Errorf("provided value %d is less than the minimum %d", num, v.Min) } - if num > max { - return fmt.Errorf("provided value %d is more than the maximum %d", num, max) + 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 index 12dfba9a..7a5b27c6 100644 --- a/provider/parameter_test.go +++ b/provider/parameter_test.go @@ -188,7 +188,7 @@ data "coder_parameter" "region" { } `, }, { - Name: "DuplicateOptionDisplayName", + Name: "DuplicateOptionName", Config: ` data "coder_parameter" "region" { name = "Region" @@ -203,7 +203,7 @@ data "coder_parameter" "region" { } } `, - ExpectError: regexp.MustCompile("cannot have the same display name"), + ExpectError: regexp.MustCompile("cannot have the same name"), }, { Name: "DuplicateOptionValue", Config: ` @@ -300,7 +300,12 @@ func TestValueValidatesType(t *testing.T) { tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() - err := provider.ValueValidatesType(tc.Type, tc.Value, tc.Regex, tc.Min, tc.Max) + 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())) } From 12f02f7e06b6df6e10f0a484a8d685ddd4485fd1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 29 Sep 2022 20:11:12 +0000 Subject: [PATCH 4/5] Remove unused lib --- param.tf | 47 ------------------------------------------ parameter/parameter.go | 26 ----------------------- 2 files changed, 73 deletions(-) delete mode 100644 param.tf delete mode 100644 parameter/parameter.go diff --git a/param.tf b/param.tf deleted file mode 100644 index fd612337..00000000 --- a/param.tf +++ /dev/null @@ -1,47 +0,0 @@ -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" - increase_only = true - validation { - # This can apply to number and string types. - min = 0 - max = 10 - } -} diff --git a/parameter/parameter.go b/parameter/parameter.go deleted file mode 100644 index 31c0b005..00000000 --- a/parameter/parameter.go +++ /dev/null @@ -1,26 +0,0 @@ -package parameter - -type Option struct { - Name string `mapstructure:"name"` - Description string `mapstructure:"description"` - Value string `mapstructure:"value"` - Icon string `mapstructure:"icon"` -} - -type Validation struct { - Min int `mapstructure:"min"` - Max int `mapstructure:"max"` - Regex string `mapstructure:"regex"` -} - -type Parameter struct { - Value string `mapstructure:"value"` - Name string `mapstructure:"name"` - Description string `mapstructure:"description"` - Type string `mapstructure:"type"` - Immutable bool `mapstructure:"bool"` - Default string `mapstructure:"default"` - Icon string `mapstructure:"icon"` - Option []Option `mapstructure:"option"` - Validation []Validation `mapstructure:"validation"` -} From aaa860cdfa3a68b857845b4b0e6b1237d1b90fc1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 29 Sep 2022 23:23:06 +0000 Subject: [PATCH 5/5] Change to mutable --- README.md | 4 +- docs/data-sources/parameter.md | 60 +++++++++++++++++++ .../resources/coder_parameter/resource.tf | 1 - provider/parameter.go | 10 ++-- provider/parameter_test.go | 4 +- 5 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 docs/data-sources/parameter.md 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 index fd612337..21fb5e4b 100644 --- a/examples/resources/coder_parameter/resource.tf +++ b/examples/resources/coder_parameter/resource.tf @@ -38,7 +38,6 @@ data "coder_parameter" "cores" { data "coder_parameter" "disk_size" { display_name = "Disk Size" type = "number" - increase_only = true validation { # This can apply to number and string types. min = 0 diff --git a/provider/parameter.go b/provider/parameter.go index 5fa8e753..5858a96a 100644 --- a/provider/parameter.go +++ b/provider/parameter.go @@ -33,7 +33,7 @@ type Parameter struct { Name string Description string Type string - Immutable bool + Mutable bool Default string Icon string Option []Option @@ -52,7 +52,7 @@ func parameterDataSource() *schema.Resource { Name interface{} Description interface{} Type interface{} - Immutable interface{} + Mutable interface{} Default interface{} Icon interface{} Option interface{} @@ -62,7 +62,7 @@ func parameterDataSource() *schema.Resource { Name: rd.Get("name"), Description: rd.Get("description"), Type: rd.Get("type"), - Immutable: rd.Get("immutable"), + Mutable: rd.Get("mutable"), Default: rd.Get("default"), Icon: rd.Get("icon"), Option: rd.Get("option"), @@ -139,10 +139,10 @@ func parameterDataSource() *schema.Resource { ValidateFunc: validation.StringInSlice([]string{"number", "string", "bool"}, false), Description: `The type of this parameter. Must be one of: "number", "string", or "bool".`, }, - "immutable": { + "mutable": { Type: schema.TypeBool, Optional: true, - Default: 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": { diff --git a/provider/parameter_test.go b/provider/parameter_test.go index 7a5b27c6..874dbbc4 100644 --- a/provider/parameter_test.go +++ b/provider/parameter_test.go @@ -25,7 +25,7 @@ data "coder_parameter" "region" { name = "Region" type = "string" description = "Some option!" - immutable = false + mutable = true icon = "/icon/region.svg" option { name = "US Central" @@ -47,7 +47,7 @@ data "coder_parameter" "region" { "name": "Region", "type": "string", "description": "Some option!", - "immutable": "false", + "mutable": "true", "icon": "/icon/region.svg", "option.0.name": "US Central", "option.0.value": "us-central1-a",