mirror of
https://github.com/coder/coder.git
synced 2025-03-14 10:09:57 +00:00
chore: add cherry-picks for patch 2.18.2 (#16061)
Co-authored-by: Cian Johnston <cian@coder.com> Co-authored-by: Joobi S B <joobisb@gmail.com>
This commit is contained in:
@ -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, <right> to all, <left> 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()
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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{
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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).
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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 dataResource.Type != "coder_parameter" {
|
||||
skipped = append(skipped, strings.Join([]string{"data", dataResource.Type, dataResource.Name}, "."))
|
||||
if !strings.HasSuffix(dataResource.Pos.Filename, ".tf") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(dataResource.Pos.Filename, ".tf") {
|
||||
needle := strings.Join([]string{"data", dataResource.Type, dataResource.Name}, ".")
|
||||
if dataResource.Type != "coder_parameter" {
|
||||
skipped = append(skipped, needle)
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
Reference in New Issue
Block a user