feat: evaluate provisioner tags (#13333)

This commit is contained in:
Marcin Tojek
2024-05-23 09:53:51 +02:00
committed by GitHub
parent fa9edc1f42
commit c2837a62e4
2 changed files with 252 additions and 20 deletions

View File

@ -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.

View File

@ -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.