diff --git a/preview_test.go b/preview_test.go index 49a7fb6..a8ffa00 100644 --- a/preview_test.go +++ b/preview_test.go @@ -54,6 +54,72 @@ func Test_Extract(t *testing.T) { dir: "badparam", failPreview: true, }, + { + // Parameters, presets, and tags whose values flow from resource + // blocks. This pins the baseline so that any evaluation strategy + // that skips resources (see coder/trivy#74) must keep every + // reference shape here: direct, via local, count and for_each + // index, transitive resource->resource, conditional, try(), + // nested option and dynamic blocks, meta-arguments, data-source + // intermediaries, and module inputs. An orphan resource nothing + // references may be dropped without effect. + name: "resource closure", + dir: "resourceclosure", + expTags: map[string]string{ + "flavor": "large", // tag reads a param-shared resource + "tagged": "tag-large", // tag independently keeps its own resource + "derived": "derived-large", // tag -> resource -> parameter + }, + params: map[string]assertParam{ + "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 + + // Expression shapes the pruner must see through. + "picked": ap().value("alt-large").def("alt-large"), // conditional + "tried": ap().value("alt-large").def("alt-large"), // try() + "varindexed": ap().value("poolimg").def("poolimg"), // index is an expression + + // References inside nested blocks. + "staticopt": ap().value("large").def("large").optVals("large"), + "dynopts": ap().value("poolimg").def("poolimg").optVals("poolimg"), + + // Meta-argument on the target reads a resource; the param only + // exists if that resource survived. + "gated": ap().value("on").def("on"), + + // Non-target data source as the intermediary. + "viadata": ap().value("large").def("large"), + + // Module input carries a root resource in; module-owned resource + // is never pruned. + "viamodule": ap().value("large").def("large"), + "modresource": ap().value("sub-large").def("sub-large"), + }, + presets: map[string]assertPreset{ + // The preset independently keeps a resource nothing else references. + "big": aPre().value("flavor", "preset-large"), + // Nested prebuilds block reads a resource. + "pre": aPre().value("flavor", "large").prebuildCount(2), + }, + variables: map[string]assertVariable{ + "pick_alt": av().def(cty.BoolVal(true)).typeEq(cty.Bool), + "idx": av().def(cty.NumberIntVal(1)).typeEq(cty.Number), + }, + }, + { + // JSON syntax templates. A single JSON expression that references two + // resources must keep both; the parameter's default is the same as + // for the HCL equivalent. + name: "resource closure json", + dir: "resourceclosurejson", + expTags: map[string]string{}, + params: map[string]assertParam{ + "joined": ap().value("A-B").def("A-B"), + }, + }, { name: "sometags", dir: "sometags", diff --git a/testdata/resourceclosure/main.tf b/testdata/resourceclosure/main.tf new file mode 100644 index 0000000..7aa7c12 --- /dev/null +++ b/testdata/resourceclosure/main.tf @@ -0,0 +1,239 @@ +// 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 = { + source = "coder/coder" + version = "2.4.0-pre0" + } + docker = { + source = "kreuzwerker/docker" + version = "3.0.2" + } + } +} + +// --- Parameter-reached resources ------------------------------------------- +resource "docker_image" "base" { + name = "large" +} + +resource "docker_image" "pool" { + count = 2 + 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 +} + +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_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 + } +} + +// --- Orphan: reachable from no target, pruned, must not affect outputs ----- +resource "docker_container" "orphan" { + name = "orphan" + image = "does-not-exist" +} + +// --- Reference shapes beyond a plain attribute traversal ------------------ +// Each of these reaches a resource through an expression form the pruner has +// to see through. If any is missed, the parameter default goes unknown. + +// Conditional and try(): both operands are references. +variable "pick_alt" { + type = bool + default = true +} + +resource "docker_image" "alt" { + name = "alt-large" +} + +data "coder_parameter" "picked" { + name = "picked" + type = "string" + default = var.pick_alt ? docker_image.alt.name : docker_image.base.name +} + +data "coder_parameter" "tried" { + name = "tried" + type = "string" + default = try(docker_image.alt.name, "fallback") +} + +// Index that is itself an expression, not a literal. +variable "idx" { + type = number + default = 1 +} + +data "coder_parameter" "varindexed" { + name = "varindexed" + type = "string" + default = docker_image.pool[var.idx].name +} + +// --- References inside nested blocks ---------------------------------------- +// A static option block and a dynamic option block both reach a resource. +data "coder_parameter" "staticopt" { + name = "staticopt" + type = "string" + form_type = "dropdown" + default = docker_image.base.name + option { + name = docker_image.base.name + value = docker_image.base.name + } +} + +data "coder_parameter" "dynopts" { + name = "dynopts" + type = "string" + form_type = "dropdown" + default = "poolimg" + dynamic "option" { + for_each = toset(docker_image.pool[*].name) + content { + name = option.value + value = option.value + } + } +} + +// --- Meta-argument on a target reads a resource ----------------------------- +resource "docker_image" "gate" { + name = "gate" +} + +data "coder_parameter" "gated" { + count = docker_image.gate.name == "gate" ? 1 : 0 + name = "gated" + type = "string" + default = "on" +} + +// --- Data source as intermediary: param -> data -> resource ----------------- +data "docker_registry_image" "viaresource" { + name = docker_image.base.name +} + +data "coder_parameter" "viadata" { + name = "viadata" + type = "string" + default = data.docker_registry_image.viaresource.name +} + +// --- Tag -> resource -> parameter ------------------------------------------- +// The common direction (resource reads a parameter) combined with the resource +// also being in a target's closure. It must be kept and evaluate after the +// parameter it reads. +resource "docker_image" "fromparam" { + name = "derived-${data.coder_parameter.flavor.value}" +} + +data "coder_workspace_tags" "derived" { + tags = { + derived = docker_image.fromparam.name + } +} + +// --- Preset nested block reads a resource ----------------------------------- +data "coder_workspace_preset" "pre" { + name = "pre" + parameters = { + flavor = "large" + } + prebuilds { + instances = length(docker_image.pool) + } +} + +// --- Module: root resource flows in through an input; the module's own +// resource is untouched by pruning ------------------------------------------- +module "sub" { + source = "./submodule" + image_name = docker_image.base.name +} + +data "coder_parameter" "viamodule" { + name = "viamodule" + type = "string" + default = module.sub.image_name +} + +data "coder_parameter" "modresource" { + name = "modresource" + type = "string" + default = module.sub.internal_name +} 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 diff --git a/testdata/resourceclosure/submodule/main.tf b/testdata/resourceclosure/submodule/main.tf new file mode 100644 index 0000000..427b6e0 --- /dev/null +++ b/testdata/resourceclosure/submodule/main.tf @@ -0,0 +1,17 @@ +// Submodules are never pruned; only root resources are. This module receives a +// root resource's value through an input and also owns a resource of its own. +variable "image_name" { + type = string +} + +resource "docker_image" "internal" { + name = "sub-${var.image_name}" +} + +output "image_name" { + value = var.image_name +} + +output "internal_name" { + value = docker_image.internal.name +} diff --git a/testdata/resourceclosurejson/main.tf.json b/testdata/resourceclosurejson/main.tf.json new file mode 100644 index 0000000..5612d22 --- /dev/null +++ b/testdata/resourceclosurejson/main.tf.json @@ -0,0 +1,26 @@ +{ + "terraform": { + "required_providers": { + "coder": { "source": "coder/coder", "version": "2.4.0-pre0" }, + "docker": { "source": "kreuzwerker/docker", "version": "3.0.2" } + } + }, + "resource": { + "docker_image": { + "first": { "name": "A" }, + "second": { "name": "B" } + } + }, + "locals": { + "joined": "${docker_image.first.name}-${docker_image.second.name}" + }, + "data": { + "coder_parameter": { + "joined": { + "name": "joined", + "type": "string", + "default": "${local.joined}" + } + } + } +} diff --git a/testdata/resourceclosurejson/skipe2e b/testdata/resourceclosurejson/skipe2e new file mode 100644 index 0000000..5e15859 --- /dev/null +++ b/testdata/resourceclosurejson/skipe2e @@ -0,0 +1 @@ +closure logic is exercised by Test_Extract (static preview eval); real terraform apply is out of scope here