diff --git a/docs/data-sources/workspace.md b/docs/data-sources/workspace.md index 29fd9179..4dacdfc3 100644 --- a/docs/data-sources/workspace.md +++ b/docs/data-sources/workspace.md @@ -70,6 +70,7 @@ resource "docker_container" "workspace" { - `access_url` (String) The access URL of the Coder deployment provisioning this workspace. - `id` (String) UUID of the workspace. - `is_prebuild` (Boolean) Similar to `prebuild_count`, but a boolean value instead of a count. This is set to true if the workspace is a currently unassigned prebuild. Once the workspace is assigned, this value will be false. +- `is_prebuild_claim` (Boolean) Indicates whether a prebuilt workspace has just been claimed and this is the first `apply` after that occurrence. - `name` (String) Name of the workspace. - `prebuild_count` (Number) A computed count, equal to 1 if the workspace is a currently unassigned prebuild. Use this to conditionally act on the status of a prebuild. Actions that do not require user identity can be taken when this value is set to 1. Actions that should only be taken once the workspace has been assigned to a user may be taken when this value is set to 0. - `start_count` (Number) A computed count based on `transition` state. If `start`, count will equal 1. diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md index edd61f18..14e235f9 100644 --- a/docs/data-sources/workspace_preset.md +++ b/docs/data-sources/workspace_preset.md @@ -3,12 +3,12 @@ page_title: "coder_workspace_preset Data Source - terraform-provider-coder" subcategory: "" description: |- - Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are not defined by the preset will still be configurable when creating a workspace. + Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are defined by the template but not defined by the preset will still be configurable when creating a workspace. --- # coder_workspace_preset (Data Source) -Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are not defined by the preset will still be configurable when creating a workspace. +Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are defined by the template but not defined by the preset will still be configurable when creating a workspace. ## Example Usage @@ -39,7 +39,7 @@ data "coder_workspace_preset" "example" { ### Optional - `parameters` (Map of String) Workspace parameters that will be set by the workspace preset. For simple templates that only need prebuilds, you may define a preset with zero parameters. Because workspace parameters may change between Coder template versions, preset parameters are allowed to define values for parameters that do not exist in the current template version. -- `prebuilds` (Block Set, Max: 1) Prebuilt workspace configuration related to this workspace preset. Coder will build and maintain workspaces in reserve based on this configuration. When a user creates a new workspace using a preset, they will be assigned a prebuilt workspace, instead of waiting for a new workspace to build. (see [below for nested schema](#nestedblock--prebuilds)) +- `prebuilds` (Block Set, Max: 1) Configuration for prebuilt workspaces associated with this preset. Coder will maintain a pool of standby workspaces based on this configuration. When a user creates a workspace using this preset, they are assigned a prebuilt workspace instead of waiting for a new one to build. See prebuilt workspace documentation [here](https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces.md) (see [below for nested schema](#nestedblock--prebuilds)) ### Read-Only diff --git a/go.mod b/go.mod index cb415c2b..2d3db5d0 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/docker/docker v26.1.5+incompatible github.com/google/uuid v1.6.0 github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 + github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.1 github.com/masterminds/semver v1.5.0 github.com/mitchellh/mapstructure v1.5.0 @@ -50,7 +51,6 @@ require ( github.com/hashicorp/terraform-exec v0.22.0 // indirect github.com/hashicorp/terraform-json v0.24.0 // indirect github.com/hashicorp/terraform-plugin-go v0.26.0 // indirect - github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.4 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect @@ -79,11 +79,11 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect go.opentelemetry.io/otel/metric v1.31.0 // indirect go.opentelemetry.io/otel/trace v1.31.0 // indirect - golang.org/x/crypto v0.35.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.22.0 // indirect google.golang.org/appengine v1.6.8 // indirect diff --git a/go.sum b/go.sum index 870d770d..77c61771 100644 --- a/go.sum +++ b/go.sum @@ -225,8 +225,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -240,15 +240,15 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -263,8 +263,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -272,8 +272,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/integration/integration_test.go b/integration/integration_test.go index 9803aa41..a5019635 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -181,7 +181,8 @@ func TestIntegration(t *testing.T) { "workspace_owner.ssh_private_key": `(?s)^.+?BEGIN OPENSSH PRIVATE KEY.+?END OPENSSH PRIVATE KEY.+?$`, "workspace_owner.ssh_public_key": `(?s)^ssh-ed25519.+$`, "workspace_owner.login_type": `password`, - "workspace_owner.rbac_roles": `(?is)\[(\{"name":"[a-z0-9-:]+","org_id":"[a-f0-9-]+"\},?)+\]`, + // org_id will either be a uuid or an empty string for site wide roles. + "workspace_owner.rbac_roles": `(?is)\[(\{"name":"[a-z0-9-:]+","org_id":"[a-f0-9-]*"\},?)+\]`, }, }, { diff --git a/provider/agent.go b/provider/agent.go index 3ddae235..ad264030 100644 --- a/provider/agent.go +++ b/provider/agent.go @@ -2,6 +2,8 @@ package provider import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "path/filepath" "reflect" @@ -9,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-log/tflog" "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" @@ -22,10 +25,12 @@ func agentResource() *schema.Resource { SchemaVersion: 1, Description: "Use this resource to associate an agent.", - CreateContext: func(_ context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics { - // This should be a real authentication token! - resourceData.SetId(uuid.NewString()) - err := resourceData.Set("token", uuid.NewString()) + CreateContext: func(ctx context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics { + agentID := uuid.NewString() + resourceData.SetId(agentID) + + token := agentAuthToken(ctx, "") + err := resourceData.Set("token", token) if err != nil { return diag.FromErr(err) } @@ -48,10 +53,12 @@ func agentResource() *schema.Resource { return updateInitScript(resourceData, i) }, ReadWithoutTimeout: func(ctx context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics { - err := resourceData.Set("token", uuid.NewString()) + token := agentAuthToken(ctx, "") + err := resourceData.Set("token", token) if err != nil { return diag.FromErr(err) } + if _, ok := resourceData.GetOk("display_apps"); !ok { err = resourceData.Set("display_apps", []interface{}{ map[string]bool{ @@ -469,3 +476,37 @@ func updateInitScript(resourceData *schema.ResourceData, i interface{}) diag.Dia } return nil } + +func agentAuthToken(ctx context.Context, agentID string) string { + existingToken := helpers.OptionalEnv(RunningAgentTokenEnvironmentVariable(agentID)) + if existingToken == "" { + // Most of the time, we will generate a new token for the agent. + // In the case of a prebuilt workspace being claimed, we will override with + // an existing token provided below. + token := uuid.NewString() + return token + } + + // An existing token was provided for this agent. That means that this + // is a prebuilt workspace in the process of being claimed. + // We should reuse the token. + tflog.Info(ctx, "using provided agent token for prebuild", map[string]interface{}{ + "agent_id": agentID, + }) + return existingToken +} + +// RunningAgentTokenEnvironmentVariable returns the name of an environment variable +// that contains the token to use for the running agent. This is used for prebuilds, +// where we want to reuse the same token for the next iteration of a workspace agent +// before and after the workspace was claimed by a user. +// +// By reusing an existing token, we can avoid the need to change a value that may have been +// used immutably. Thus, allowing us to avoid reprovisioning resources that may take a long time +// to replace. +// +// agentID is unused for now, but will be used as soon as we support multiple agents. +func RunningAgentTokenEnvironmentVariable(agentID string) string { + sum := sha256.Sum256([]byte(agentID)) + return "CODER_RUNNING_WORKSPACE_AGENT_TOKEN_" + hex.EncodeToString(sum[:]) +} diff --git a/provider/formtype_test.go b/provider/formtype_test.go index 599c2e41..eaf7b587 100644 --- a/provider/formtype_test.go +++ b/provider/formtype_test.go @@ -6,6 +6,7 @@ import ( "regexp" "strconv" "strings" + "sync" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" @@ -53,7 +54,7 @@ func TestValidateFormType(t *testing.T) { // formTypesChecked keeps track of all checks run. It will be used to // ensure all combinations of form_type and option_type are tested. // All untested options are assumed to throw an error. - formTypesChecked := make(map[string]struct{}) + var formTypesChecked sync.Map expectType := func(expected provider.ParameterFormType, opts formTypeCheck) formTypeTestCase { ftname := opts.formType @@ -240,12 +241,12 @@ func TestValidateFormType(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() - if _, ok := formTypesChecked[c.config.String()]; ok { + if _, ok := formTypesChecked.Load(c.config.String()); ok { t.Log("Duplicated form type check, delete this extra test case") t.Fatalf("form type %q already checked", c.config.String()) } - formTypesChecked[c.config.String()] = struct{}{} + formTypesChecked.Store(c.config.String(), struct{}{}) formTypeTest(t, c) }) } @@ -282,7 +283,7 @@ func TestValidateFormType(t *testing.T) { } for _, check := range requiredChecks { - if _, alreadyChecked := formTypesChecked[check.String()]; alreadyChecked { + if _, alreadyChecked := formTypesChecked.Load(check.String()); alreadyChecked { continue } diff --git a/provider/parameter.go b/provider/parameter.go index 2f7dc662..f0a26c99 100644 --- a/provider/parameter.go +++ b/provider/parameter.go @@ -21,6 +21,10 @@ import ( "golang.org/x/xerrors" ) +var ( + defaultValuePath = cty.Path{cty.GetAttrStep{Name: "default"}} +) + type Option struct { Name string Description string @@ -46,14 +50,13 @@ const ( ) type Parameter struct { - Value string Name string DisplayName string `mapstructure:"display_name"` Description string Type OptionType FormType ParameterFormType Mutable bool - Default string + Default *string Icon string Option []Option Validation []Validation @@ -82,7 +85,6 @@ func parameterDataSource() *schema.Resource { var parameter Parameter err = mapstructure.Decode(struct { - Value interface{} Name interface{} DisplayName interface{} Description interface{} @@ -97,17 +99,22 @@ func parameterDataSource() *schema.Resource { Order interface{} Ephemeral interface{} }{ - Value: rd.Get("value"), Name: rd.Get("name"), DisplayName: rd.Get("display_name"), Description: rd.Get("description"), Type: rd.Get("type"), FormType: rd.Get("form_type"), Mutable: rd.Get("mutable"), - Default: rd.Get("default"), - Icon: rd.Get("icon"), - Option: rd.Get("option"), - Validation: fixedValidation, + Default: func() *string { + if rd.GetRawConfig().AsValueMap()["default"].IsNull() { + return nil + } + val, _ := rd.Get("default").(string) + return &val + }(), + Icon: rd.Get("icon"), + Option: rd.Get("option"), + Validation: fixedValidation, Optional: func() bool { // This hack allows for checking if the "default" field is present in the .tf file. // If "default" is missing or is "null", then it means that this field is required, @@ -122,19 +129,6 @@ func parameterDataSource() *schema.Resource { if err != nil { return diag.Errorf("decode parameter: %s", err) } - var value string - if parameter.Default != "" { - err := valueIsType(parameter.Type, parameter.Default, cty.Path{cty.GetAttrStep{Name: "default"}}) - if err != nil { - return err - } - value = parameter.Default - } - envValue, ok := os.LookupEnv(ParameterEnvironmentVariable(parameter.Name)) - if ok { - value = envValue - } - rd.Set("value", value) if !parameter.Mutable && parameter.Ephemeral { return diag.Errorf("parameter can't be immutable and ephemeral") @@ -144,38 +138,31 @@ func parameterDataSource() *schema.Resource { return diag.Errorf("ephemeral parameter requires the default property") } - // TODO: Should we move this into the Valid() function on - // Parameter? - if len(parameter.Validation) == 1 { - validation := ¶meter.Validation[0] - err = validation.Valid(parameter.Type, value) - if err != nil { - return diag.FromErr(err) - } + var input *string + envValue, ok := os.LookupEnv(ParameterEnvironmentVariable(parameter.Name)) + if ok { + input = &envValue } - // Validate options - _, parameter.FormType, err = ValidateFormType(parameter.Type, len(parameter.Option), parameter.FormType) - if err != nil { - return diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid form_type for parameter", - Detail: err.Error(), - AttributePath: cty.Path{cty.GetAttrStep{Name: "form_type"}}, - }, - } + var previous *string + envPreviousValue, ok := os.LookupEnv(ParameterEnvironmentVariablePrevious(parameter.Name)) + if ok { + previous = &envPreviousValue } - // Set the form_type back in case the value was changed. - // Eg via a default. If a user does not specify, a default value - // is used and saved. - rd.Set("form_type", parameter.FormType) - diags := parameter.Valid() + value, diags := parameter.ValidateInput(input, previous) if diags.HasError() { return diags } + // Always set back the value, as it can be sourced from the default + rd.Set("value", value) + + // Set the form_type, as if it was unset, a default form_type will be updated on + // the parameter struct. Always set back the updated form_type to be more + // specific than the default empty string. + rd.Set("form_type", parameter.FormType) + return nil }, Schema: map[string]*schema.Schema{ @@ -389,37 +376,49 @@ func fixValidationResourceData(rawConfig cty.Value, validation interface{}) (int return vArr, nil } -func valueIsType(typ OptionType, value string, attrPath cty.Path) diag.Diagnostics { +func valueIsType(typ OptionType, value string) error { switch typ { case OptionTypeNumber: _, err := strconv.ParseFloat(value, 64) if err != nil { - return diag.Errorf("%q is not a number", value) + return fmt.Errorf("%q is not a number", value) } case OptionTypeBoolean: _, err := strconv.ParseBool(value) if err != nil { - return diag.Errorf("%q is not a bool", value) + return fmt.Errorf("%q is not a bool", value) } case OptionTypeListString: - _, diags := valueIsListString(value, attrPath) - if diags.HasError() { - return diags + _, err := valueIsListString(value) + if err != nil { + return err } case OptionTypeString: // Anything is a string! default: - return diag.Errorf("invalid type %q", typ) + return fmt.Errorf("invalid type %q", typ) } return nil } -func (v *Parameter) Valid() diag.Diagnostics { +func (v *Parameter) ValidateInput(input *string, previous *string) (string, diag.Diagnostics) { + var err error + var optionType OptionType + + valuePath := cty.Path{} + value := input + if input == nil { + value = v.Default + if v.Default != nil { + valuePath = defaultValuePath + } + } + // optionType might differ from parameter.Type. This is ok, and parameter.Type // should be used for the value type, and optionType for options. - optionType, _, err := ValidateFormType(v.Type, len(v.Option), v.FormType) + optionType, v.FormType, err = ValidateFormType(v.Type, len(v.Option), v.FormType) if err != nil { - return diag.Diagnostics{ + return "", diag.Diagnostics{ { Severity: diag.Error, Summary: "Invalid form_type for parameter", @@ -429,53 +428,120 @@ func (v *Parameter) Valid() diag.Diagnostics { } } - optionNames := map[string]any{} - optionValues := map[string]any{} - if len(v.Option) > 0 { - for _, option := range v.Option { - _, exists := optionNames[option.Name] - if exists { - return diag.Diagnostics{{ - Severity: diag.Error, - Summary: "Option names must be unique.", - Detail: fmt.Sprintf("multiple options found with the same name %q", option.Name), - }, - } - } - _, exists = optionValues[option.Value] - if exists { - return diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Option values must be unique.", - Detail: fmt.Sprintf("multiple options found with the same value %q", option.Value), - }, - } - } - diags := valueIsType(optionType, option.Value, cty.Path{}) - if diags.HasError() { - return diags - } - optionValues[option.Value] = nil - optionNames[option.Name] = nil + optionValues, diags := v.ValidOptions(optionType) + if diags.HasError() { + return "", diags + } + + // TODO: This is a bit of a hack. The current behavior states if validation + // is given, then apply validation to unset values. + // value == nil should not be accepted in the first place. + // To fix this, value should be coerced to an empty string + // if it is nil. Then let the validation logic always apply. + if len(v.Validation) == 0 && value == nil { + return "", nil + } + + // forcedValue ensures the value is not-nil. + var forcedValue string + if value != nil { + forcedValue = *value + } + + d := v.validValue(forcedValue, previous, optionType, optionValues, valuePath) + if d.HasError() { + return "", d + } + + err = valueIsType(v.Type, forcedValue) + if err != nil { + return "", diag.Diagnostics{ + { + Severity: diag.Error, + Summary: fmt.Sprintf("Parameter value is not of type %q", v.Type), + Detail: err.Error(), + }, } } - if v.Default != "" && len(v.Option) > 0 { + return forcedValue, nil +} + +func (v *Parameter) ValidOptions(optionType OptionType) (map[string]struct{}, diag.Diagnostics) { + optionNames := map[string]struct{}{} + optionValues := map[string]struct{}{} + + var diags diag.Diagnostics + for _, option := range v.Option { + _, exists := optionNames[option.Name] + if exists { + return nil, diag.Diagnostics{{ + Severity: diag.Error, + Summary: "Option names must be unique.", + Detail: fmt.Sprintf("multiple options found with the same name %q", option.Name), + }} + } + + _, exists = optionValues[option.Value] + if exists { + return nil, diag.Diagnostics{{ + Severity: diag.Error, + Summary: "Option values must be unique.", + Detail: fmt.Sprintf("multiple options found with the same value %q", option.Value), + }} + } + + err := valueIsType(optionType, option.Value) + if err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("Option %q with value=%q is not of type %q", option.Name, option.Value, optionType), + Detail: err.Error(), + }) + continue + } + optionValues[option.Value] = struct{}{} + optionNames[option.Name] = struct{}{} + + // Option values are assumed to be valid. Do not call validValue on them. + } + + if diags != nil && diags.HasError() { + return nil, diags + } + return optionValues, nil +} + +func (v *Parameter) validValue(value string, previous *string, optionType OptionType, optionValues map[string]struct{}, path cty.Path) diag.Diagnostics { + // name is used for constructing more precise error messages. + name := "Value" + if path.Equals(defaultValuePath) { + name = "Default value" + } + + // First validate if the value is a valid option + if len(optionValues) > 0 { if v.Type == OptionTypeListString && optionType == OptionTypeString { // If the type is list(string) and optionType is string, we have - // to ensure all elements of the default exist as options. - defaultValues, diags := valueIsListString(v.Default, cty.Path{cty.GetAttrStep{Name: "default"}}) - if diags.HasError() { - return diags + // to ensure all elements of the value exist as options. + listValues, err := valueIsListString(value) + if err != nil { + return diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "When using list(string) type, value must be a json encoded list of strings", + Detail: err.Error(), + AttributePath: path, + }, + } } // missing is used to construct a more helpful error message var missing []string - for _, defaultValue := range defaultValues { - _, defaultIsValid := optionValues[defaultValue] - if !defaultIsValid { - missing = append(missing, defaultValue) + for _, listValue := range listValues { + _, isValid := optionValues[listValue] + if !isValid { + missing = append(missing, listValue) } } @@ -483,34 +549,53 @@ func (v *Parameter) Valid() diag.Diagnostics { return diag.Diagnostics{ { Severity: diag.Error, - Summary: "Default values must be a valid option", + Summary: fmt.Sprintf("%ss must be a valid option", name), Detail: fmt.Sprintf( - "default value %q is not a valid option, values %q are missing from the options", - v.Default, strings.Join(missing, ", "), + "%s %q is not a valid option, values %q are missing from the options", + name, value, strings.Join(missing, ", "), ), - AttributePath: cty.Path{cty.GetAttrStep{Name: "default"}}, + AttributePath: path, }, } } } else { - _, defaultIsValid := optionValues[v.Default] - if !defaultIsValid { + _, isValid := optionValues[value] + if !isValid { + extra := "" + if value == "" { + extra = ". The value is empty, did you forget to set it with a default or from user input?" + } return diag.Diagnostics{ { Severity: diag.Error, - Summary: "Default value must be a valid option", - Detail: fmt.Sprintf("the value %q must be defined as one of options", v.Default), - AttributePath: cty.Path{cty.GetAttrStep{Name: "default"}}, + Summary: fmt.Sprintf("%s must be a valid option%s", name, extra), + Detail: fmt.Sprintf("the value %q must be defined as one of options", value), + AttributePath: path, }, } } } } + if len(v.Validation) == 1 { + validCheck := &v.Validation[0] + err := validCheck.Valid(v.Type, value, previous) + if err != nil { + return diag.Diagnostics{ + { + Severity: diag.Error, + Summary: fmt.Sprintf("Invalid parameter %s according to 'validation' block", strings.ToLower(name)), + Detail: err.Error(), + AttributePath: path, + }, + } + } + } + return nil } -func (v *Validation) Valid(typ OptionType, value string) error { +func (v *Validation) Valid(typ OptionType, value string, previous *string) error { if typ != OptionTypeNumber { if !v.MinDisabled { return fmt.Errorf("a min cannot be specified for a %s type", typ) @@ -560,6 +645,34 @@ func (v *Validation) Valid(typ OptionType, value string) error { if v.Monotonic != "" && v.Monotonic != ValidationMonotonicIncreasing && v.Monotonic != ValidationMonotonicDecreasing { return fmt.Errorf("number monotonicity can be either %q or %q", ValidationMonotonicIncreasing, ValidationMonotonicDecreasing) } + + switch v.Monotonic { + case "": + // No monotonicity check + case ValidationMonotonicIncreasing, ValidationMonotonicDecreasing: + if previous != nil { // Only check if previous value exists + previousNum, err := strconv.Atoi(*previous) + if err != nil { + // Do not throw an error for the previous value not being a number. Throwing an + // error here would cause an unrepairable state for the user. This is + // unfortunate, but there is not much we can do at this point. + // TODO: Maybe we should enforce this, and have the calling coderd + // do something to resolve it. Such as doing this check before calling + // terraform apply. + break + } + + if v.Monotonic == ValidationMonotonicIncreasing && !(num >= previousNum) { + return fmt.Errorf("parameter value '%d' must be equal or greater than previous value: %d", num, previousNum) + } + + if v.Monotonic == ValidationMonotonicDecreasing && !(num <= previousNum) { + return fmt.Errorf("parameter value '%d' must be equal or lower than previous value: %d", num, previousNum) + } + } + default: + return fmt.Errorf("number monotonicity can be either %q or %q", ValidationMonotonicIncreasing, ValidationMonotonicDecreasing) + } case OptionTypeListString: var listOfStrings []string err := json.Unmarshal([]byte(value), &listOfStrings) @@ -570,18 +683,11 @@ func (v *Validation) Valid(typ OptionType, value string) error { return nil } -func valueIsListString(value string, path cty.Path) ([]string, diag.Diagnostics) { +func valueIsListString(value string) ([]string, error) { var items []string err := json.Unmarshal([]byte(value), &items) if err != nil { - return nil, diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "When using list(string) type, value must be a json encoded list of strings", - Detail: fmt.Sprintf("value %q is not a valid list of strings", value), - AttributePath: path, - }, - } + return nil, fmt.Errorf("value %q is not a valid list of strings", value) } return items, nil } @@ -594,6 +700,15 @@ func ParameterEnvironmentVariable(name string) string { return "CODER_PARAMETER_" + hex.EncodeToString(sum[:]) } +// ParameterEnvironmentVariablePrevious returns the environment variable to +// specify for a parameter's previous value. This is used for workspace +// subsequent builds after the first. Primarily to validate monotonicity in the +// `validation` block. +func ParameterEnvironmentVariablePrevious(name string) string { + sum := sha256.Sum256([]byte(name)) + return "CODER_PARAMETER_PREVIOUS_" + hex.EncodeToString(sum[:]) +} + func takeFirstError(errs ...error) error { for _, err := range errs { if err != nil { diff --git a/provider/parameter_test.go b/provider/parameter_test.go index 4b52d943..9b5e76f1 100644 --- a/provider/parameter_test.go +++ b/provider/parameter_test.go @@ -2,12 +2,15 @@ package provider_test import ( "fmt" + "os" "regexp" + "strconv" "strings" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/terraform-provider-coder/v2/provider" @@ -82,6 +85,7 @@ func TestParameter(t *testing.T) { data "coder_parameter" "region" { name = "Region" type = "number" + default = 1 option { name = "1" value = "1" @@ -99,6 +103,7 @@ func TestParameter(t *testing.T) { data "coder_parameter" "region" { name = "Region" type = "string" + default = "1" option { name = "1" value = "1" @@ -686,12 +691,406 @@ data "coder_parameter" "region" { } } +func TestParameterValidation(t *testing.T) { + t.Parallel() + opts := func(vals ...string) []provider.Option { + options := make([]provider.Option, 0, len(vals)) + for _, val := range vals { + options = append(options, provider.Option{ + Name: val, + Value: val, + }) + } + return options + } + + for _, tc := range []struct { + Name string + Parameter provider.Parameter + Value string + ExpectError *regexp.Regexp + }{ + { + Name: "ValidStringParameter", + Parameter: provider.Parameter{ + Type: "string", + }, + Value: "alpha", + }, + // Test invalid states + { + Name: "InvalidFormType", + Parameter: provider.Parameter{ + Type: "string", + Option: opts("alpha", "bravo", "charlie"), + FormType: provider.ParameterFormTypeSlider, + }, + Value: "alpha", + ExpectError: regexp.MustCompile("Invalid form_type for parameter"), + }, + { + Name: "NotInOptions", + Parameter: provider.Parameter{ + Type: "string", + Option: opts("alpha", "bravo", "charlie"), + }, + Value: "delta", // not in option set + ExpectError: regexp.MustCompile("Value must be a valid option"), + }, + { + Name: "NumberNotInOptions", + Parameter: provider.Parameter{ + Type: "number", + Option: opts("1", "2", "3"), + }, + Value: "0", // not in option set + ExpectError: regexp.MustCompile("Value must be a valid option"), + }, + { + Name: "NonUniqueOptionNames", + Parameter: provider.Parameter{ + Type: "string", + Option: opts("alpha", "alpha"), + }, + Value: "alpha", + ExpectError: regexp.MustCompile("Option names must be unique"), + }, + { + Name: "NonUniqueOptionValues", + Parameter: provider.Parameter{ + Type: "string", + Option: []provider.Option{ + {Name: "Alpha", Value: "alpha"}, + {Name: "AlphaAgain", Value: "alpha"}, + }, + }, + Value: "alpha", + ExpectError: regexp.MustCompile("Option values must be unique"), + }, + { + Name: "IncorrectValueTypeOption", + Parameter: provider.Parameter{ + Type: "number", + Option: opts("not-a-number"), + }, + Value: "5", + ExpectError: regexp.MustCompile("is not a number"), + }, + { + Name: "IncorrectValueType", + Parameter: provider.Parameter{ + Type: "number", + }, + Value: "not-a-number", + ExpectError: regexp.MustCompile("Parameter value is not of type \"number\""), + }, + { + Name: "NotListStringDefault", + Parameter: provider.Parameter{ + Type: "list(string)", + Default: ptr("not-a-list"), + }, + ExpectError: regexp.MustCompile("not a valid list of strings"), + }, + { + Name: "NotListStringDefault", + Parameter: provider.Parameter{ + Type: "list(string)", + }, + Value: "not-a-list", + ExpectError: regexp.MustCompile("not a valid list of strings"), + }, + { + Name: "DefaultListStringNotInOptions", + Parameter: provider.Parameter{ + Type: "list(string)", + Default: ptr(`["red", "yellow", "black"]`), + Option: opts("red", "blue", "green"), + FormType: provider.ParameterFormTypeMultiSelect, + }, + Value: `["red", "yellow", "black"]`, + ExpectError: regexp.MustCompile("is not a valid option, values \"yellow, black\" are missing from the options"), + }, + { + Name: "ListStringNotInOptions", + Parameter: provider.Parameter{ + Type: "list(string)", + Default: ptr(`["red"]`), + Option: opts("red", "blue", "green"), + FormType: provider.ParameterFormTypeMultiSelect, + }, + Value: `["red", "yellow", "black"]`, + ExpectError: regexp.MustCompile("is not a valid option, values \"yellow, black\" are missing from the options"), + }, + { + Name: "InvalidMiniumum", + Parameter: provider.Parameter{ + Type: "number", + Default: ptr("5"), + Validation: []provider.Validation{{ + Min: 10, + Error: "must be greater than 10", + }}, + }, + ExpectError: regexp.MustCompile("must be greater than 10"), + }, + } { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + value := &tc.Value + _, diags := tc.Parameter.ValidateInput(value, nil) + if tc.ExpectError != nil { + require.True(t, diags.HasError()) + errMsg := fmt.Sprintf("%+v", diags[0]) // close enough + require.Truef(t, tc.ExpectError.MatchString(errMsg), "got: %s", errMsg) + } else { + if !assert.False(t, diags.HasError()) { + t.Logf("got: %+v", diags[0]) + } + } + }) + } +} + +// TestParameterValidationEnforcement tests various parameter states and the +// validation enforcement that should be applied to them. The table is described +// by a markdown table. This is done so that the test cases can be more easily +// edited and read. +// +// Copy and paste the table to https://www.tablesgenerator.com/markdown_tables for easier editing +// +//nolint:paralleltest,tparallel // Parameters load values from env vars +func TestParameterValidationEnforcement(t *testing.T) { + // Some interesting observations: + // - Validation logic does not apply to the value of 'options' + // - [NumDefInvOpt] So an invalid option can be present and selected, but would fail + // - Validation logic does not apply to the default if a value is given + // - [NumIns/DefInv] So the default can be invalid if an input value is valid. + // The value is therefore not really optional, but it is marked as such. + table, err := os.ReadFile("testdata/parameter_table.md") + require.NoError(t, err) + + type row struct { + Name string + Types []string + InputValue string + Default string + Options []string + Validation *provider.Validation + OutputValue string + Optional bool + CreateError *regexp.Regexp + Previous *string + } + + rows := make([]row, 0) + lines := strings.Split(string(table), "\n") + validMinMax := regexp.MustCompile("^[0-9]*-[0-9]*$") + for _, line := range lines[2:] { + columns := strings.Split(line, "|") + columns = columns[1 : len(columns)-1] + for i := range columns { + // Trim the whitespace from all columns + columns[i] = strings.TrimSpace(columns[i]) + } + + if columns[0] == "" { + continue // Skip rows with empty names + } + + cname, ctype, cprev, cinput, cdefault, coptions, cvalidation, _, coutput, coptional, cerr := + columns[0], columns[1], columns[2], columns[3], columns[4], columns[5], columns[6], columns[7], columns[8], columns[9], columns[10] + + optional, err := strconv.ParseBool(coptional) + if coptional != "" { + // Value does not matter if not specified + require.NoError(t, err) + } + + var rerr *regexp.Regexp + if cerr != "" { + rerr, err = regexp.Compile(cerr) + if err != nil { + t.Fatalf("failed to parse error column %q: %v", cerr, err) + } + } + + var options []string + if coptions != "" { + options = strings.Split(coptions, ",") + } + + var validation *provider.Validation + if cvalidation != "" { + switch { + case cvalidation == provider.ValidationMonotonicIncreasing || cvalidation == provider.ValidationMonotonicDecreasing: + validation = &provider.Validation{ + MinDisabled: true, + MaxDisabled: true, + Monotonic: cvalidation, + Error: "monotonicity", + } + case validMinMax.MatchString(cvalidation): + // Min-Max validation should look like: + // 1-10 :: min=1, max=10 + // -10 :: max=10 + // 1- :: min=1 + parts := strings.Split(cvalidation, "-") + min, _ := strconv.ParseInt(parts[0], 10, 64) + max, _ := strconv.ParseInt(parts[1], 10, 64) + validation = &provider.Validation{ + Min: int(min), + MinDisabled: parts[0] == "", + Max: int(max), + MaxDisabled: parts[1] == "", + Monotonic: "", + Regex: "", + Error: "{min} < {value} < {max}", + } + default: + validation = &provider.Validation{ + Min: 0, + MinDisabled: true, + Max: 0, + MaxDisabled: true, + Monotonic: "", + Regex: cvalidation, + Error: "regex error", + } + } + } + + var prev *string + if cprev != "" { + prev = ptr(cprev) + if cprev == `""` { + prev = ptr("") + } + } + rows = append(rows, row{ + Name: cname, + Types: strings.Split(ctype, ","), + InputValue: cinput, + Default: cdefault, + Options: options, + Validation: validation, + OutputValue: coutput, + Optional: optional, + CreateError: rerr, + Previous: prev, + }) + } + + stringLiteral := func(s string) string { + if s == "" { + return `""` + } + return fmt.Sprintf("%q", s) + } + + for rowIndex, row := range rows { + for _, rt := range row.Types { + //nolint:paralleltest,tparallel // Parameters load values from env vars + t.Run(fmt.Sprintf("%d|%s:%s", rowIndex, row.Name, rt), func(t *testing.T) { + if row.InputValue != "" { + t.Setenv(provider.ParameterEnvironmentVariable("parameter"), row.InputValue) + } + if row.Previous != nil { + t.Setenv(provider.ParameterEnvironmentVariablePrevious("parameter"), *row.Previous) + } + + if row.CreateError != nil && row.OutputValue != "" { + t.Errorf("output value %q should not be set if both errors are set", row.OutputValue) + } + + var cfg strings.Builder + cfg.WriteString("data \"coder_parameter\" \"parameter\" {\n") + cfg.WriteString("\tname = \"parameter\"\n") + if rt == "multi-select" || rt == "tag-select" { + cfg.WriteString(fmt.Sprintf("\ttype = \"%s\"\n", "list(string)")) + cfg.WriteString(fmt.Sprintf("\tform_type = \"%s\"\n", rt)) + } else { + cfg.WriteString(fmt.Sprintf("\ttype = \"%s\"\n", rt)) + } + if row.Default != "" { + cfg.WriteString(fmt.Sprintf("\tdefault = %s\n", stringLiteral(row.Default))) + } + + for _, opt := range row.Options { + cfg.WriteString("\toption {\n") + cfg.WriteString(fmt.Sprintf("\t\tname = %s\n", stringLiteral(opt))) + cfg.WriteString(fmt.Sprintf("\t\tvalue = %s\n", stringLiteral(opt))) + cfg.WriteString("\t}\n") + } + + if row.Validation != nil { + cfg.WriteString("\tvalidation {\n") + if !row.Validation.MinDisabled { + cfg.WriteString(fmt.Sprintf("\t\tmin = %d\n", row.Validation.Min)) + } + if !row.Validation.MaxDisabled { + cfg.WriteString(fmt.Sprintf("\t\tmax = %d\n", row.Validation.Max)) + } + if row.Validation.Monotonic != "" { + cfg.WriteString(fmt.Sprintf("\t\tmonotonic = \"%s\"\n", row.Validation.Monotonic)) + } + if row.Validation.Regex != "" { + cfg.WriteString(fmt.Sprintf("\t\tregex = %q\n", row.Validation.Regex)) + } + cfg.WriteString(fmt.Sprintf("\t\terror = %q\n", row.Validation.Error)) + cfg.WriteString("\t}\n") + } + + cfg.WriteString("}\n") + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: cfg.String(), + ExpectError: row.CreateError, + 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.parameter"] + require.NotNil(t, param) + + if row.Default == "" { + _, ok := param.Primary.Attributes["default"] + require.False(t, ok, "default should not be set") + } else { + require.Equal(t, strings.Trim(row.Default, `"`), param.Primary.Attributes["default"]) + } + + if row.OutputValue == "" { + _, ok := param.Primary.Attributes["value"] + require.False(t, ok, "output value should not be set") + } else { + require.Equal(t, strings.Trim(row.OutputValue, `"`), param.Primary.Attributes["value"]) + } + + for key, expected := range map[string]string{ + "optional": strconv.FormatBool(row.Optional), + } { + require.Equal(t, expected, param.Primary.Attributes[key], "optional") + } + + return nil + }, + }}, + }) + }) + } + } +} + func TestValueValidatesType(t *testing.T) { t.Parallel() for _, tc := range []struct { Name string Type provider.OptionType Value string + Previous *string Regex string RegexError string Min int @@ -779,6 +1178,75 @@ func TestValueValidatesType(t *testing.T) { Min: 0, Max: 2, Monotonic: "decreasing", + }, { + Name: "IncreasingMonotonicityEqual", + Type: "number", + Previous: ptr("1"), + Value: "1", + Monotonic: "increasing", + MinDisabled: true, + MaxDisabled: true, + }, { + Name: "DecreasingMonotonicityEqual", + Type: "number", + Value: "1", + Previous: ptr("1"), + Monotonic: "decreasing", + MinDisabled: true, + MaxDisabled: true, + }, { + Name: "IncreasingMonotonicityGreater", + Type: "number", + Previous: ptr("0"), + Value: "1", + Monotonic: "increasing", + MinDisabled: true, + MaxDisabled: true, + }, { + Name: "DecreasingMonotonicityGreater", + Type: "number", + Value: "1", + Previous: ptr("0"), + Monotonic: "decreasing", + MinDisabled: true, + MaxDisabled: true, + Error: regexp.MustCompile("must be equal or"), + }, { + Name: "IncreasingMonotonicityLesser", + Type: "number", + Previous: ptr("2"), + Value: "1", + Monotonic: "increasing", + MinDisabled: true, + MaxDisabled: true, + Error: regexp.MustCompile("must be equal or"), + }, { + Name: "DecreasingMonotonicityLesser", + Type: "number", + Value: "1", + Previous: ptr("2"), + Monotonic: "decreasing", + MinDisabled: true, + MaxDisabled: true, + }, { + Name: "ValidListOfStrings", + Type: "list(string)", + Value: `["first","second","third"]`, + MinDisabled: true, + MaxDisabled: true, + }, { + Name: "InvalidListOfStrings", + Type: "list(string)", + Value: `["first","second","third"`, + MinDisabled: true, + MaxDisabled: true, + Error: regexp.MustCompile("is not valid list of strings"), + }, { + Name: "EmptyListOfStrings", + Type: "list(string)", + Value: `[]`, + MinDisabled: true, + MaxDisabled: true, }, { Name: "ValidListOfStrings", Type: "list(string)", @@ -811,7 +1279,7 @@ func TestValueValidatesType(t *testing.T) { Regex: tc.Regex, Error: tc.RegexError, } - err := v.Valid(tc.Type, tc.Value) + err := v.Valid(tc.Type, tc.Value, tc.Previous) if tc.Error != nil { require.Error(t, err) require.True(t, tc.Error.MatchString(err.Error()), "got: %s", err.Error()) @@ -861,3 +1329,7 @@ func TestParameterWithManyOptions(t *testing.T) { }}, }) } + +func ptr[T any](v T) *T { + return &v +} diff --git a/provider/testdata/parameter_table.md b/provider/testdata/parameter_table.md new file mode 100644 index 00000000..6087460f --- /dev/null +++ b/provider/testdata/parameter_table.md @@ -0,0 +1,95 @@ +| Name | Type | Previous | Input | Default | Options | Validation | -> | Output | Optional | ErrorCreate | +|----------------------|---------------|----------|-----------|---------|-------------------|------------|----|--------|----------|-----------------| +| | Empty Vals | | | | | | | | | | +| Empty | string,number | | | | | | | "" | false | | +| EmptyDupeOps | string,number | | | | 1,1,1 | | | | | unique | +| EmptyList | list(string) | | | | | | | "" | false | | +| EmptyListDupeOpts | list(string) | | | | ["a"],["a"] | | | | | unique | +| EmptyMulti | tag-select | | | | | | | "" | false | | +| EmptyOpts | string,number | | | | 1,2,3 | | | "" | false | | +| EmptyRegex | string | | | | | world | | | | regex error | +| EmptyMin | number | | | | | 1-10 | | | | 1 < < 10 | +| EmptyMinOpt | number | | | | 1,2,3 | 2-5 | | | | valid option | +| EmptyRegexOpt | string | | | | "hello","goodbye" | goodbye | | | | valid option | +| EmptyRegexOk | string | | | | | .* | | "" | false | | +| | | | | | | | | | | | +| | Default Set | | No inputs | | | | | | | | +| NumDef | number | | | 5 | | | | 5 | true | | +| NumDefVal | number | | | 5 | | 3-7 | | 5 | true | | +| NumDefInv | number | | | 5 | | 10- | | | | 10 < 5 < 0 | +| NumDefOpts | number | | | 5 | 1,3,5,7 | 2-6 | | 5 | true | | +| NumDefNotOpts | number | | | 5 | 1,3,7,9 | 2-6 | | | | valid option | +| NumDefInvOpt | number | | | 5 | 1,3,5,7 | 6-10 | | | | 6 < 5 < 10 | +| NumDefNotNum | number | | | a | | | | | | type "number" | +| NumDefOptsNotNum | number | | | 1 | 1,a,2 | | | | | type "number" | +| NumDefInc | number | 4 | | 5 | | increasing | | 5 | true | | +| NumDefIncBad | number | 6 | | 5 | | increasing | | | | greater | +| NumDefDec | number | 6 | | 5 | | decreasing | | 5 | true | | +| NumDefDecBad | number | 4 | | 5 | | decreasing | | | | lower | +| NumDefDecEq | number | 5 | | 5 | | decreasing | | 5 | true | | +| NumDefIncEq | number | 5 | | 5 | | increasing | | 5 | true | | +| NumDefIncNaN | number | a | | 5 | | increasing | | 5 | true | | +| NumDefDecNaN | number | b | | 5 | | decreasing | | 5 | true | | +| | | | | | | | | | | | +| StrDef | string | | | hello | | | | hello | true | | +| StrMonotonicity | string | | | hello | | increasing | | | | monotonic | +| StrDefInv | string | | | hello | | world | | | | regex error | +| StrDefOpts | string | | | a | a,b,c | | | a | true | | +| StrDefNotOpts | string | | | a | b,c,d | | | | | valid option | +| StrDefValOpts | string | | | a | a,b,c,d,e,f | [a-c] | | a | true | | +| StrDefInvOpt | string | | | d | a,b,c,d,e,f | [a-c] | | | | regex error | +| | | | | | | | | | | | +| LStrDef | list(string) | | | ["a"] | | | | ["a"] | true | | +| LStrDefOpts | list(string) | | | ["a"] | ["a"], ["b"] | | | ["a"] | true | | +| LStrDefNotOpts | list(string) | | | ["a"] | ["b"], ["c"] | | | | | valid option | +| | | | | | | | | | | | +| MulDef | tag-select | | | ["a"] | | | | ["a"] | true | | +| MulDefOpts | multi-select | | | ["a"] | a,b | | | ["a"] | true | | +| MulDefNotOpts | multi-select | | | ["a"] | b,c | | | | | valid option | +| | | | | | | | | | | | +| | Input Vals | | | | | | | | | | +| NumIns | number | | 3 | | | | | 3 | false | | +| NumInsOptsNaN | number | | 3 | 5 | a,1,2,3,4,5 | 1-3 | | | | type "number" | +| NumInsNotNum | number | | a | | | | | | | type "number" | +| NumInsNotNumInv | number | | a | | | 1-3 | | | | 1 < a < 3 | +| NumInsDef | number | | 3 | 5 | | | | 3 | true | | +| NumIns/DefInv | number | | 3 | 5 | | 1-3 | | 3 | true | | +| NumIns=DefInv | number | | 5 | 5 | | 1-3 | | | | 1 < 5 < 3 | +| NumInsOpts | number | | 3 | 5 | 1,2,3,4,5 | 1-3 | | 3 | true | | +| NumInsNotOptsVal | number | | 3 | 5 | 1,2,4,5 | 1-3 | | | | valid option | +| NumInsNotOptsInv | number | | 3 | 5 | 1,2,4,5 | 1-2 | | | true | valid option | +| NumInsNotOpts | number | | 3 | 5 | 1,2,4,5 | | | | | valid option | +| NumInsNotOpts/NoDef | number | | 3 | | 1,2,4,5 | | | | | valid option | +| NumInsInc | number | 4 | 5 | 3 | | increasing | | 5 | true | | +| NumInsIncBad | number | 6 | 5 | 7 | | increasing | | | | greater | +| NumInsDec | number | 6 | 5 | 7 | | decreasing | | 5 | true | | +| NumInsDecBad | number | 4 | 5 | 3 | | decreasing | | | | lower | +| NumInsDecEq | number | 5 | 5 | 5 | | decreasing | | 5 | true | | +| NumInsIncEq | number | 5 | 5 | 5 | | increasing | | 5 | true | | +| | | | | | | | | | | | +| StrIns | string | | c | | | | | c | false | | +| StrInsDupeOpts | string | | c | | a,b,c,c | | | | | unique | +| StrInsDef | string | | c | e | | | | c | true | | +| StrIns/DefInv | string | | c | e | | [a-c] | | c | true | | +| StrIns=DefInv | string | | e | e | | [a-c] | | | | regex error | +| StrInsOpts | string | | c | e | a,b,c,d,e | [a-c] | | c | true | | +| StrInsNotOptsVal | string | | c | e | a,b,d,e | [a-c] | | | | valid option | +| StrInsNotOptsInv | string | | c | e | a,b,d,e | [a-b] | | | | valid option | +| StrInsNotOpts | string | | c | e | a,b,d,e | | | | | valid option | +| StrInsNotOpts/NoDef | string | | c | | a,b,d,e | | | | | valid option | +| StrInsBadVal | string | | c | | a,b,c,d,e | 1-10 | | | | min cannot | +| | | | | | | | | | | | +| | list(string) | | | | | | | | | | +| LStrIns | list(string) | | ["c"] | | | | | ["c"] | false | | +| LStrInsNotList | list(string) | | c | | | | | | | list of strings | +| LStrInsDef | list(string) | | ["c"] | ["e"] | | | | ["c"] | true | | +| LStrIns/DefInv | list(string) | | ["c"] | ["e"] | | [a-c] | | | | regex cannot | +| LStrInsOpts | list(string) | | ["c"] | ["e"] | ["c"],["d"],["e"] | | | ["c"] | true | | +| LStrInsNotOpts | list(string) | | ["c"] | ["e"] | ["d"],["e"] | | | | | valid option | +| LStrInsNotOpts/NoDef | list(string) | | ["c"] | | ["d"],["e"] | | | | | valid option | +| | | | | | | | | | | | +| MulInsOpts | multi-select | | ["c"] | ["e"] | c,d,e | | | ["c"] | true | | +| MulInsNotListOpts | multi-select | | c | ["e"] | c,d,e | | | | | json encoded | +| MulInsNotOpts | multi-select | | ["c"] | ["e"] | d,e | | | | | valid option | +| MulInsNotOpts/NoDef | multi-select | | ["c"] | | d,e | | | | | valid option | +| MulInsInvOpts | multi-select | | ["c"] | ["e"] | c,d,e | [a-c] | | | | regex cannot | \ No newline at end of file diff --git a/provider/workspace.go b/provider/workspace.go index 5ddd3ee8..58100a88 100644 --- a/provider/workspace.go +++ b/provider/workspace.go @@ -4,6 +4,7 @@ import ( "context" "reflect" "strconv" + "strings" "github.com/google/uuid" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -27,13 +28,26 @@ func workspaceDataSource() *schema.Resource { } _ = rd.Set("start_count", count) - prebuild := helpers.OptionalEnv(IsPrebuildEnvironmentVariable()) - prebuildCount := 0 - if prebuild == "true" { - prebuildCount = 1 + if isPrebuiltWorkspace() { + _ = rd.Set("prebuild_count", 1) _ = rd.Set("is_prebuild", true) + + // A claim can only take place AFTER a prebuild, so it's not logically consistent to have this set to any other value. + _ = rd.Set("is_prebuild_claim", false) + } else { + _ = rd.Set("prebuild_count", 0) + _ = rd.Set("is_prebuild", false) + } + if isPrebuiltWorkspaceClaim() { + // Indicate that a prebuild claim has taken place. + _ = rd.Set("is_prebuild_claim", true) + + // A claim can only take place AFTER a prebuild, so it's not logically consistent to have these set to any other values. + _ = rd.Set("prebuild_count", 0) + _ = rd.Set("is_prebuild", false) + } else { + _ = rd.Set("is_prebuild_claim", false) } - _ = rd.Set("prebuild_count", prebuildCount) name := helpers.OptionalEnvOrDefault("CODER_WORKSPACE_NAME", "default") rd.Set("name", name) @@ -116,6 +130,11 @@ func workspaceDataSource() *schema.Resource { Computed: true, Description: "Similar to `prebuild_count`, but a boolean value instead of a count. This is set to true if the workspace is a currently unassigned prebuild. Once the workspace is assigned, this value will be false.", }, + "is_prebuild_claim": { + Type: schema.TypeBool, + Computed: true, + Description: "Indicates whether a prebuilt workspace has just been claimed and this is the first `apply` after that occurrence.", + }, "name": { Type: schema.TypeString, Computed: true, @@ -140,6 +159,47 @@ func workspaceDataSource() *schema.Resource { } } +// isPrebuiltWorkspace returns true if the workspace is an unclaimed prebuilt workspace. +func isPrebuiltWorkspace() bool { + return strings.EqualFold(helpers.OptionalEnv(IsPrebuildEnvironmentVariable()), "true") +} + +// isPrebuiltWorkspaceClaim returns true if the workspace is a prebuilt workspace which has just been claimed. +func isPrebuiltWorkspaceClaim() bool { + return strings.EqualFold(helpers.OptionalEnv(IsPrebuildClaimEnvironmentVariable()), "true") +} + +// IsPrebuildEnvironmentVariable returns the name of the environment variable that +// indicates whether the workspace is an unclaimed prebuilt workspace. +// +// Knowing whether the workspace is an unclaimed prebuilt workspace allows template +// authors to conditionally execute code in the template based on whether the workspace +// has been assigned to a user or not. This allows identity specific configuration to +// be applied only after the workspace is claimed, while the rest of the workspace can +// be pre-configured. +// +// The value of this environment variable should be set to "true" if the workspace is prebuilt +// and it has not yet been claimed by a user. Any other values, including "false" +// and "" will be interpreted to mean that the workspace is not prebuilt, or was +// prebuilt but has since been claimed by a user. func IsPrebuildEnvironmentVariable() string { return "CODER_WORKSPACE_IS_PREBUILD" } + +// IsPrebuildClaimEnvironmentVariable returns the name of the environment variable that +// indicates whether the workspace is a prebuilt workspace which has just been claimed, and this is the first Terraform +// apply after that occurrence. +// +// Knowing whether the workspace is a claimed prebuilt workspace allows template +// authors to conditionally execute code in the template based on whether the workspace +// has been assigned to a user or not. This allows identity specific configuration to +// be applied only after the workspace is claimed, while the rest of the workspace can +// be pre-configured. +// +// The value of this environment variable should be set to "true" if the workspace is prebuilt +// and it has just been claimed by a user. Any other values, including "false" +// and "" will be interpreted to mean that the workspace is not prebuilt, or was +// prebuilt but has not been claimed by a user. +func IsPrebuildClaimEnvironmentVariable() string { + return "CODER_WORKSPACE_IS_PREBUILD_CLAIM" +} diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go index 2fab0b8a..78466c10 100644 --- a/provider/workspace_preset.go +++ b/provider/workspace_preset.go @@ -28,7 +28,8 @@ func workspacePresetDataSource() *schema.Resource { return &schema.Resource{ SchemaVersion: 1, - Description: "Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are not defined by the preset will still be configurable when creating a workspace.", + Description: "Use this data source to predefine common configurations for coder workspaces. Users will have the option to select a defined preset, which will automatically apply the selected configuration. Any parameters defined in the preset will be applied to the workspace. Parameters that are defined by the template but not defined by the preset will still be configurable when creating a workspace.", + ReadContext: func(ctx context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics { var preset WorkspacePreset err := mapstructure.Decode(struct { @@ -68,7 +69,7 @@ func workspacePresetDataSource() *schema.Resource { }, "prebuilds": { Type: schema.TypeSet, - Description: "Prebuilt workspace configuration related to this workspace preset. Coder will build and maintain workspaces in reserve based on this configuration. When a user creates a new workspace using a preset, they will be assigned a prebuilt workspace, instead of waiting for a new workspace to build.", + Description: "Configuration for prebuilt workspaces associated with this preset. Coder will maintain a pool of standby workspaces based on this configuration. When a user creates a workspace using this preset, they are assigned a prebuilt workspace instead of waiting for a new one to build. See prebuilt workspace documentation [here](https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces.md)", Optional: true, MaxItems: 1, Elem: &schema.Resource{ diff --git a/provider/workspace_test.go b/provider/workspace_test.go index e82a1005..17dfabd2 100644 --- a/provider/workspace_test.go +++ b/provider/workspace_test.go @@ -4,6 +4,7 @@ import ( "regexp" "testing" + "github.com/coder/terraform-provider-coder/v2/provider" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "github.com/stretchr/testify/assert" @@ -102,3 +103,122 @@ func TestWorkspace_MissingTemplateName(t *testing.T) { }}, }) } + +// TestWorkspace_PrebuildEnv validates that our handling of input environment variables is correct. +func TestWorkspace_PrebuildEnv(t *testing.T) { + cases := []struct { + name string + envs map[string]string + check func(state *terraform.State, resource *terraform.ResourceState) error + }{ + { + name: "unused", + envs: map[string]string{}, + check: func(state *terraform.State, resource *terraform.ResourceState) error { + attribs := resource.Primary.Attributes + assert.Equal(t, "false", attribs["is_prebuild"]) + assert.Equal(t, "0", attribs["prebuild_count"]) + assert.Equal(t, "false", attribs["is_prebuild_claim"]) + return nil + }, + }, + { + name: "prebuild=true", + envs: map[string]string{ + provider.IsPrebuildEnvironmentVariable(): "true", + }, + check: func(state *terraform.State, resource *terraform.ResourceState) error { + attribs := resource.Primary.Attributes + assert.Equal(t, "true", attribs["is_prebuild"]) + assert.Equal(t, "1", attribs["prebuild_count"]) + assert.Equal(t, "false", attribs["is_prebuild_claim"]) + return nil + }, + }, + { + name: "prebuild=false", + envs: map[string]string{ + provider.IsPrebuildEnvironmentVariable(): "false", + }, + check: func(state *terraform.State, resource *terraform.ResourceState) error { + attribs := resource.Primary.Attributes + assert.Equal(t, "false", attribs["is_prebuild"]) + assert.Equal(t, "0", attribs["prebuild_count"]) + assert.Equal(t, "false", attribs["is_prebuild_claim"]) + return nil + }, + }, + { + name: "prebuild_claim=true", + envs: map[string]string{ + provider.IsPrebuildClaimEnvironmentVariable(): "true", + }, + check: func(state *terraform.State, resource *terraform.ResourceState) error { + attribs := resource.Primary.Attributes + assert.Equal(t, "false", attribs["is_prebuild"]) + assert.Equal(t, "0", attribs["prebuild_count"]) + assert.Equal(t, "true", attribs["is_prebuild_claim"]) + return nil + }, + }, + { + name: "prebuild_claim=false", + envs: map[string]string{ + provider.IsPrebuildClaimEnvironmentVariable(): "false", + }, + check: func(state *terraform.State, resource *terraform.ResourceState) error { + attribs := resource.Primary.Attributes + assert.Equal(t, "false", attribs["is_prebuild"]) + assert.Equal(t, "0", attribs["prebuild_count"]) + assert.Equal(t, "false", attribs["is_prebuild_claim"]) + return nil + }, + }, + { + // Should not ever happen, but let's ensure our defensive check is activated. We can't ever have both flags + // being true. + name: "prebuild=true,prebuild_claim=true", + envs: map[string]string{ + provider.IsPrebuildEnvironmentVariable(): "true", + provider.IsPrebuildClaimEnvironmentVariable(): "true", + }, + check: func(state *terraform.State, resource *terraform.ResourceState) error { + attribs := resource.Primary.Attributes + assert.Equal(t, "false", attribs["is_prebuild"]) + assert.Equal(t, "0", attribs["prebuild_count"]) + assert.Equal(t, "true", attribs["is_prebuild_claim"]) + return nil + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.envs { + t.Setenv(k, v) + } + + resource.Test(t, resource.TestCase{ + ProviderFactories: coderFactory(), + IsUnitTest: true, + Steps: []resource.TestStep{{ + Config: ` +provider "coder" { + url = "https://example.com:8080" +} +data "coder_workspace" "me" { +}`, + Check: func(state *terraform.State) error { + // Baseline checks + require.Len(t, state.Modules, 1) + require.Len(t, state.Modules[0].Resources, 1) + resource := state.Modules[0].Resources["data.coder_workspace.me"] + require.NotNil(t, resource) + + return tc.check(state, resource) + }, + }}, + }) + }) + } +}