From adb5edce0ccc5c441121288920320ec588dd4850 Mon Sep 17 00:00:00 2001 From: Bryson Henneberger <591079+PushTheLimit@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:34:31 -0600 Subject: [PATCH 1/6] feat: evaluate only the parameter/preset/tag closure when rendering Preview evaluates the entire Terraform module graph on every call, even though rendering a workspace form only needs coder_parameter, coder_workspace_preset and coder_workspace_tags (and what they reference). The resources a workspace would create cannot feed those blocks, so evaluating them is wasted work that dominates request latency on large templates. Pass OptionWithResourceClosure with the three target block types so the parser drops root-module resources that nothing in that closure references. On a real template this cuts EvaluateAll from ~2s to ~0.16s (~12x) with byte-identical parameters, presets and tags. Depends on the OptionWithResourceClosure addition in the trivy fork (coder/trivy#74). The trivy replace is temporarily pinned to that PR's commit; it will be moved to the merged coder/trivy commit before this merges. --- go.mod | 2 +- go.sum | 4 ++++ preview.go | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 5e2285e..721cb89 100644 --- a/go.mod +++ b/go.mod @@ -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/PushTheLimit/trivy v0.0.0-20260825213047-3916002b18a9 diff --git a/go.sum b/go.sum index 1b7c977..893f141 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,10 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/PushTheLimit/trivy v0.0.0-20260825211335-fa2a87959259 h1:vba8EkcoWWGRr6nTNFspGAESDHswMwBLAz+y0XqxtfU= +github.com/PushTheLimit/trivy v0.0.0-20260825211335-fa2a87959259/go.mod h1:+zF17ZBOdhFWwD3+GkLxZ/vkmKLudoOtt+hgnc1TQpA= +github.com/PushTheLimit/trivy v0.0.0-20260825213047-3916002b18a9 h1:9cCoooPV3k63sQdDY0E0eFutFgy+avEMdWaWm6jc7qc= +github.com/PushTheLimit/trivy v0.0.0-20260825213047-3916002b18a9/go.mod h1:+zF17ZBOdhFWwD3+GkLxZ/vkmKLudoOtt+hgnc1TQpA= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= diff --git a/preview.go b/preview.go index 32ac43f..f41e08b 100644 --- a/preview.go +++ b/preview.go @@ -243,6 +243,16 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn parser.OptionWithEvalHook(ownerHook), parser.OptionWithWorkingDirectoryPath("/"), parser.OptionWithEvalHook(parameterContextsEvalHook(input)), + // 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 pruning the ones nothing references + // avoids evaluating the entire module graph on every request without + // changing any parameter, preset or tag. See OptionWithResourceClosure. + parser.OptionWithResourceClosure([]string{ + "coder_parameter", + "coder_workspace_preset", + "coder_workspace_tags", + }), // '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), From 47bc4683132f66ebf431ab1ce379d146e38e4ecc Mon Sep 17 00:00:00 2001 From: Bryson Henneberger <591079+PushTheLimit@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:37:04 -0600 Subject: [PATCH 2/6] chore: bump trivy pin to include the indexed-reference pruning fix Picks up the fix that retains resources referenced through an index expression (count/for_each) when pruning the target closure, plus the accompanying test coverage. Required by the closure corpus added next. --- go.mod | 2 +- go.sum | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 721cb89..b433416 100644 --- a/go.mod +++ b/go.mod @@ -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/PushTheLimit/trivy v0.0.0-20260825213047-3916002b18a9 +replace github.com/aquasecurity/trivy => github.com/PushTheLimit/trivy v0.0.0-20260902202626-5f70611adf65 diff --git a/go.sum b/go.sum index 893f141..5288d7b 100644 --- a/go.sum +++ b/go.sum @@ -38,10 +38,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= -github.com/PushTheLimit/trivy v0.0.0-20260825211335-fa2a87959259 h1:vba8EkcoWWGRr6nTNFspGAESDHswMwBLAz+y0XqxtfU= -github.com/PushTheLimit/trivy v0.0.0-20260825211335-fa2a87959259/go.mod h1:+zF17ZBOdhFWwD3+GkLxZ/vkmKLudoOtt+hgnc1TQpA= -github.com/PushTheLimit/trivy v0.0.0-20260825213047-3916002b18a9 h1:9cCoooPV3k63sQdDY0E0eFutFgy+avEMdWaWm6jc7qc= -github.com/PushTheLimit/trivy v0.0.0-20260825213047-3916002b18a9/go.mod h1:+zF17ZBOdhFWwD3+GkLxZ/vkmKLudoOtt+hgnc1TQpA= +github.com/PushTheLimit/trivy v0.0.0-20260902202626-5f70611adf65 h1:fVQjYtdo5NMXre7YRkxb6M2vLRjnIVSmRkzdN446KBM= +github.com/PushTheLimit/trivy v0.0.0-20260902202626-5f70611adf65/go.mod h1:+zF17ZBOdhFWwD3+GkLxZ/vkmKLudoOtt+hgnc1TQpA= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= @@ -119,8 +117,6 @@ 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/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= From 3bb4e5863276c440b638d5063526e0f86a271ae3 Mon Sep 17 00:00:00 2001 From: Bryson Henneberger <591079+PushTheLimit@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:37:04 -0600 Subject: [PATCH 3/6] test: validate resource-closure pruning preserves parameters and tags Adds a fixture whose parameters and workspace tags derive from resource blocks through direct, local-indirected, and count-indexed references, with an unreferenced orphan resource that pruning drops. Asserts the parameter defaults and tag values are exactly what an unpruned evaluation produces, so an over-aggressive prune is caught end to end (the count-indexed case fails against the pre-fix evaluator). Excluded from the terraform-apply e2e suite (skipe2e); the closure logic lives in static preview evaluation, which Test_Extract covers. --- preview_test.go | 16 +++++++++ testdata/resourceclosure/main.tf | 61 ++++++++++++++++++++++++++++++++ testdata/resourceclosure/skipe2e | 1 + 3 files changed, 78 insertions(+) create mode 100644 testdata/resourceclosure/main.tf create mode 100644 testdata/resourceclosure/skipe2e diff --git a/preview_test.go b/preview_test.go index 49a7fb6..7eae689 100644 --- a/preview_test.go +++ b/preview_test.go @@ -54,6 +54,22 @@ func Test_Extract(t *testing.T) { dir: "badparam", failPreview: true, }, + { + // Parameters and tags whose values flow from resource blocks must be + // unchanged when unreferenced resources are pruned by the target + // closure. Covers direct, local-indirected, and count-indexed + // references; an orphan resource is pruned without effect. + name: "resource closure", + dir: "resourceclosure", + expTags: map[string]string{ + "flavor": "large", + }, + params: map[string]assertParam{ + "flavor": ap().value("large").def("large"), + "direct": ap().value("large").def("large"), + "indexed": ap().value("poolimg").def("poolimg"), + }, + }, { name: "sometags", dir: "sometags", diff --git a/testdata/resourceclosure/main.tf b/testdata/resourceclosure/main.tf new file mode 100644 index 0000000..e35febc --- /dev/null +++ b/testdata/resourceclosure/main.tf @@ -0,0 +1,61 @@ +// Exercises target-driven resource-closure pruning end to end: parameters and +// workspace tags whose values flow from resource blocks (directly, through a +// local, and through a count index) must be identical whether or not the +// unreferenced resources are pruned. The orphan resource below is outside the +// closure and is dropped; it must not change any output. +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "2.4.0-pre0" + } + docker = { + source = "kreuzwerker/docker" + version = "3.0.2" + } + } +} + +resource "docker_image" "base" { + name = "large" +} + +resource "docker_image" "pool" { + count = 2 + name = "poolimg" +} + +locals { + flavor = docker_image.base.name +} + +data "coder_parameter" "flavor" { + name = "flavor" + type = "string" + default = local.flavor +} + +data "coder_parameter" "direct" { + name = "direct" + type = "string" + default = docker_image.base.name +} + +data "coder_parameter" "indexed" { + name = "indexed" + type = "string" + default = docker_image.pool[0].name +} + +data "coder_workspace_tags" "tags" { + tags = { + flavor = docker_image.base.name + } +} + +// Nothing in the parameter/tag closure references this resource, so it is +// pruned. Its presence must not affect the parameters or tags above. +resource "docker_container" "orphan" { + name = "orphan" + image = "does-not-exist" +} diff --git a/testdata/resourceclosure/skipe2e b/testdata/resourceclosure/skipe2e new file mode 100644 index 0000000..5e15859 --- /dev/null +++ b/testdata/resourceclosure/skipe2e @@ -0,0 +1 @@ +closure logic is exercised by Test_Extract (static preview eval); real terraform apply is out of scope here From ba54491b4a8eb01fc700e744c1c242501958c4b2 Mon Sep 17 00:00:00 2001 From: Bryson Henneberger <591079+PushTheLimit@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:50:03 -0600 Subject: [PATCH 4/6] test: round out closure corpus with preset, tag, for_each and transitive edges Extends the resourceclosure fixture so each closure target type keeps a resource independently: a preset and a workspace tag now each keep a resource nothing else references, alongside the parameter paths. Adds a for_each index reference and a transitive resource->resource chain. The for_each and count-index cases fail against the pre-fix evaluator and pass with the indexed-reference fix; the full existing corpus still passes with the closure enabled. --- preview_test.go | 25 ++++++++----- testdata/resourceclosure/main.tf | 60 ++++++++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/preview_test.go b/preview_test.go index 7eae689..3f45fba 100644 --- a/preview_test.go +++ b/preview_test.go @@ -55,19 +55,28 @@ func Test_Extract(t *testing.T) { failPreview: true, }, { - // Parameters and tags whose values flow from resource blocks must be - // unchanged when unreferenced resources are pruned by the target - // closure. Covers direct, local-indirected, and count-indexed - // references; an orphan resource is pruned without effect. + // Parameters, presets, and tags whose values flow from resource + // blocks must be unchanged when unreferenced resources are pruned by + // the target closure. Each target type (param/preset/tag) is proven + // to independently keep a resource, across direct, local, count-index, + // for_each-index, and transitive resource->resource references. An + // orphan resource is pruned without effect. name: "resource closure", dir: "resourceclosure", expTags: map[string]string{ - "flavor": "large", + "flavor": "large", // tag reads a param-shared resource + "tagged": "tag-large", // tag independently keeps its own resource }, params: map[string]assertParam{ - "flavor": ap().value("large").def("large"), - "direct": ap().value("large").def("large"), - "indexed": ap().value("poolimg").def("poolimg"), + "flavor": ap().value("large").def("large"), // via local + "direct": ap().value("large").def("large"), // direct + "indexed": ap().value("poolimg").def("poolimg"), // count index + "byeach": ap().value("fe-large").def("fe-large"), // for_each index + "chained": ap().value("chain-large").def("chain-large"), // transitive resource->resource + }, + presets: map[string]assertPreset{ + // The preset independently keeps a resource nothing else references. + "big": aPre().value("flavor", "preset-large"), }, }, { diff --git a/testdata/resourceclosure/main.tf b/testdata/resourceclosure/main.tf index e35febc..7622cfa 100644 --- a/testdata/resourceclosure/main.tf +++ b/testdata/resourceclosure/main.tf @@ -1,8 +1,9 @@ -// Exercises target-driven resource-closure pruning end to end: parameters and -// workspace tags whose values flow from resource blocks (directly, through a -// local, and through a count index) must be identical whether or not the -// unreferenced resources are pruned. The orphan resource below is outside the -// closure and is dropped; it must not change any output. +// Exercises target-driven resource-closure pruning end to end. Every resource +// below is reachable from exactly one target (a parameter, a preset, or a tag) +// or through a resource->resource chain, so each proves that its path keeps the +// resource alive through pruning. The orphan is reachable from nothing and is +// dropped; it must not change any output. Resource attributes use static values +// so the assertions isolate pruning behaviour, not count/for_each value binding. terraform { required_providers { coder = { @@ -16,6 +17,7 @@ terraform { } } +// --- Parameter-reached resources ------------------------------------------- resource "docker_image" "base" { name = "large" } @@ -25,6 +27,21 @@ resource "docker_image" "pool" { name = "poolimg" } +resource "docker_image" "bykey" { + for_each = toset(["a"]) + name = "fe-large" +} + +// Transitive chain: a parameter reaches chain_a directly, chain_a reaches +// chain_b. Pruning must keep both. +resource "docker_image" "chain_b" { + name = "chain-large" +} + +resource "docker_image" "chain_a" { + name = docker_image.chain_b.name +} + locals { flavor = docker_image.base.name } @@ -47,14 +64,43 @@ data "coder_parameter" "indexed" { default = docker_image.pool[0].name } +data "coder_parameter" "byeach" { + name = "byeach" + type = "string" + default = docker_image.bykey["a"].name +} + +data "coder_parameter" "chained" { + name = "chained" + type = "string" + default = docker_image.chain_a.name +} + +// --- Preset-reached resource (nothing else references it) ------------------ +resource "docker_image" "preset_only" { + name = "preset-large" +} + +data "coder_workspace_preset" "big" { + name = "big" + parameters = { + flavor = docker_image.preset_only.name + } +} + +// --- Tag-reached resource (nothing else references it) --------------------- +resource "docker_image" "tag_only" { + name = "tag-large" +} + data "coder_workspace_tags" "tags" { tags = { flavor = docker_image.base.name + tagged = docker_image.tag_only.name } } -// Nothing in the parameter/tag closure references this resource, so it is -// pruned. Its presence must not affect the parameters or tags above. +// --- Orphan: reachable from no target, pruned, must not affect outputs ----- resource "docker_container" "orphan" { name = "orphan" image = "does-not-exist" From 4bcab5d118340259ac579d386223ff81618290ac Mon Sep 17 00:00:00 2001 From: Bryson Henneberger <591079+PushTheLimit@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:19:32 -0600 Subject: [PATCH 5/6] test: add Test_Extract assertions for previously-uncovered fixtures Fills gaps in the corpus with thorough parameter/tag assertions for fixtures that had no Test_Extract case: - countsub: count-guarded parameter and a tag driven by a submodule output - http: a tag from an http data source that is unknown without plan data - notstringtag: non-string tag keys and values coerced to strings - wordle: seven parameters that default to the empty string Assertion-only; no changes to preview behavior. --- preview_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/preview_test.go b/preview_test.go index 3f45fba..6659e55 100644 --- a/preview_test.go +++ b/preview_test.go @@ -79,6 +79,52 @@ func Test_Extract(t *testing.T) { "big": aPre().value("flavor", "preset-large"), }, }, + { + // A count-guarded parameter and a tag both driven by a submodule + // output. + name: "count-guarded param from submodule output", + dir: "countsub", + expTags: map[string]string{ + "test": "true", + }, + unknownTags: []string{}, + params: map[string]assertParam{ + "Region": ap().value("STATIC").def("STATIC"), + }, + }, + { + // A tag whose value comes from an http data source that cannot be + // resolved without plan data, so it is unknown. + name: "tag from unknown http data source", + dir: "http", + expTags: map[string]string{}, + unknownTags: []string{"tfversion"}, + }, + { + // Non-string tag keys and values. + name: "non-string tag values", + dir: "notstringtag", + expTags: map[string]string{ + "zone": "5", + "10": "hello", + }, + unknownTags: []string{}, + }, + { + // No guesses entered, so every parameter's value and default is the + // empty string. + name: "wordle empty defaults", + dir: "wordle", + params: map[string]assertParam{ + "letter_bank": ap().value("").def(""), + "one": ap().value("").def(""), + "two": ap().value("").def(""), + "three": ap().value("").def(""), + "four": ap().value("").def(""), + "five": ap().value("").def(""), + "six": ap().value("").def(""), + }, + }, { name: "sometags", dir: "sometags", From 0b467d60388861165c54b01e18a5576c0af4f831 Mon Sep 17 00:00:00 2001 From: Bryson Henneberger <591079+PushTheLimit@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:24:39 -0600 Subject: [PATCH 6/6] test: add Test_Extract assertions for the formtypes fixture Asserts each parameter's value, default and form type, including the four whose form_type is resolved from a sibling *_format dropdown's value (single->radio, number->input, boolean->radio, list->multi-select). The count-guarded satisfaction parameter is correctly absent because like_it defaults false. --- preview_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/preview_test.go b/preview_test.go index 6659e55..d578b8c 100644 --- a/preview_test.go +++ b/preview_test.go @@ -125,6 +125,25 @@ func Test_Extract(t *testing.T) { "six": ap().value("").def(""), }, }, + { + // Each "*_format" dropdown drives the sibling parameter's form_type, + // so this exercises form_type resolved from another parameter's value + // alongside static defaults. like_it defaults false, so the + // count-guarded "satisfaction" parameter is absent. + name: "form types", + dir: "formtypes", + params: map[string]assertParam{ + "single_select": ap().value("radio").def("radio").formType(provider.ParameterFormTypeDropdown), + "single": ap().value("alpha-value").def("alpha-value").formType(provider.ParameterFormTypeRadio), + "number_format": ap().value("input").def("input").formType(provider.ParameterFormTypeDropdown), + "number": ap().value("7").def("7").formType(provider.ParameterFormTypeInput), + "boolean_format": ap().value("radio").def("radio").formType(provider.ParameterFormTypeDropdown), + "boolean": ap().value("true").def("true").formType(provider.ParameterFormTypeRadio), + "list_format": ap().value("multi-select").def("multi-select").formType(provider.ParameterFormTypeDropdown), + "list": ap().value(`["blue","green"]`).def(`["blue","green"]`).formType(provider.ParameterFormTypeMultiSelect), + "like_it": ap().value("false").def("false").formType(provider.ParameterFormTypeCheckbox), + }, + }, { name: "sometags", dir: "sometags",