diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 39e547c0258ea..4697dda09d660 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -300,9 +300,10 @@ func (m selectModel) filteredOptions() []string { } type MultiSelectOptions struct { - Message string - Options []string - Defaults []string + Message string + Options []string + Defaults []string + EnableCustomInput bool } func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, error) { @@ -328,9 +329,10 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er } initialModel := multiSelectModel{ - search: textinput.New(), - options: options, - message: opts.Message, + search: textinput.New(), + options: options, + message: opts.Message, + enableCustomInput: opts.EnableCustomInput, } initialModel.search.Prompt = "" @@ -370,12 +372,15 @@ type multiSelectOption struct { } type multiSelectModel struct { - search textinput.Model - options []*multiSelectOption - cursor int - message string - canceled bool - selected bool + search textinput.Model + options []*multiSelectOption + cursor int + message string + canceled bool + selected bool + isCustomInputMode bool // track if we're adding a custom option + customInput string // store custom input + enableCustomInput bool // control whether custom input is allowed } func (multiSelectModel) Init() tea.Cmd { @@ -386,6 +391,10 @@ func (multiSelectModel) Init() tea.Cmd { func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd + if m.isCustomInputMode { + return m.handleCustomInputMode(msg) + } + switch msg := msg.(type) { case terminateMsg: m.canceled = true @@ -398,6 +407,11 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case tea.KeyEnter: + // Switch to custom input mode if we're on the "+ Add custom value:" option + if m.enableCustomInput && m.cursor == len(m.filteredOptions()) { + m.isCustomInputMode = true + return m, nil + } if len(m.options) != 0 { m.selected = true return m, tea.Quit @@ -413,16 +427,16 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyUp: - options := m.filteredOptions() + maxIndex := m.getMaxIndex() if m.cursor > 0 { m.cursor-- } else { - m.cursor = len(options) - 1 + m.cursor = maxIndex } case tea.KeyDown: - options := m.filteredOptions() - if m.cursor < len(options)-1 { + maxIndex := m.getMaxIndex() + if m.cursor < maxIndex { m.cursor++ } else { m.cursor = 0 @@ -457,6 +471,91 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +func (m multiSelectModel) getMaxIndex() int { + options := m.filteredOptions() + if m.enableCustomInput { + // Include the "+ Add custom value" entry + return len(options) + } + // Includes only the actual options + return len(options) - 1 +} + +// handleCustomInputMode manages keyboard interactions when in custom input mode +func (m *multiSelectModel) handleCustomInputMode(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + switch keyMsg.Type { + case tea.KeyEnter: + return m.handleCustomInputSubmission() + + case tea.KeyCtrlC: + m.canceled = true + return m, tea.Quit + + case tea.KeyBackspace: + return m.handleCustomInputBackspace() + + default: + m.customInput += keyMsg.String() + return m, nil + } +} + +// handleCustomInputSubmission processes the submission of custom input +func (m *multiSelectModel) handleCustomInputSubmission() (tea.Model, tea.Cmd) { + if m.customInput == "" { + m.isCustomInputMode = false + return m, nil + } + + // Clear search to ensure option is visible and cursor points to the new option + m.search.SetValue("") + + // Check for duplicates + for i, opt := range m.options { + if opt.option == m.customInput { + // If the option exists but isn't chosen, select it + if !opt.chosen { + opt.chosen = true + } + + // Point cursor to the new option + m.cursor = i + + // Reset custom input mode to disabled + m.isCustomInputMode = false + m.customInput = "" + return m, nil + } + } + + // Add new unique option + m.options = append(m.options, &multiSelectOption{ + option: m.customInput, + chosen: true, + }) + + // Point cursor to the newly added option + m.cursor = len(m.options) - 1 + + // Reset custom input mode to disabled + m.customInput = "" + m.isCustomInputMode = false + return m, nil +} + +// handleCustomInputBackspace handles backspace in custom input mode +func (m *multiSelectModel) handleCustomInputBackspace() (tea.Model, tea.Cmd) { + if len(m.customInput) > 0 { + m.customInput = m.customInput[:len(m.customInput)-1] + } + return m, nil +} + func (m multiSelectModel) View() string { var s strings.Builder @@ -469,13 +568,19 @@ func (m multiSelectModel) View() string { return s.String() } + if m.isCustomInputMode { + _, _ = s.WriteString(fmt.Sprintf("%s\nEnter custom value: %s\n", msg, m.customInput)) + return s.String() + } + _, _ = s.WriteString(fmt.Sprintf( "%s %s[Use arrows to move, space to select, to all, to none, type to filter]\n", msg, m.search.View(), )) - for i, option := range m.filteredOptions() { + options := m.filteredOptions() + for i, option := range options { cursor := " " chosen := "[ ]" o := option.option @@ -498,6 +603,16 @@ func (m multiSelectModel) View() string { )) } + if m.enableCustomInput { + // Add the "+ Add custom value" option at the bottom + cursor := " " + text := " + Add custom value" + if m.cursor == len(options) { + cursor = pretty.Sprint(DefaultStyles.Keyword, "> ") + text = pretty.Sprint(DefaultStyles.Keyword, text) + } + _, _ = s.WriteString(fmt.Sprintf("%s%s\n", cursor, text)) + } return s.String() } diff --git a/cli/cliui/select_test.go b/cli/cliui/select_test.go index c0da49714fc40..c7630ac4f2460 100644 --- a/cli/cliui/select_test.go +++ b/cli/cliui/select_test.go @@ -101,6 +101,39 @@ func TestMultiSelect(t *testing.T) { }() require.Equal(t, items, <-msgChan) }) + + t.Run("MultiSelectWithCustomInput", func(t *testing.T) { + t.Parallel() + items := []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"} + ptty := ptytest.New(t) + msgChan := make(chan []string) + go func() { + resp, err := newMultiSelectWithCustomInput(ptty, items) + assert.NoError(t, err) + msgChan <- resp + }() + require.Equal(t, items, <-msgChan) + }) +} + +func newMultiSelectWithCustomInput(ptty *ptytest.PTY, items []string) ([]string, error) { + var values []string + cmd := &serpent.Command{ + Handler: func(inv *serpent.Invocation) error { + selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{ + Options: items, + Defaults: items, + EnableCustomInput: true, + }) + if err == nil { + values = selectedItems + } + return err + }, + } + inv := cmd.Invoke() + ptty.Attach(inv) + return values, inv.Run() } func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) { diff --git a/cli/prompts.go b/cli/prompts.go index 9bd7ecaa03204..225685a0c375a 100644 --- a/cli/prompts.go +++ b/cli/prompts.go @@ -41,6 +41,15 @@ func (RootCmd) promptExample() *serpent.Command { Default: "", Value: serpent.StringArrayOf(&multiSelectValues), } + + enableCustomInput bool + enableCustomInputOption = serpent.Option{ + Name: "enable-custom-input", + Description: "Enable custom input option in multi-select.", + Required: false, + Flag: "enable-custom-input", + Value: serpent.BoolOf(&enableCustomInput), + } ) cmd := &serpent.Command{ Use: "prompt-example", @@ -156,14 +165,15 @@ func (RootCmd) promptExample() *serpent.Command { multiSelectValues, multiSelectError = cliui.MultiSelect(inv, cliui.MultiSelectOptions{ Message: "Select some things:", Options: []string{ - "Code", "Chair", "Whale", "Diamond", "Carrot", + "Code", "Chairs", "Whale", "Diamond", "Carrot", }, - Defaults: []string{"Code"}, + Defaults: []string{"Code"}, + EnableCustomInput: enableCustomInput, }) } _, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", ")) return multiSelectError - }, useThingsOption), + }, useThingsOption, enableCustomInputOption), promptCmd("rich-parameter", func(inv *serpent.Invocation) error { value, err := cliui.RichSelect(inv, cliui.RichSelectOptions{ Options: []codersdk.TemplateVersionParameterOption{ diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index d8377821245bf..7d386988fe16d 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -293,6 +293,11 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { type = string default = "2" } + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) + } resource "null_resource" "test" {}`, }, wantTags: map[string]string{"owner": "", "scope": "organization"}, @@ -301,18 +306,23 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { name: "main.tf with empty workspace tags", files: map[string]string{ `main.tf`: ` - variable "a" { - type = string - default = "1" - } - data "coder_parameter" "b" { - type = string - default = "2" - } - resource "null_resource" "test" {} - data "coder_workspace_tags" "tags" { - tags = {} - }`, + variable "a" { + type = string + default = "1" + } + data "coder_parameter" "b" { + type = string + default = "2" + } + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) + } + resource "null_resource" "test" {} + data "coder_workspace_tags" "tags" { + tags = {} + }`, }, wantTags: map[string]string{"owner": "", "scope": "organization"}, }, @@ -328,6 +338,11 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { type = string default = "2" } + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) + } resource "null_resource" "test" {} data "coder_workspace_tags" "tags" { tags = { @@ -343,22 +358,28 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { name: "main.tf with workspace tags and request tags", files: map[string]string{ `main.tf`: ` - variable "a" { - type = string - default = "1" - } - data "coder_parameter" "b" { - type = string - default = "2" - } - resource "null_resource" "test" {} - data "coder_workspace_tags" "tags" { - tags = { - "foo": "bar", - "a": var.a, - "b": data.coder_parameter.b.value, + // This file is the same as the above, except for this comment. + variable "a" { + type = string + default = "1" + } + data "coder_parameter" "b" { + type = string + default = "2" } - }`, + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) + } + resource "null_resource" "test" {} + data "coder_workspace_tags" "tags" { + tags = { + "foo": "bar", + "a": var.a, + "b": data.coder_parameter.b.value, + } + }`, }, reqTags: map[string]string{"baz": "zap", "foo": "noclobber"}, wantTags: map[string]string{"owner": "", "scope": "organization", "foo": "bar", "baz": "zap", "a": "1", "b": "2"}, @@ -375,6 +396,11 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { type = string default = "2" } + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) + } resource "null_resource" "test" { name = "foo" } @@ -401,6 +427,11 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { type = string default = "2" } + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) + } resource "null_resource" "test" { name = "foo" } @@ -423,6 +454,11 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { name: "main.tf with workspace tags that attempts to set user scope", files: map[string]string{ `main.tf`: ` + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) + } resource "null_resource" "test" {} data "coder_workspace_tags" "tags" { tags = { @@ -437,6 +473,11 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { name: "main.tf with workspace tags that attempt to clobber org ID", files: map[string]string{ `main.tf`: ` + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) + } resource "null_resource" "test" {} data "coder_workspace_tags" "tags" { tags = { @@ -451,6 +492,11 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { name: "main.tf with workspace tags that set scope=user", files: map[string]string{ `main.tf`: ` + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) + } resource "null_resource" "test" {} data "coder_workspace_tags" "tags" { tags = { @@ -460,6 +506,19 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { }, wantTags: map[string]string{"owner": templateAdminUser.ID.String(), "scope": "user"}, }, + // Ref: https://github.com/coder/coder/issues/16021 + { + name: "main.tf with no workspace_tags and a function call in a parameter default", + files: map[string]string{ + `main.tf`: ` + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) + }`, + }, + wantTags: map[string]string{"owner": "", "scope": "organization"}, + }, } { tt := tt t.Run(tt.name, func(t *testing.T) { diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index d59af8cdc1b32..2123322356a3c 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -12,9 +12,9 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" - "github.com/zclconf/go-cty/cty" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/provisioner/terraform/tfparse" "github.com/coder/coder/v2/provisionersdk" "github.com/google/uuid" @@ -64,6 +64,7 @@ type Builder struct { templateVersion *database.TemplateVersion templateVersionJob *database.ProvisionerJob templateVersionParameters *[]database.TemplateVersionParameter + templateVersionVariables *[]database.TemplateVersionVariable templateVersionWorkspaceTags *[]database.TemplateVersionWorkspaceTag lastBuild *database.WorkspaceBuild lastBuildErr *error @@ -617,6 +618,22 @@ func (b *Builder) getTemplateVersionParameters() ([]database.TemplateVersionPara return tvp, nil } +func (b *Builder) getTemplateVersionVariables() ([]database.TemplateVersionVariable, error) { + if b.templateVersionVariables != nil { + return *b.templateVersionVariables, nil + } + tvID, err := b.getTemplateVersionID() + if err != nil { + return nil, xerrors.Errorf("get template version ID to get variables: %w", err) + } + tvs, err := b.store.GetTemplateVersionVariables(b.ctx, tvID) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + return nil, xerrors.Errorf("get template version %s variables: %w", tvID, err) + } + b.templateVersionVariables = &tvs + return tvs, nil +} + // verifyNoLegacyParameters verifies that initiator can't start the workspace build // if it uses legacy parameters (database.ParameterSchemas). func (b *Builder) verifyNoLegacyParameters() error { @@ -678,17 +695,40 @@ func (b *Builder) getProvisionerTags() (map[string]string, error) { tags[name] = value } - // Step 2: Mutate workspace tags + // Step 2: Mutate workspace tags: + // - Get workspace tags from the template version job + // - Get template version variables from the template version as they can be + // referenced in workspace tags + // - Get parameters from the workspace build as they can also be referenced + // in workspace tags + // - Evaluate workspace tags given the above inputs workspaceTags, err := b.getTemplateVersionWorkspaceTags() if err != nil { return nil, BuildError{http.StatusInternalServerError, "failed to fetch template version workspace tags", err} } + tvs, err := b.getTemplateVersionVariables() + if err != nil { + return nil, BuildError{http.StatusInternalServerError, "failed to fetch template version variables", err} + } + varsM := make(map[string]string) + for _, tv := range tvs { + // FIXME: do this in Terraform? This is a bit of a hack. + if tv.Value == "" { + varsM[tv.Name] = tv.DefaultValue + } else { + varsM[tv.Name] = tv.Value + } + } parameterNames, parameterValues, err := b.getParameters() if err != nil { return nil, err // already wrapped BuildError } + paramsM := make(map[string]string) + for i, name := range parameterNames { + paramsM[name] = parameterValues[i] + } - evalCtx := buildParametersEvalContext(parameterNames, parameterValues) + evalCtx := tfparse.BuildEvalContext(varsM, paramsM) for _, workspaceTag := range workspaceTags { expr, diags := hclsyntax.ParseExpression([]byte(workspaceTag.Value), "expression.hcl", hcl.InitialPos) if diags.HasErrors() { @@ -701,7 +741,7 @@ func (b *Builder) getProvisionerTags() (map[string]string, error) { } // Do not use "val.AsString()" as it can panic - str, err := ctyValueString(val) + str, err := tfparse.CtyValueString(val) if err != nil { return nil, BuildError{http.StatusBadRequest, "failed to marshal cty.Value as string", err} } @@ -710,44 +750,6 @@ func (b *Builder) getProvisionerTags() (map[string]string, error) { return tags, nil } -func buildParametersEvalContext(names, values []string) *hcl.EvalContext { - m := map[string]cty.Value{} - for i, name := range names { - m[name] = cty.MapVal(map[string]cty.Value{ - "value": cty.StringVal(values[i]), - }) - } - - if len(m) == 0 { - return nil // otherwise, panic: must not call MapVal with empty map - } - - return &hcl.EvalContext{ - Variables: map[string]cty.Value{ - "data": cty.MapVal(map[string]cty.Value{ - "coder_parameter": cty.MapVal(m), - }), - }, - } -} - -func ctyValueString(val cty.Value) (string, error) { - switch val.Type() { - case cty.Bool: - if val.True() { - return "true", nil - } else { - return "false", nil - } - case cty.Number: - return val.AsBigFloat().String(), nil - case cty.String: - return val.AsString(), nil - default: - return "", xerrors.Errorf("only primitive types are supported - bool, number, and string") - } -} - func (b *Builder) getTemplateVersionWorkspaceTags() ([]database.TemplateVersionWorkspaceTag, error) { if b.templateVersionWorkspaceTags != nil { return *b.templateVersionWorkspaceTags, nil diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index 3f373efd3bfdb..d8f25c5a8cda3 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -58,6 +58,7 @@ func TestBuilder_NoOptions(t *testing.T) { withTemplate, withInactiveVersion(nil), withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, nil), withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), @@ -113,6 +114,7 @@ func TestBuilder_Initiator(t *testing.T) { withTemplate, withInactiveVersion(nil), withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, nil), withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), @@ -158,6 +160,7 @@ func TestBuilder_Baggage(t *testing.T) { withTemplate, withInactiveVersion(nil), withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, nil), withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), @@ -195,6 +198,7 @@ func TestBuilder_Reason(t *testing.T) { withTemplate, withInactiveVersion(nil), withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, nil), withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), @@ -232,6 +236,7 @@ func TestBuilder_ActiveVersion(t *testing.T) { withTemplate, withActiveVersion(nil), withLastBuildNotFound, + withTemplateVersionVariables(activeVersionID, nil), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), @@ -296,6 +301,14 @@ func TestWorkspaceBuildWithTags(t *testing.T) { Key: "is_debug_build", Value: `data.coder_parameter.is_debug_build.value == "true" ? "in-debug-mode" : "no-debug"`, }, + { + Key: "variable_tag", + Value: `var.tag`, + }, + { + Key: "another_variable_tag", + Value: `var.tag2`, + }, } richParameters := []database.TemplateVersionParameter{ @@ -307,6 +320,11 @@ func TestWorkspaceBuildWithTags(t *testing.T) { {Name: "number_of_oranges", Type: "number", Description: "This is fifth parameter", Mutable: false, DefaultValue: "6", Options: json.RawMessage("[]")}, } + templateVersionVariables := []database.TemplateVersionVariable{ + {Name: "tag", Description: "This is a variable tag", TemplateVersionID: inactiveVersionID, Type: "string", DefaultValue: "default-value", Value: "my-value"}, + {Name: "tag2", Description: "This is another variable tag", TemplateVersionID: inactiveVersionID, Type: "string", DefaultValue: "default-value-2", Value: ""}, + } + buildParameters := []codersdk.WorkspaceBuildParameter{ {Name: "project", Value: "foobar-foobaz"}, {Name: "is_debug_build", Value: "true"}, @@ -321,6 +339,7 @@ func TestWorkspaceBuildWithTags(t *testing.T) { withTemplate, withInactiveVersion(richParameters), withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, templateVersionVariables), withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, workspaceTags), @@ -328,16 +347,18 @@ func TestWorkspaceBuildWithTags(t *testing.T) { // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { - asrt.Len(job.Tags, 10) + asrt.Len(job.Tags, 12) expected := database.StringMap{ - "actually_no": "false", - "cluster_tag": "best_developers", - "fruits_tag": "10", - "is_debug_build": "in-debug-mode", - "project_tag": "foobar-foobaz+12345", - "team_tag": "godzilla", - "yes_or_no": "true", + "actually_no": "false", + "cluster_tag": "best_developers", + "fruits_tag": "10", + "is_debug_build": "in-debug-mode", + "project_tag": "foobar-foobaz+12345", + "team_tag": "godzilla", + "yes_or_no": "true", + "variable_tag": "my-value", + "another_variable_tag": "default-value-2", "scope": "user", "version": "inactive", @@ -413,6 +434,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withTemplate, withInactiveVersion(richParameters), withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, nil), withRichParameters(initialBuildParameters), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), @@ -459,6 +481,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withTemplate, withInactiveVersion(richParameters), withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, nil), withRichParameters(initialBuildParameters), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), @@ -511,6 +534,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withTemplate, withInactiveVersion(richParameters), withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, nil), withRichParameters(nil), withParameterSchemas(inactiveJobID, schemas), withWorkspaceTags(inactiveVersionID, nil), @@ -542,6 +566,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withTemplate, withInactiveVersion(richParameters), withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, nil), withRichParameters(initialBuildParameters), withParameterSchemas(inactiveJobID, nil), withWorkspaceTags(inactiveVersionID, nil), @@ -593,6 +618,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withTemplate, withActiveVersion(version2params), withLastBuildFound, + withTemplateVersionVariables(activeVersionID, nil), withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), @@ -655,6 +681,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withTemplate, withActiveVersion(version2params), withLastBuildFound, + withTemplateVersionVariables(activeVersionID, nil), withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), @@ -715,6 +742,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withTemplate, withActiveVersion(version2params), withLastBuildFound, + withTemplateVersionVariables(activeVersionID, nil), withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), withWorkspaceTags(activeVersionID, nil), @@ -921,6 +949,18 @@ func withParameterSchemas(jobID uuid.UUID, schemas []database.ParameterSchema) f } } +func withTemplateVersionVariables(versionID uuid.UUID, params []database.TemplateVersionVariable) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + c := mTx.EXPECT().GetTemplateVersionVariables(gomock.Any(), versionID). + Times(1) + if len(params) > 0 { + c.Return(params, nil) + } else { + c.Return(nil, sql.ErrNoRows) + } + } +} + func withRichParameters(params []database.WorkspaceBuildParameter) func(mTx *dbmock.MockStore) { return func(mTx *dbmock.MockStore) { c := mTx.EXPECT().GetWorkspaceBuildParameters(gomock.Any(), lastBuildID). diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index b397cb05dc5d4..0d44937e4a82d 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -7,6 +7,7 @@ import ( "crypto/tls" "io" "net/http" + "os/exec" "strings" "testing" "time" @@ -16,7 +17,8 @@ import ( "github.com/moby/moby/pkg/namesgenerator" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/xerrors" + + "cdr.dev/slog" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" @@ -28,6 +30,7 @@ import ( "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/coder/v2/enterprise/dbcrypt" "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisioner/terraform" "github.com/coder/coder/v2/provisionerd" provisionerdproto "github.com/coder/coder/v2/provisionerd/proto" "github.com/coder/coder/v2/provisionersdk" @@ -304,14 +307,31 @@ func CreateOrganization(t *testing.T, client *codersdk.Client, opts CreateOrgani return org } +// NewExternalProvisionerDaemon runs an external provisioner daemon in a +// goroutine and returns a closer to stop it. The echo provisioner is used +// here. This is the default provisioner for tests and should be fine for +// most use cases. If you need to test terraform-specific behaviors, use +// NewExternalProvisionerDaemonTerraform instead. func NewExternalProvisionerDaemon(t testing.TB, client *codersdk.Client, org uuid.UUID, tags map[string]string) io.Closer { t.Helper() + return newExternalProvisionerDaemon(t, client, org, tags, codersdk.ProvisionerTypeEcho) +} + +// NewExternalProvisionerDaemonTerraform runs an external provisioner daemon in +// a goroutine and returns a closer to stop it. The terraform provisioner is +// used here. Avoid using this unless you need to test terraform-specific +// behaviors! +func NewExternalProvisionerDaemonTerraform(t testing.TB, client *codersdk.Client, org uuid.UUID, tags map[string]string) io.Closer { + t.Helper() + return newExternalProvisionerDaemon(t, client, org, tags, codersdk.ProvisionerTypeTerraform) +} + +// nolint // This function is a helper for tests and should not be linted. +func newExternalProvisionerDaemon(t testing.TB, client *codersdk.Client, org uuid.UUID, tags map[string]string, provisionerType codersdk.ProvisionerType) io.Closer { + t.Helper() - // Without this check, the provisioner will silently fail. entitlements, err := client.Entitlements(context.Background()) if err != nil { - // AGPL instances will throw this error. They cannot use external - // provisioners. t.Errorf("external provisioners requires a license with entitlements. The client failed to fetch the entitlements, is this an enterprise instance of coderd?") t.FailNow() return nil @@ -319,42 +339,67 @@ func NewExternalProvisionerDaemon(t testing.TB, client *codersdk.Client, org uui feature := entitlements.Features[codersdk.FeatureExternalProvisionerDaemons] if !feature.Enabled || feature.Entitlement != codersdk.EntitlementEntitled { - require.NoError(t, xerrors.Errorf("external provisioner daemons require an entitled license")) + t.Errorf("external provisioner daemons require an entitled license") + t.FailNow() return nil } - echoClient, echoServer := drpc.MemTransportPipe() + provisionerClient, provisionerSrv := drpc.MemTransportPipe() ctx, cancelFunc := context.WithCancel(context.Background()) serveDone := make(chan struct{}) t.Cleanup(func() { - _ = echoClient.Close() - _ = echoServer.Close() + _ = provisionerClient.Close() + _ = provisionerSrv.Close() cancelFunc() <-serveDone }) - go func() { - defer close(serveDone) - err := echo.Serve(ctx, &provisionersdk.ServeOptions{ - Listener: echoServer, - WorkDirectory: t.TempDir(), - }) - assert.NoError(t, err) - }() + + switch provisionerType { + case codersdk.ProvisionerTypeTerraform: + // Ensure the Terraform binary is present in the path. + // If not, we fail this test rather than downloading it. + terraformPath, err := exec.LookPath("terraform") + require.NoError(t, err, "terraform binary not found in PATH") + t.Logf("using Terraform binary at %s", terraformPath) + + go func() { + defer close(serveDone) + assert.NoError(t, terraform.Serve(ctx, &terraform.ServeOptions{ + BinaryPath: terraformPath, + CachePath: t.TempDir(), + ServeOptions: &provisionersdk.ServeOptions{ + Listener: provisionerSrv, + WorkDirectory: t.TempDir(), + }, + })) + }() + case codersdk.ProvisionerTypeEcho: + go func() { + defer close(serveDone) + assert.NoError(t, echo.Serve(ctx, &provisionersdk.ServeOptions{ + Listener: provisionerSrv, + WorkDirectory: t.TempDir(), + })) + }() + default: + t.Fatalf("unsupported provisioner type: %s", provisionerType) + return nil + } daemon := provisionerd.New(func(ctx context.Context) (provisionerdproto.DRPCProvisionerDaemonClient, error) { return client.ServeProvisionerDaemon(ctx, codersdk.ServeProvisionerDaemonRequest{ ID: uuid.New(), Name: t.Name(), Organization: org, - Provisioners: []codersdk.ProvisionerType{codersdk.ProvisionerTypeEcho}, + Provisioners: []codersdk.ProvisionerType{provisionerType}, Tags: tags, }) }, &provisionerd.Options{ - Logger: testutil.Logger(t).Named("provisionerd"), + Logger: testutil.Logger(t).Named("provisionerd").Leveled(slog.LevelDebug), UpdateInterval: 250 * time.Millisecond, ForceCancelInterval: 5 * time.Second, Connector: provisionerd.LocalProvisioners{ - string(database.ProvisionerTypeEcho): sdkproto.NewDRPCProvisionerClient(echoClient), + string(provisionerType): sdkproto.NewDRPCProvisionerClient(provisionerClient), }, }) closer := coderdtest.NewProvisionerDaemonCloser(daemon) diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index e20bfba9c189c..cce93dcc3a8fc 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -1,8 +1,10 @@ package coderd_test import ( + "bytes" "context" "database/sql" + "fmt" "net/http" "sync/atomic" "testing" @@ -1180,6 +1182,187 @@ func TestTemplateDoesNotAllowUserAutostop(t *testing.T) { }) } +// TestWorkspaceTagsTerraform tests that a workspace can be created with tags. +// This is an end-to-end-style test, meaning that we actually run the +// real Terraform provisioner and validate that the workspace is created +// successfully. The workspace itself does not specify any resources, and +// this is fine. +func TestWorkspaceTagsTerraform(t *testing.T) { + t.Parallel() + + mainTfTemplate := ` + terraform { + required_providers { + coder = { + source = "coder/coder" + } + } + } + provider "coder" {} + data "coder_workspace" "me" {} + data "coder_workspace_owner" "me" {} + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) + } + %s + ` + + for _, tc := range []struct { + name string + // tags to apply to the external provisioner + provisionerTags map[string]string + // tags to apply to the create template version request + createTemplateVersionRequestTags map[string]string + // the coder_workspace_tags bit of main.tf. + // you can add more stuff here if you need + tfWorkspaceTags string + }{ + { + name: "no tags", + tfWorkspaceTags: ``, + }, + { + name: "empty tags", + tfWorkspaceTags: ` + data "coder_workspace_tags" "tags" { + tags = {} + } + `, + }, + { + name: "static tag", + provisionerTags: map[string]string{"foo": "bar"}, + tfWorkspaceTags: ` + data "coder_workspace_tags" "tags" { + tags = { + "foo" = "bar" + } + }`, + }, + { + name: "tag variable", + provisionerTags: map[string]string{"foo": "bar"}, + tfWorkspaceTags: ` + variable "foo" { + default = "bar" + } + data "coder_workspace_tags" "tags" { + tags = { + "foo" = var.foo + } + }`, + }, + { + name: "tag param", + provisionerTags: map[string]string{"foo": "bar"}, + tfWorkspaceTags: ` + data "coder_parameter" "foo" { + name = "foo" + type = "string" + default = "bar" + } + data "coder_workspace_tags" "tags" { + tags = { + "foo" = data.coder_parameter.foo.value + } + }`, + }, + { + name: "tag param with default from var", + provisionerTags: map[string]string{"foo": "bar"}, + tfWorkspaceTags: ` + variable "foo" { + type = string + default = "bar" + } + data "coder_parameter" "foo" { + name = "foo" + type = "string" + default = var.foo + } + data "coder_workspace_tags" "tags" { + tags = { + "foo" = data.coder_parameter.foo.value + } + }`, + }, + { + name: "override no tags", + provisionerTags: map[string]string{"foo": "baz"}, + createTemplateVersionRequestTags: map[string]string{"foo": "baz"}, + tfWorkspaceTags: ``, + }, + { + name: "override empty tags", + provisionerTags: map[string]string{"foo": "baz"}, + createTemplateVersionRequestTags: map[string]string{"foo": "baz"}, + tfWorkspaceTags: ` + data "coder_workspace_tags" "tags" { + tags = {} + }`, + }, + { + name: "does not override static tag", + provisionerTags: map[string]string{"foo": "bar"}, + createTemplateVersionRequestTags: map[string]string{"foo": "baz"}, + tfWorkspaceTags: ` + data "coder_workspace_tags" "tags" { + tags = { + "foo" = "bar" + } + }`, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitSuperLong) + + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + // We intentionally do not run a built-in provisioner daemon here. + IncludeProvisionerDaemon: false, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureExternalProvisionerDaemons: 1, + }, + }, + }) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + _ = coderdenttest.NewExternalProvisionerDaemonTerraform(t, client, owner.OrganizationID, tc.provisionerTags) + + // Creating a template as a template admin must succeed + templateFiles := map[string]string{"main.tf": fmt.Sprintf(mainTfTemplate, tc.tfWorkspaceTags)} + tarBytes := testutil.CreateTar(t, templateFiles) + fi, err := templateAdmin.Upload(ctx, "application/x-tar", bytes.NewReader(tarBytes)) + require.NoError(t, err, "failed to upload file") + tv, err := templateAdmin.CreateTemplateVersion(ctx, owner.OrganizationID, codersdk.CreateTemplateVersionRequest{ + Name: testutil.GetRandomName(t), + FileID: fi.ID, + StorageMethod: codersdk.ProvisionerStorageMethodFile, + Provisioner: codersdk.ProvisionerTypeTerraform, + ProvisionerTags: tc.createTemplateVersionRequestTags, + }) + require.NoError(t, err, "failed to create template version") + coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, tv.ID) + tpl := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, tv.ID) + + // Creating a workspace as a non-privileged user must succeed + ws, err := member.CreateUserWorkspace(ctx, memberUser.Username, codersdk.CreateWorkspaceRequest{ + TemplateID: tpl.ID, + Name: coderdtest.RandomUsername(t), + }) + require.NoError(t, err, "failed to create workspace") + coderdtest.AwaitWorkspaceBuildJobCompleted(t, member, ws.LatestBuild.ID) + }) + } +} + // Blocked by autostart requirements func TestExecutorAutostartBlocked(t *testing.T) { t.Parallel() diff --git a/provisioner/terraform/parse.go b/provisioner/terraform/parse.go index 9c60102fc8579..7aa78e401c503 100644 --- a/provisioner/terraform/parse.go +++ b/provisioner/terraform/parse.go @@ -26,7 +26,7 @@ func (s *server) Parse(sess *provisionersdk.Session, _ *proto.ParseRequest, _ <- return provisionersdk.ParseErrorf("load module: %s", formatDiagnostics(sess.WorkDirectory, diags)) } - workspaceTags, err := parser.WorkspaceTags(ctx) + workspaceTags, _, err := parser.WorkspaceTags(ctx) if err != nil { return provisionersdk.ParseErrorf("can't load workspace tags: %v", err) } diff --git a/provisioner/terraform/tfparse/tfparse.go b/provisioner/terraform/tfparse/tfparse.go index 0eb6a0094e505..de767a833207f 100644 --- a/provisioner/terraform/tfparse/tfparse.go +++ b/provisioner/terraform/tfparse/tfparse.go @@ -80,10 +80,12 @@ func New(workdir string, opts ...Option) (*Parser, tfconfig.Diagnostics) { } // WorkspaceTags looks for all coder_workspace_tags datasource in the module -// and returns the raw values for the tags. Use -func (p *Parser) WorkspaceTags(ctx context.Context) (map[string]string, error) { +// and returns the raw values for the tags. It also returns the set of +// variables referenced by any expressions in the raw values of tags. +func (p *Parser) WorkspaceTags(ctx context.Context) (map[string]string, map[string]struct{}, error) { tags := map[string]string{} - var skipped []string + skipped := []string{} + requiredVars := map[string]struct{}{} for _, dataResource := range p.module.DataResources { if dataResource.Type != "coder_workspace_tags" { skipped = append(skipped, strings.Join([]string{"data", dataResource.Type, dataResource.Name}, ".")) @@ -99,13 +101,13 @@ func (p *Parser) WorkspaceTags(ctx context.Context) (map[string]string, error) { // We know in which HCL file is the data resource defined. file, diags = p.underlying.ParseHCLFile(dataResource.Pos.Filename) if diags.HasErrors() { - return nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error()) + return nil, nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error()) } // Parse root to find "coder_workspace_tags". content, _, diags := file.Body.PartialContent(rootTemplateSchema) if diags.HasErrors() { - return nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error()) + return nil, nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error()) } // Iterate over blocks to locate the exact "coder_workspace_tags" data resource. @@ -117,7 +119,7 @@ func (p *Parser) WorkspaceTags(ctx context.Context) (map[string]string, error) { // Parse "coder_workspace_tags" to find all key-value tags. resContent, _, diags := block.Body.PartialContent(coderWorkspaceTagsSchema) if diags.HasErrors() { - return nil, xerrors.Errorf(`can't parse the resource coder_workspace_tags: %s`, diags.Error()) + return nil, nil, xerrors.Errorf(`can't parse the resource coder_workspace_tags: %s`, diags.Error()) } if resContent == nil { @@ -125,54 +127,106 @@ func (p *Parser) WorkspaceTags(ctx context.Context) (map[string]string, error) { } if _, ok := resContent.Attributes["tags"]; !ok { - return nil, xerrors.Errorf(`"tags" attribute is required by coder_workspace_tags`) + return nil, nil, xerrors.Errorf(`"tags" attribute is required by coder_workspace_tags`) } expr := resContent.Attributes["tags"].Expr tagsExpr, ok := expr.(*hclsyntax.ObjectConsExpr) if !ok { - return nil, xerrors.Errorf(`"tags" attribute is expected to be a key-value map`) + return nil, nil, xerrors.Errorf(`"tags" attribute is expected to be a key-value map`) } // Parse key-value entries in "coder_workspace_tags" for _, tagItem := range tagsExpr.Items { key, err := previewFileContent(tagItem.KeyExpr.Range()) if err != nil { - return nil, xerrors.Errorf("can't preview the resource file: %v", err) + return nil, nil, xerrors.Errorf("can't preview the resource file: %v", err) } key = strings.Trim(key, `"`) value, err := previewFileContent(tagItem.ValueExpr.Range()) if err != nil { - return nil, xerrors.Errorf("can't preview the resource file: %v", err) + return nil, nil, xerrors.Errorf("can't preview the resource file: %v", err) } if _, ok := tags[key]; ok { - return nil, xerrors.Errorf(`workspace tag %q is defined multiple times`, key) + return nil, nil, xerrors.Errorf(`workspace tag %q is defined multiple times`, key) } tags[key] = value + + // Find values referenced by the expression. + refVars := referencedVariablesExpr(tagItem.ValueExpr) + for _, refVar := range refVars { + requiredVars[refVar] = struct{}{} + } } } } - p.logger.Debug(ctx, "found workspace tags", slog.F("tags", maps.Keys(tags)), slog.F("skipped", skipped)) - return tags, nil + + requiredVarNames := maps.Keys(requiredVars) + slices.Sort(requiredVarNames) + p.logger.Debug(ctx, "found workspace tags", slog.F("tags", maps.Keys(tags)), slog.F("skipped", skipped), slog.F("required_vars", requiredVarNames)) + return tags, requiredVars, nil +} + +// referencedVariablesExpr determines the variables referenced in expr +// and returns the names of those variables. +func referencedVariablesExpr(expr hclsyntax.Expression) (names []string) { + var parts []string + for _, expVar := range expr.Variables() { + for _, tr := range expVar { + switch v := tr.(type) { + case hcl.TraverseRoot: + parts = append(parts, v.Name) + case hcl.TraverseAttr: + parts = append(parts, v.Name) + default: // skip + } + } + + cleaned := cleanupTraversalName(parts) + names = append(names, strings.Join(cleaned, ".")) + } + return names +} + +// cleanupTraversalName chops off extraneous pieces of the traversal. +// for example: +// - var.foo -> unchanged +// - data.coder_parameter.bar.value -> data.coder_parameter.bar +// - null_resource.baz.zap -> null_resource.baz +func cleanupTraversalName(parts []string) []string { + if len(parts) == 0 { + return parts + } + if len(parts) > 3 && parts[0] == "data" { + return parts[:3] + } + if len(parts) > 2 { + return parts[:2] + } + return parts } func (p *Parser) WorkspaceTagDefaults(ctx context.Context) (map[string]string, error) { // This only gets us the expressions. We need to evaluate them. // Example: var.region -> "us" - tags, err := p.WorkspaceTags(ctx) + tags, requiredVars, err := p.WorkspaceTags(ctx) if err != nil { return nil, xerrors.Errorf("extract workspace tags: %w", err) } + if len(tags) == 0 { + return map[string]string{}, nil + } + // To evaluate the expressions, we need to load the default values for // variables and parameters. varsDefaults, err := p.VariableDefaults(ctx) if err != nil { return nil, xerrors.Errorf("load variable defaults: %w", err) } - paramsDefaults, err := p.CoderParameterDefaults(ctx, varsDefaults) + paramsDefaults, err := p.CoderParameterDefaults(ctx, varsDefaults, requiredVars) if err != nil { return nil, xerrors.Errorf("load parameter defaults: %w", err) } @@ -247,10 +301,10 @@ func WriteArchive(bs []byte, mimetype string, path string) error { return nil } -// VariableDefaults returns the default values for all variables passed to it. +// VariableDefaults returns the default values for all variables in the module. func (p *Parser) VariableDefaults(ctx context.Context) (map[string]string, error) { // iterate through vars to get the default values for all - // variables. + // required variables. m := make(map[string]string) for _, v := range p.module.Variables { if v == nil { @@ -268,7 +322,7 @@ func (p *Parser) VariableDefaults(ctx context.Context) (map[string]string, error // CoderParameterDefaults returns the default values of all coder_parameter data sources // in the parsed module. -func (p *Parser) CoderParameterDefaults(ctx context.Context, varsDefaults map[string]string) (map[string]string, error) { +func (p *Parser) CoderParameterDefaults(ctx context.Context, varsDefaults map[string]string, names map[string]struct{}) (map[string]string, error) { defaultsM := make(map[string]string) var ( skipped []string @@ -281,12 +335,18 @@ func (p *Parser) CoderParameterDefaults(ctx context.Context, varsDefaults map[st continue } + if !strings.HasSuffix(dataResource.Pos.Filename, ".tf") { + continue + } + + needle := strings.Join([]string{"data", dataResource.Type, dataResource.Name}, ".") if dataResource.Type != "coder_parameter" { - skipped = append(skipped, strings.Join([]string{"data", dataResource.Type, dataResource.Name}, ".")) + skipped = append(skipped, needle) continue } - if !strings.HasSuffix(dataResource.Pos.Filename, ".tf") { + if _, found := names[needle]; !found { + skipped = append(skipped, needle) continue } @@ -327,13 +387,13 @@ func (p *Parser) CoderParameterDefaults(ctx context.Context, varsDefaults map[st // Issue #15795: the "default" value could also be an expression we need // to evaluate. // TODO: should we support coder_parameter default values that reference other coder_parameter data sources? - evalCtx := buildEvalContext(varsDefaults, nil) + evalCtx := BuildEvalContext(varsDefaults, nil) val, diags := expr.Value(evalCtx) if diags.HasErrors() { return nil, xerrors.Errorf("failed to evaluate coder_parameter %q default value %q: %s", dataResource.Name, value, diags.Error()) } // Do not use "val.AsString()" as it can panic - strVal, err := ctyValueString(val) + strVal, err := CtyValueString(val) if err != nil { return nil, xerrors.Errorf("failed to marshal coder_parameter %q default value %q as string: %s", dataResource.Name, value, err) } @@ -355,7 +415,7 @@ func evaluateWorkspaceTags(varsDefaults, paramsDefaults, workspaceTags map[strin } // We only add variables and coder_parameter data sources. Anything else will be // undefined and will raise a Terraform error. - evalCtx := buildEvalContext(varsDefaults, paramsDefaults) + evalCtx := BuildEvalContext(varsDefaults, paramsDefaults) tags := make(map[string]string) for workspaceTagKey, workspaceTagValue := range workspaceTags { expr, diags := hclsyntax.ParseExpression([]byte(workspaceTagValue), "expression.hcl", hcl.InitialPos) @@ -369,7 +429,7 @@ func evaluateWorkspaceTags(varsDefaults, paramsDefaults, workspaceTags map[strin } // Do not use "val.AsString()" as it can panic - str, err := ctyValueString(val) + str, err := CtyValueString(val) if err != nil { return nil, xerrors.Errorf("failed to marshal workspace tag key %q value %q as string: %s", workspaceTagKey, workspaceTagValue, err) } @@ -395,16 +455,17 @@ func validWorkspaceTagValues(tags map[string]string) error { return nil } -func buildEvalContext(varDefaults map[string]string, paramDefaults map[string]string) *hcl.EvalContext { +// BuildEvalContext builds an evaluation context for the given variable and parameter defaults. +func BuildEvalContext(vars map[string]string, params map[string]string) *hcl.EvalContext { varDefaultsM := map[string]cty.Value{} - for varName, varDefault := range varDefaults { + for varName, varDefault := range vars { varDefaultsM[varName] = cty.MapVal(map[string]cty.Value{ "value": cty.StringVal(varDefault), }) } paramDefaultsM := map[string]cty.Value{} - for paramName, paramDefault := range paramDefaults { + for paramName, paramDefault := range params { paramDefaultsM[paramName] = cty.MapVal(map[string]cty.Value{ "value": cty.StringVal(paramDefault), }) @@ -496,7 +557,10 @@ func compareSourcePos(x, y tfconfig.SourcePos) bool { return x.Line < y.Line } -func ctyValueString(val cty.Value) (string, error) { +// CtyValueString converts a cty.Value to a string. +// It supports only primitive types - bool, number, and string. +// As a special case, it also supports map[string]interface{} with key "value". +func CtyValueString(val cty.Value) (string, error) { switch val.Type() { case cty.Bool: if val.True() { @@ -514,7 +578,7 @@ func ctyValueString(val cty.Value) (string, error) { if !ok { return "", xerrors.Errorf("map does not have key 'value'") } - return ctyValueString(valval) + return CtyValueString(valval) default: return "", xerrors.Errorf("only primitive types are supported - bool, number, and string") } @@ -534,7 +598,11 @@ func interfaceToString(i interface{}) (string, error) { return strconv.FormatFloat(v, 'f', -1, 64), nil case bool: return strconv.FormatBool(v), nil - default: - return "", xerrors.Errorf("unsupported type %T", v) + default: // just try to JSON-encode it. + var sb strings.Builder + if err := json.NewEncoder(&sb).Encode(i); err != nil { + return "", xerrors.Errorf("convert %T: %w", v, err) + } + return strings.TrimSpace(sb.String()), nil } } diff --git a/provisioner/terraform/tfparse/tfparse_test.go b/provisioner/terraform/tfparse/tfparse_test.go index 9d9bcc4526584..afbec4d0b8d4b 100644 --- a/provisioner/terraform/tfparse/tfparse_test.go +++ b/provisioner/terraform/tfparse/tfparse_test.go @@ -34,7 +34,7 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { name: "single text file", files: map[string]string{ "file.txt": ` - hello world`, + hello world`, }, expectTags: map[string]string{}, expectError: "", @@ -49,8 +49,10 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { type = string default = "us" } - data "base" "ours" { - all = true + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) } data "coder_parameter" "az" { name = "az" @@ -71,8 +73,10 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { type = string default = "us" } - data "base" "ours" { - all = true + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) } data "coder_parameter" "az" { name = "az" @@ -94,8 +98,13 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { type = string default = "us" } - data "base" "ours" { - all = true + variable "unrelated" { + type = bool + } + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) } data "coder_parameter" "az" { name = "az" @@ -128,8 +137,10 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { type = string default = "${""}${"a"}" } - data "base" "ours" { - all = true + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) } data "coder_parameter" "az" { name = "az" @@ -158,8 +169,10 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { type = string default = "us" } - data "base" "ours" { - all = true + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) } data "coder_parameter" "az" { type = string @@ -195,8 +208,10 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { type = string default = "eu" } - data "base" "ours" { - all = true + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) } data "coder_parameter" "az" { name = "az" @@ -235,8 +250,10 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { type = string default = "us" } - data "base" "ours" { - all = true + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) } data "coder_parameter" "az" { name = "az" @@ -263,8 +280,10 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { type = string default = "us" } - data "base" "ours" { - all = true + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) } data "coder_parameter" "az" { name = "az" @@ -300,8 +319,10 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { variable "notregion" { type = string } - data "base" "ours" { - all = true + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) } data "coder_parameter" "az" { name = "az" @@ -332,8 +353,10 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { type = string default = "us" } - data "base" "ours" { - all = true + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) } data "coder_parameter" "az" { name = "az" @@ -368,8 +391,10 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { type = string default = "us" } - data "base" "ours" { - all = true + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) } data "coder_parameter" "az" { name = "az" @@ -402,8 +427,10 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { type = string default = "region.us" } - data "base" "ours" { - all = true + data "coder_parameter" "unrelated" { + name = "unrelated" + type = "list(string)" + default = jsonencode(["a", "b"]) } data "coder_parameter" "az" { name = "az" @@ -422,6 +449,103 @@ func Test_WorkspaceTagDefaultsFromFile(t *testing.T) { expectTags: nil, expectError: `Function calls not allowed; Functions may not be called here.`, }, + { + name: "supported types", + files: map[string]string{ + "main.tf": ` + variable "stringvar" { + type = string + default = "a" + } + variable "numvar" { + type = number + default = 1 + } + variable "boolvar" { + type = bool + default = true + } + variable "listvar" { + type = list(string) + default = ["a"] + } + variable "mapvar" { + type = map(string) + default = {"a": "b"} + } + data "coder_parameter" "stringparam" { + name = "stringparam" + type = "string" + default = "a" + } + data "coder_parameter" "numparam" { + name = "numparam" + type = "number" + default = 1 + } + data "coder_parameter" "boolparam" { + name = "boolparam" + type = "bool" + default = true + } + data "coder_parameter" "listparam" { + name = "listparam" + type = "list(string)" + default = "[\"a\", \"b\"]" + } + data "coder_workspace_tags" "tags" { + tags = { + "stringvar" = var.stringvar + "numvar" = var.numvar + "boolvar" = var.boolvar + "listvar" = var.listvar + "mapvar" = var.mapvar + "stringparam" = data.coder_parameter.stringparam.value + "numparam" = data.coder_parameter.numparam.value + "boolparam" = data.coder_parameter.boolparam.value + "listparam" = data.coder_parameter.listparam.value + } + }`, + }, + expectTags: map[string]string{ + "stringvar": "a", + "numvar": "1", + "boolvar": "true", + "listvar": `["a"]`, + "mapvar": `{"a":"b"}`, + "stringparam": "a", + "numparam": "1", + "boolparam": "true", + "listparam": `["a", "b"]`, + }, + expectError: ``, + }, + { + name: "overlapping var name", + files: map[string]string{ + `main.tf`: ` + variable "a" { + type = string + default = "1" + } + variable "unused" { + type = map(string) + default = {"a" : "b"} + } + variable "ab" { + description = "This is a variable of type string" + type = string + default = "ab" + } + data "coder_workspace_tags" "tags" { + tags = { + "foo": "bar", + "a": var.a, + } + }`, + }, + expectTags: map[string]string{"foo": "bar", "a": "1"}, + }, } { tc := tc t.Run(tc.name+"/tar", func(t *testing.T) { @@ -505,7 +629,7 @@ func BenchmarkWorkspaceTagDefaultsFromFile(b *testing.B) { tfparse.WriteArchive(tarFile, "application/x-tar", tmpDir) parser, diags := tfparse.New(tmpDir, tfparse.WithLogger(logger)) require.NoError(b, diags.Err()) - _, err := parser.WorkspaceTags(ctx) + _, _, err := parser.WorkspaceTags(ctx) if err != nil { b.Fatal(err) } @@ -519,7 +643,7 @@ func BenchmarkWorkspaceTagDefaultsFromFile(b *testing.B) { tfparse.WriteArchive(zipFile, "application/zip", tmpDir) parser, diags := tfparse.New(tmpDir, tfparse.WithLogger(logger)) require.NoError(b, diags.Err()) - _, err := parser.WorkspaceTags(ctx) + _, _, err := parser.WorkspaceTags(ctx) if err != nil { b.Fatal(err) }