Skip to content

feat: include template variables in dynamic parameter rendering #18819

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jul 21, 2025
Merged
30 changes: 29 additions & 1 deletion coderd/coderdtest/dynamicparameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ type DynamicParameterTemplateParams struct {
// TemplateID is used to update an existing template instead of creating a new one.
TemplateID uuid.UUID

Version func(request *codersdk.CreateTemplateVersionRequest)
Version func(request *codersdk.CreateTemplateVersionRequest)
Variables []codersdk.TemplateVersionVariable
}

func DynamicParameterTemplate(t *testing.T, client *codersdk.Client, org uuid.UUID, args DynamicParameterTemplateParams) (codersdk.Template, codersdk.TemplateVersion) {
Expand All @@ -48,6 +49,32 @@ func DynamicParameterTemplate(t *testing.T, client *codersdk.Client, org uuid.UU
},
}}

userVars := make([]codersdk.VariableValue, 0, len(args.Variables))
parseVars := make([]*proto.TemplateVariable, 0, len(args.Variables))
for _, argv := range args.Variables {
parseVars = append(parseVars, &proto.TemplateVariable{
Name: argv.Name,
Description: argv.Description,
Type: argv.Type,
DefaultValue: argv.DefaultValue,
Required: argv.Required,
Sensitive: argv.Sensitive,
})

userVars = append(userVars, codersdk.VariableValue{
Name: argv.Name,
Value: argv.Value,
})
}

files.Parse = []*proto.Response{{
Type: &proto.Response_Parse{
Parse: &proto.ParseComplete{
TemplateVariables: parseVars,
},
},
}}

mime := codersdk.ContentTypeTar
if args.Zip {
mime = codersdk.ContentTypeZip
Expand All @@ -59,6 +86,7 @@ func DynamicParameterTemplate(t *testing.T, client *codersdk.Client, org uuid.UU
if args.Version != nil {
args.Version(request)
}
request.UserVariableValues = userVars
})
AwaitTemplateVersionJobCompleted(t, client, version.ID)

Expand Down
31 changes: 27 additions & 4 deletions coderd/dynamicparameters/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"github.com/google/uuid"
"github.com/zclconf/go-cty/cty"
"golang.org/x/xerrors"

"github.com/coder/coder/v2/apiversion"
Expand Down Expand Up @@ -41,9 +42,10 @@ type loader struct {
templateVersionID uuid.UUID

// cache of objects
templateVersion *database.TemplateVersion
job *database.ProvisionerJob
terraformValues *database.TemplateVersionTerraformValue
templateVersion *database.TemplateVersion
job *database.ProvisionerJob
terraformValues *database.TemplateVersionTerraformValue
templateVariableValues *[]database.TemplateVersionVariable
}

// Prepare is the entrypoint for this package. It loads the necessary objects &
Expand All @@ -61,6 +63,12 @@ func Prepare(ctx context.Context, db database.Store, cache files.FileAcquirer, v
return l.Renderer(ctx, db, cache)
}

func WithTemplateVariableValues(vals []database.TemplateVersionVariable) func(r *loader) {
return func(r *loader) {
r.templateVariableValues = &vals
}
}

func WithTemplateVersion(tv database.TemplateVersion) func(r *loader) {
return func(r *loader) {
if tv.ID == r.templateVersionID {
Expand Down Expand Up @@ -127,6 +135,14 @@ func (r *loader) loadData(ctx context.Context, db database.Store) error {
r.terraformValues = &values
}

if r.templateVariableValues == nil {
vals, err := db.GetTemplateVersionVariables(ctx, r.templateVersion.ID)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("template version variables: %w", err)
}
r.templateVariableValues = &vals
}

return nil
}

Expand Down Expand Up @@ -160,13 +176,17 @@ func (r *loader) dynamicRenderer(ctx context.Context, db database.Store, cache *
}
}()

tfVarValues, err := VariableValues(*r.templateVariableValues)
if err != nil {
return nil, xerrors.Errorf("parse variable values: %w", err)
}

// If they can read the template version, then they can read the file for
// parameter loading purposes.
//nolint:gocritic
fileCtx := dbauthz.AsFileReader(ctx)

var templateFS fs.FS
var err error

