Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,4 @@ require (

// Trivy has some issues that we're floating patches for, and will hopefully
// be upstreamed eventually.
replace github.com/aquasecurity/trivy => github.com/coder/trivy v0.0.0-20260309164037-c413f5a2f511
replace github.com/aquasecurity/trivy => github.com/coder/trivy v0.0.0-20260911135535-15b949537506
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ github.com/coder/serpent v0.10.0 h1:ofVk9FJXSek+SmL3yVE3GoArP83M+1tX+H7S4t8BSuM=
github.com/coder/serpent v0.10.0/go.mod h1:cZFW6/fP+kE9nd/oRkEHJpG6sXCtQ+AX7WMMEHv0Y3Q=
github.com/coder/terraform-provider-coder/v2 v2.8.0 h1:pbWfegCPI0v8eATgE8kGwIyuaMPgMRIcdLF2GTVkgG0=
github.com/coder/terraform-provider-coder/v2 v2.8.0/go.mod h1:WrdLSbihuzH1RZhwrU+qmkqEhUbdZT/sjHHdarm5b5g=
github.com/coder/trivy v0.0.0-20260309164037-c413f5a2f511 h1:wJS3Pk13VuCbV8hjrQRnOBCUwP3Islk91sMvbSdY0Vk=
github.com/coder/trivy v0.0.0-20260309164037-c413f5a2f511/go.mod h1:+zF17ZBOdhFWwD3+GkLxZ/vkmKLudoOtt+hgnc1TQpA=
github.com/coder/trivy v0.0.0-20260911135535-15b949537506 h1:A9f2UisugOgV2JlhibP0GZdzpqn+sWPIcz4q3bsgopI=
github.com/coder/trivy v0.0.0-20260911135535-15b949537506/go.mod h1:+zF17ZBOdhFWwD3+GkLxZ/vkmKLudoOtt+hgnc1TQpA=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
Expand Down
34 changes: 34 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package preview

// Option adjusts how Preview evaluates a template. Options exist so a caller
// can change evaluation behavior without a new preview release, for example
// to turn off an optimization that misbehaves on a particular template.
type Option func(*options)

type options struct {
// fullEvaluation disables resource closure pruning so every resource in
// the root module is evaluated.
fullEvaluation bool
}

// OptionFullEvaluation evaluates every resource in the root module instead of
// only those reachable from parameter, preset, and tag blocks. It is the escape
// hatch for the resource closure optimization: parameters, presets, and tags
// are unchanged either way, so this only matters when that optimization has a
// bug, or when Output.ModuleOutput must include outputs that read resources
// outside the closure.
func OptionFullEvaluation() Option {
return func(o *options) {
o.fullEvaluation = true
}
}

func applyOptions(opts []Option) options {
var o options
for _, opt := range opts {
if opt != nil {
opt(&o)
}
}
return o
}
19 changes: 16 additions & 3 deletions preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ func ValidatePrebuilds(ctx context.Context, input Input, preValid []types.Preset
}
}

