mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: evaluate provisioner tags (#13333)
This commit is contained in:
@ -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.
|
||||
|
@ -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.
|
||||
|
Reference in New Issue
Block a user