templateFS, err = cache.Acquire(fileCtx, db, r.job.FileID)
if err != nil {
Expand All @@ -189,6 +209,7 @@ func (r *loader) dynamicRenderer(ctx context.Context, db database.Store, cache *
db: db,
ownerErrors: make(map[uuid.UUID]error),
close: cache.Close,
tfvarValues: tfVarValues,
}, nil
}

Expand All @@ -199,6 +220,7 @@ type dynamicRenderer struct {

ownerErrors map[uuid.UUID]error
currentOwner *previewtypes.WorkspaceOwner
tfvarValues map[string]cty.Value

once sync.Once
close func()
Expand Down Expand Up @@ -229,6 +251,7 @@ func (r *dynamicRenderer) Render(ctx context.Context, ownerID uuid.UUID, values
PlanJSON: r.data.terraformValues.CachedPlan,
ParameterValues: values,
Owner: *r.currentOwner,
TFVars: r.tfvarValues,
// Do not emit parser logs to coderd output logs.
// TODO: Returning this logs in the output would benefit the caller.
// Unsure how large the logs can be, so for now we just discard them.
Expand Down
65 changes: 65 additions & 0 deletions coderd/dynamicparameters/variablevalues.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package dynamicparameters

import (
"strconv"

"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/json"
"golang.org/x/xerrors"

"github.com/coder/coder/v2/coderd/database"
)

// VariableValues is a helper function that converts a slice of TemplateVersionVariable
// into a map of cty.Value for use in coder/preview.
func VariableValues(vals []database.TemplateVersionVariable) (map[string]cty.Value, error) {
ctyVals := make(map[string]cty.Value, len(vals))
for _, v := range vals {
value := v.Value
if value == "" && v.DefaultValue != "" {
value = v.DefaultValue
}

if value == "" {
// Empty strings are unsupported I guess?
continue // omit non-set vals
}

var err error
switch v.Type {
// Defaulting the empty type to "string"
// TODO: This does not match the terraform behavior, however it is too late
// at this point in the code to determine this, as the database type stores all values
// as strings. The code needs to be fixed in the `Parse` step of the provisioner.
// That step should determine the type of the variable correctly and store it in the database.
case "string", "":
ctyVals[v.Name] = cty.StringVal(value)
Comment on lines +30 to +36
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not ideal, will resolve in another PR

case "number":
ctyVals[v.Name], err = cty.ParseNumberVal(value)
if err != nil {
return nil, xerrors.Errorf("parse variable %q: %w", v.Name, err)
}
case "bool":
parsed, err := strconv.ParseBool(value)
if err != nil {
return nil, xerrors.Errorf("parse variable %q: %w", v.Name, err)
}
ctyVals[v.Name] = cty.BoolVal(parsed)
default:
// If it is a complex type, let the cty json code give it a try.
// TODO: Ideally we parse `list` & `map` and build the type ourselves.
ty, err := json.ImpliedType([]byte(value))
if err != nil {
return nil, xerrors.Errorf("implied type for variable %q: %w", v.Name, err)
}

jv, err := json.Unmarshal([]byte(value), ty)
if err != nil {
return nil, xerrors.Errorf("unmarshal variable %q: %w", v.Name, err)
}
ctyVals[v.Name] = jv
}
}

return ctyVals, nil
}
32 changes: 32 additions & 0 deletions coderd/parameters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,36 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) {
require.Len(t, preview.Diagnostics, 1)
require.Equal(t, preview.Diagnostics[0].Extra.Code, "owner_not_found")
})

t.Run("TemplateVariables", func(t *testing.T) {
t.Parallel()

dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/variables/main.tf")
require.NoError(t, err)

setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
provisionerDaemonVersion: provProto.CurrentVersion.String(),
mainTF: dynamicParametersTerraformSource,
variables: []codersdk.TemplateVersionVariable{
{Name: "one", Value: "austin", DefaultValue: "alice", Type: "string"},
},
plan: nil,
static: nil,
})

ctx := testutil.Context(t, testutil.WaitShort)
stream := setup.stream
previews := stream.Chan()

// Should see the output of the module represented
preview := testutil.RequireReceive(ctx, t, previews)
require.Equal(t, -1, preview.ID)
require.Empty(t, preview.Diagnostics)

require.Len(t, preview.Parameters, 1)
coderdtest.AssertParameter(t, "variable_values", preview.Parameters).
Exists().Value("austin")
})
}

