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) } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy