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 e5142c1a83ee8..0c1c4031404eb 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" @@ -1420,6 +1422,182 @@ 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" {} + %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/tfparse/tfparse.go b/provisioner/terraform/tfparse/tfparse.go index 0eb6a0094e505..a3a7f971fac2e 100644 --- a/provisioner/terraform/tfparse/tfparse.go +++ b/provisioner/terraform/tfparse/tfparse.go @@ -327,13 +327,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 +355,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 +369,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 +395,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 +497,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 +518,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") }