type setupDynamicParamsTestParams struct {
Expand All @@ -355,6 +385,7 @@ type setupDynamicParamsTestParams struct {

static []*proto.RichParameter
expectWebsocketError bool
variables []codersdk.TemplateVersionVariable
}

type dynamicParamsTest struct {
Expand All @@ -380,6 +411,7 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
Plan: args.plan,
ModulesArchive: args.modulesArchive,
StaticParams: args.static,
Variables: args.variables,
})

ctx := testutil.Context(t, testutil.WaitShort)
Expand Down
13 changes: 11 additions & 2 deletions coderd/templateversions.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/google/uuid"
"github.com/moby/moby/pkg/namesgenerator"
"github.com/sqlc-dev/pqtype"
"github.com/zclconf/go-cty/cty"
"golang.org/x/xerrors"

"cdr.dev/slog"
Expand Down Expand Up @@ -1585,7 +1586,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
var parsedTags map[string]string
var ok bool
if dynamicTemplate {
parsedTags, ok = api.dynamicTemplateVersionTags(ctx, rw, organization.ID, apiKey.UserID, file)
parsedTags, ok = api.dynamicTemplateVersionTags(ctx, rw, organization.ID, apiKey.UserID, file, req.UserVariableValues)
if !ok {
return
}
Expand Down Expand Up @@ -1762,7 +1763,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
warnings))
}

func (api *API) dynamicTemplateVersionTags(ctx context.Context, rw http.ResponseWriter, orgID uuid.UUID, owner uuid.UUID, file database.File) (map[string]string, bool) {
func (api *API) dynamicTemplateVersionTags(ctx context.Context, rw http.ResponseWriter, orgID uuid.UUID, owner uuid.UUID, file database.File, templateVariables []codersdk.VariableValue) (map[string]string, bool) {
ownerData, err := dynamicparameters.WorkspaceOwner(ctx, api.Database, orgID, owner)
if err != nil {
if httpapi.Is404Error(err) {
Expand Down Expand Up @@ -1800,11 +1801,19 @@ func (api *API) dynamicTemplateVersionTags(ctx context.Context, rw http.Response
return nil, false
}

// Pass in any manually specified template variables as TFVars.
// TODO: Does this break if the type is not a string?
tfVarValues := make(map[string]cty.Value)
for _, variable := range templateVariables {
tfVarValues[variable.Name] = cty.StringVal(variable.Value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICT VariableValue.Value will always be a string?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is, which I just found strange. This is working for now, I will test complex behavior as I edit the source of this (the parse function)

}

output, diags := preview.Preview(ctx, preview.Input{
PlanJSON: nil, // Template versions are before `terraform plan`
ParameterValues: nil, // No user-specified parameters
Owner: *ownerData,
Logger: stdslog.New(stdslog.DiscardHandler),
TFVars: tfVarValues,
}, files)
tagErr := dynamicparameters.CheckTags(output, diags)
if tagErr != nil {
Expand Down
30 changes: 30 additions & 0 deletions coderd/testdata/parameters/variables/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Base case for workspace tags + parameters.
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
version = "3.0.2"
}
}
}

variable "one" {
default = "alice"
type = string
}


data "coder_parameter" "variable_values" {
name = "variable_values"
description = "Just to show the variable values"
type = "string"
default = var.one

option {
name = "one"
value = var.one
}
}
6 changes: 6 additions & 0 deletions coderd/wsbuilder/wsbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -633,10 +633,16 @@ func (b *Builder) getDynamicParameterRenderer() (dynamicparameters.Renderer, err
return nil, xerrors.Errorf("get template version terraform values: %w", err)
}

variableValues, err := b.getTemplateVersionVariables()
if err != nil {
return nil, xerrors.Errorf("get template version variables: %w", err)
}

renderer, err := dynamicparameters.Prepare(b.ctx, b.store, b.fileCache, tv.ID,
dynamicparameters.WithTemplateVersion(*tv),
dynamicparameters.WithProvisionerJob(*job),
dynamicparameters.WithTerraformValues(*tfVals),
dynamicparameters.WithTemplateVariableValues(variableValues),
)
if err != nil {
return nil, xerrors.Errorf("get template version renderer: %w", err)
Expand Down
Loading
Loading