func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagnostics hcl.Diagnostics) {
func Preview(ctx context.Context, input Input, dir fs.FS, opts ...Option) (output *Output, diagnostics hcl.Diagnostics) {
settings := applyOptions(opts)
// The trivy package works with `github.com/zclconf/go-cty`. This package is
// similar to `reflect` in its usage. This package can panic if types are
// misused. To protect the caller, a general `recover` is used to catch any
Expand Down Expand Up @@ -234,7 +235,7 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn
}

// moduleSource is "" for a local module
p := parser.New(dir, "",
parserOpts := []parser.Option{
parser.OptionWithLogger(logger),
parser.OptionStopOnHCLError(false),
parser.OptionWithDownloads(false),
Expand All @@ -246,7 +247,19 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn
// 'OptionsWithTfVars' cannot be set with 'OptionWithTFVarsPaths'. So load the
// tfvars from the files ourselves and merge with the user-supplied tf vars.
parser.OptionsWithTfVars(variableValues),
)
}
if !settings.fullEvaluation {
// Only the parameter/preset/tag blocks and what they reference need to
// be evaluated to render a workspace form. The resources a workspace
// would create cannot feed those blocks, so root resources nothing in
// this closure references are skipped. See OptionFullEvaluation.
parserOpts = append(parserOpts, parser.OptionWithResourceClosure([]string{
"coder_parameter",
"coder_workspace_preset",
"coder_workspace_tags",
}))
}
p := parser.New(dir, "", parserOpts...)

err = p.ParseFS(ctx, ".")
if err != nil {
Expand Down
170 changes: 106 additions & 64 deletions preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -867,85 +867,127 @@ func Test_Extract(t *testing.T) {
},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if tc.skip != "" {
t.Skip(tc.skip)
return
}
// Every vector runs twice: with the default resource closure
// optimization, and with OptionFullEvaluation. The expectations are
// shared, which pins that the optimization is output-neutral for
// parameters, presets, tags, and variables.
for _, mode := range []struct {
name string
opts []preview.Option
}{
{name: "closure"},
{name: "full", opts: []preview.Option{preview.OptionFullEvaluation()}},
} {
t.Run(tc.name+"/"+mode.name, func(t *testing.T) {
t.Parallel()
if tc.skip != "" {
t.Skip(tc.skip)
return
}

if tc.unknownTags == nil {
tc.unknownTags = []string{}
}
if tc.expTags == nil {
tc.expTags = map[string]string{}
}
if tc.unknownTags == nil {
tc.unknownTags = []string{}
}
if tc.expTags == nil {
tc.expTags = map[string]string{}
}
Comment on lines +888 to +893

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We might have a race here now that we have parallel subtests sharing tc.


dirFs := os.DirFS(filepath.Join("testdata", tc.dir))
dirFs := os.DirFS(filepath.Join("testdata", tc.dir))

output, diags := preview.Preview(context.Background(), tc.input, dirFs)
if tc.failPreview {
require.True(t, diags.HasErrors())
return
}
if diags.HasErrors() {
t.Logf("diags: %s", diags)
}
require.False(t, diags.HasErrors())
output, diags := preview.Preview(context.Background(), tc.input, dirFs, mode.opts...)
if tc.failPreview {
require.True(t, diags.HasErrors())
return
}
if diags.HasErrors() {
t.Logf("diags: %s", diags)
}
require.False(t, diags.HasErrors())

// Validate prebuilds too
preview.ValidatePrebuilds(context.Background(), tc.input, output.Presets, dirFs)
// Validate prebuilds too
preview.ValidatePrebuilds(context.Background(), tc.input, output.Presets, dirFs)

if len(tc.warnings) > 0 {
for _, w := range tc.warnings {
idx := slices.IndexFunc(diags, func(diagnostic *hcl.Diagnostic) bool {
return w.MatchString(diagnostic.Error())
if len(tc.warnings) > 0 {
for _, w := range tc.warnings {
idx := slices.IndexFunc(diags, func(diagnostic *hcl.Diagnostic) bool {
return w.MatchString(diagnostic.Error())

})
require.Greater(t, idx, -1, "expected warning %q to be present in diags", w.String())
})
require.Greater(t, idx, -1, "expected warning %q to be present in diags", w.String())
}
}
}

// Assert tags
validTags := output.WorkspaceTags.Tags()

for k, expected := range tc.expTags {
tag, ok := validTags[k]
if !ok {
t.Errorf("expected tag %q to be present in output, but it was not", k)
continue
// Assert tags
validTags := output.WorkspaceTags.Tags()

for k, expected := range tc.expTags {
tag, ok := validTags[k]
if !ok {
t.Errorf("expected tag %q to be present in output, but it was not", k)
continue
}
if tag != expected {
assert.JSONEqf(t, expected, tag, "tag %q does not match expected, nor is it a json equivalent", k)
}
}
if tag != expected {
assert.JSONEqf(t, expected, tag, "tag %q does not match expected, nor is it a json equivalent", k)
assert.Equal(t, len(tc.expTags), len(output.WorkspaceTags.Tags()), "unexpected number of tags in output")

assert.ElementsMatch(t, tc.unknownTags, output.WorkspaceTags.UnusableTags().SafeNames())

// Assert params
require.Len(t, output.Parameters, len(tc.params), "wrong number of parameters expected")
for _, param := range output.Parameters {
check, ok := tc.params[param.Name]
require.True(t, ok, "unknown parameter %s", param.Name)
check(t, param)
}
}
assert.Equal(t, len(tc.expTags), len(output.WorkspaceTags.Tags()), "unexpected number of tags in output")

assert.ElementsMatch(t, tc.unknownTags, output.WorkspaceTags.UnusableTags().SafeNames())
for _, preset := range output.Presets {
check, ok := tc.presets[preset.Name]
require.True(t, ok, "unknown preset %s", preset.Name)
check(t, preset)
}

// Assert params
require.Len(t, output.Parameters, len(tc.params), "wrong number of parameters expected")
for _, param := range output.Parameters {
check, ok := tc.params[param.Name]
require.True(t, ok, "unknown parameter %s", param.Name)
check(t, param)
}
// Assert variables
require.Len(t, output.Variables, len(tc.variables), "wrong number of variables expected")
for _, variable := range output.Variables {
check, ok := tc.variables[variable.Name]
require.True(t, ok, "unknown variable %s", variable.Name)
check(t, variable)
}
})
}
}
}

for _, preset := range output.Presets {
check, ok := tc.presets[preset.Name]
require.True(t, ok, "unknown preset %s", preset.Name)
check(t, preset)
}
// Test_OptionFullEvaluation proves the option changes evaluation, not just
// that both modes agree on parameters. Output.ModuleOutput is the one place a
// resource outside the closure is observable.
func Test_OptionFullEvaluation(t *testing.T) {
t.Parallel()

// Assert variables
require.Len(t, output.Variables, len(tc.variables), "wrong number of variables expected")
for _, variable := range output.Variables {
check, ok := tc.variables[variable.Name]
require.True(t, ok, "unknown variable %s", variable.Name)
check(t, variable)
}
})
dirFs := os.DirFS(filepath.Join("testdata", "fullevaluation"))
outputName := func(t *testing.T, opts ...preview.Option) cty.Value {
t.Helper()
output, diags := preview.Preview(context.Background(), preview.Input{}, dirFs, opts...)
require.False(t, diags.HasErrors(), diags.Error())
require.Len(t, output.Parameters, 1)
assert.Equal(t, "large", output.Parameters[0].Value.AsString())
return output.ModuleOutput.GetAttr("unreferenced_name")
}

t.Run("Closure", func(t *testing.T) {
t.Parallel()
v := outputName(t)
assert.False(t, v.IsKnown(), "resource outside the closure should not be evaluated by default, got %s", v.GoString())
})

t.Run("Full", func(t *testing.T) {
t.Parallel()
v := outputName(t, preview.OptionFullEvaluation())
require.True(t, v.IsKnown(), "OptionFullEvaluation must evaluate every resource")
assert.Equal(t, "outside-closure", v.AsString())
})
}

func TestPresetValidation(t *testing.T) {
Expand Down
30 changes: 30 additions & 0 deletions testdata/fullevaluation/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Observes whether resources outside the parameter/preset/tag closure were
// evaluated. Nothing a parameter reads references the resource, so the default
// closure skips it and the output that reads it is unknown. With
// OptionFullEvaluation the resource is evaluated and the output resolves.
terraform {
required_providers {
coder = {
source = "coder/coder"
version = "2.4.0-pre0"
}
docker = {
source = "kreuzwerker/docker"
version = "3.0.2"
}
}
}

data "coder_parameter" "flavor" {
name = "flavor"
type = "string"
default = "large"
}

resource "docker_image" "unreferenced" {
name = "outside-closure"
}

output "unreferenced_name" {
value = docker_image.unreferenced.name
}
1 change: 1 addition & 0 deletions testdata/fullevaluation/skipe2e
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
evaluation mode is exercised by Test_OptionFullEvaluation (static preview eval); real terraform apply is out of scope here
Loading