diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index b34eb9ce3c858..9e8de1d688768 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -10,6 +10,10 @@ import ( "net/http" "time" + "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/provisionersdk" @@ -55,14 +59,17 @@ type Builder struct { store database.Store // cache of objects, so we only fetch once - template *database.Template - templateVersion *database.TemplateVersion - templateVersionJob *database.ProvisionerJob - templateVersionParameters *[]database.TemplateVersionParameter - lastBuild *database.WorkspaceBuild - lastBuildErr *error - lastBuildParameters *[]database.WorkspaceBuildParameter - lastBuildJob *database.ProvisionerJob + template *database.Template + templateVersion *database.TemplateVersion + templateVersionJob *database.ProvisionerJob + templateVersionParameters *[]database.TemplateVersionParameter + templateVersionWorkspaceTags *[]database.TemplateVersionWorkspaceTag + lastBuild *database.WorkspaceBuild + lastBuildErr *error + lastBuildParameters *[]database.WorkspaceBuildParameter + lastBuildJob *database.ProvisionerJob + parameterNames *[]string + parameterValues *[]string verifyNoLegacyParametersOnce bool } @@ -297,7 +304,11 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object if err != nil { return nil, nil, BuildError{http.StatusInternalServerError, "marshal metadata", err} } - tags := provisionersdk.MutateTags(b.workspace.OwnerID, templateVersionJob.Tags) + + tags, err := b.getProvisionerTags() + if err != nil { + return nil, nil, err // already wrapped BuildError + } now := dbtime.Now() provisionerJob, err := b.store.InsertProvisionerJob(b.ctx, database.InsertProvisionerJobParams{ @@ -364,6 +375,7 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object // getParameters already wraps errors in BuildError return err } + err = store.InsertWorkspaceBuildParameters(b.ctx, database.InsertWorkspaceBuildParametersParams{ WorkspaceBuildID: workspaceBuildID, Name: names, @@ -502,6 +514,10 @@ func (b *Builder) getState() ([]byte, error) { } func (b *Builder) getParameters() (names, values []string, err error) { + if b.parameterNames != nil { + return *b.parameterNames, *b.parameterValues, nil + } + templateVersionParameters, err := b.getTemplateVersionParameters() if err != nil { return nil, nil, BuildError{http.StatusInternalServerError, "failed to fetch template version parameters", err} @@ -535,6 +551,9 @@ func (b *Builder) getParameters() (names, values []string, err error) { names = append(names, templateVersionParameter.Name) values = append(values, value) } + + b.parameterNames = &names + b.parameterValues = &values return names, values, nil } @@ -632,6 +651,108 @@ func (b *Builder) getLastBuildJob() (*database.ProvisionerJob, error) { return b.lastBuildJob, nil } +func (b *Builder) getProvisionerTags() (map[string]string, error) { + // Step 1: Mutate template version tags + templateVersionJob, err := b.getTemplateVersionJob() + if err != nil { + return nil, BuildError{http.StatusInternalServerError, "failed to fetch template version job", err} + } + annotationTags := provisionersdk.MutateTags(b.workspace.OwnerID, templateVersionJob.Tags) + + tags := map[string]string{} + for name, value := range annotationTags { + tags[name] = value + } + + // Step 2: Mutate workspace tags + workspaceTags, err := b.getTemplateVersionWorkspaceTags() + if err != nil { + return nil, BuildError{http.StatusInternalServerError, "failed to fetch template version workspace tags", err} + } + parameterNames, parameterValues, err := b.getParameters() + if err != nil { + return nil, err // already wrapped BuildError + } + + evalCtx := buildParametersEvalContext(parameterNames, parameterValues) + for _, workspaceTag := range workspaceTags { + expr, diags := hclsyntax.ParseExpression([]byte(workspaceTag.Value), "expression.hcl", hcl.InitialPos) + if diags.HasErrors() { + return nil, BuildError{http.StatusBadRequest, "failed to parse workspace tag value", xerrors.Errorf(diags.Error())} + } + + val, diags := expr.Value(evalCtx) + if diags.HasErrors() { + return nil, BuildError{http.StatusBadRequest, "failed to evaluate workspace tag value", xerrors.Errorf(diags.Error())} + } + + // Do not use "val.AsString()" as it can panic + str, err := ctyValueString(val) + if err != nil { + return nil, BuildError{http.StatusBadRequest, "failed to marshal cty.Value as string", err} + } + tags[workspaceTag.Key] = str + } + 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 + } + + templateVersion, err := b.getTemplateVersion() + if err != nil { + return nil, xerrors.Errorf("get template version: %w", err) + } + + workspaceTags, err := b.store.GetTemplateVersionWorkspaceTags(b.ctx, templateVersion.ID) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + return nil, xerrors.Errorf("get template version workspace tags: %w", err) + } + + b.templateVersionWorkspaceTags = &workspaceTags + return *b.templateVersionWorkspaceTags, nil +} + // authorize performs build authorization pre-checks using the provided authFunc func (b *Builder) authorize(authFunc func(action policy.Action, object rbac.Objecter) bool) error { // Doing this up front saves a lot of work if the user doesn't have permission. diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index f1c7e6b62a493..ad53cd7d45609 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -60,6 +60,7 @@ func TestBuilder_NoOptions(t *testing.T) { withLastBuildFound, withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), + withWorkspaceTags(inactiveVersionID, nil), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -112,6 +113,7 @@ func TestBuilder_Initiator(t *testing.T) { withLastBuildFound, withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), + withWorkspaceTags(inactiveVersionID, nil), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -154,6 +156,7 @@ func TestBuilder_Baggage(t *testing.T) { withLastBuildFound, withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), + withWorkspaceTags(inactiveVersionID, nil), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) { @@ -188,9 +191,10 @@ func TestBuilder_Reason(t *testing.T) { withLastBuildFound, withRichParameters(nil), withParameterSchemas(inactiveJobID, nil), + withWorkspaceTags(inactiveVersionID, nil), // Outputs - expectProvisionerJob(func(job database.InsertProvisionerJobParams) { + expectProvisionerJob(func(_ database.InsertProvisionerJobParams) { }), withInTx, expectBuild(func(bld database.InsertWorkspaceBuildParams) { @@ -221,6 +225,7 @@ func TestBuilder_ActiveVersion(t *testing.T) { withActiveVersion(nil), withLastBuildNotFound, withParameterSchemas(activeJobID, nil), + withWorkspaceTags(activeVersionID, nil), // previous rich parameters are not queried because there is no previous build. // Outputs @@ -246,6 +251,102 @@ func TestBuilder_ActiveVersion(t *testing.T) { req.NoError(err) } +func TestWorkspaceBuildWithTags(t *testing.T) { + t.Parallel() + + asrt := assert.New(t) + req := require.New(t) + + workspaceTags := []database.TemplateVersionWorkspaceTag{ + { + Key: "fruits_tag", + Value: "data.coder_parameter.number_of_apples.value + data.coder_parameter.number_of_oranges.value", + }, + { + Key: "cluster_tag", + Value: `"best_developers"`, + }, + { + Key: "project_tag", + Value: `"${data.coder_parameter.project.value}+12345"`, + }, + { + Key: "team_tag", + Value: `data.coder_parameter.team.value`, + }, + { + Key: "yes_or_no", + Value: `data.coder_parameter.is_debug_build.value`, + }, + { + Key: "actually_no", + Value: `!data.coder_parameter.is_debug_build.value`, + }, + { + Key: "is_debug_build", + Value: `data.coder_parameter.is_debug_build.value == "true" ? "in-debug-mode" : "no-debug"`, + }, + } + + richParameters := []database.TemplateVersionParameter{ + // Parameters can be mutable although it is discouraged as the workspace can be moved between provisioner nodes. + {Name: "project", Description: "This is first parameter", Mutable: true, Options: json.RawMessage("[]")}, + {Name: "team", Description: "This is second parameter", Mutable: true, DefaultValue: "godzilla", Options: json.RawMessage("[]")}, + {Name: "is_debug_build", Type: "bool", Description: "This is third parameter", Mutable: false, DefaultValue: "false", Options: json.RawMessage("[]")}, + {Name: "number_of_apples", Type: "number", Description: "This is fourth parameter", Mutable: false, DefaultValue: "4", Options: json.RawMessage("[]")}, + {Name: "number_of_oranges", Type: "number", Description: "This is fifth parameter", Mutable: false, DefaultValue: "6", Options: json.RawMessage("[]")}, + } + + buildParameters := []codersdk.WorkspaceBuildParameter{ + {Name: "project", Value: "foobar-foobaz"}, + {Name: "is_debug_build", Value: "true"}, + // Parameters "team", "number_of_apples", "number_of_oranges" are skipped, so default value is selected + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mDB := expectDB(t, + // Inputs + withTemplate, + withInactiveVersion(richParameters), + withLastBuildFound, + withRichParameters(nil), + withParameterSchemas(inactiveJobID, nil), + withWorkspaceTags(inactiveVersionID, workspaceTags), + + // Outputs + expectProvisionerJob(func(job database.InsertProvisionerJobParams) { + asrt.Len(job.Tags, 10) + + 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", + + "scope": "user", + "version": "inactive", + "owner": userID.String(), + } + asrt.Equal(job.Tags, expected) + }), + withInTx, + expectBuild(func(_ database.InsertWorkspaceBuildParams) {}), + expectBuildParameters(func(_ database.InsertWorkspaceBuildParametersParams) { + }), + withBuild, + ) + + ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} + uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(buildParameters) + _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + req.NoError(err) +} + func TestWorkspaceBuildWithRichParameters(t *testing.T) { t.Parallel() @@ -302,6 +403,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withLastBuildFound, withRichParameters(initialBuildParameters), withParameterSchemas(inactiveJobID, nil), + withWorkspaceTags(inactiveVersionID, nil), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -345,6 +447,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withLastBuildFound, withRichParameters(initialBuildParameters), withParameterSchemas(inactiveJobID, nil), + withWorkspaceTags(inactiveVersionID, nil), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -394,11 +497,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withLastBuildFound, withRichParameters(nil), withParameterSchemas(inactiveJobID, schemas), - - // Outputs - expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), - withInTx, - expectBuild(func(bld database.InsertWorkspaceBuildParams) {}), + withWorkspaceTags(inactiveVersionID, nil), ) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} @@ -429,13 +528,10 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withLastBuildFound, withRichParameters(initialBuildParameters), withParameterSchemas(inactiveJobID, nil), + withWorkspaceTags(inactiveVersionID, nil), // Outputs - expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), - withInTx, - expectBuild(func(bld database.InsertWorkspaceBuildParams) {}), - // no build parameters, since we hit an error validating. - // expectBuildParameters(func(params database.InsertWorkspaceBuildParametersParams) {}), + // no transaction, since we failed fast while validation build parameters ) ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} @@ -482,6 +578,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withLastBuildFound, withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), + withWorkspaceTags(activeVersionID, nil), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -542,6 +639,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withLastBuildFound, withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), + withWorkspaceTags(activeVersionID, nil), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -600,6 +698,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { withLastBuildFound, withRichParameters(initialBuildParameters), withParameterSchemas(activeJobID, nil), + withWorkspaceTags(activeVersionID, nil), // Outputs expectProvisionerJob(func(job database.InsertProvisionerJobParams) {}), @@ -813,6 +912,18 @@ func withRichParameters(params []database.WorkspaceBuildParameter) func(mTx *dbm } } +func withWorkspaceTags(versionID uuid.UUID, tags []database.TemplateVersionWorkspaceTag) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + c := mTx.EXPECT().GetTemplateVersionWorkspaceTags(gomock.Any(), versionID). + Times(1) + if len(tags) > 0 { + c.Return(tags, nil) + } else { + c.Return(nil, sql.ErrNoRows) + } + } +} + // Since there is expected to be only one each of job, build, and build-parameters inserted, instead // of building matchers, we match any call and then assert its parameters. This will feel // more familiar to the way we write other tests.