From 430e9c81a2d501fc33774c7f5f470701446aef2f Mon Sep 17 00:00:00 2001 From: Samsondeen Dare Date: Wed, 12 Aug 2026 17:04:57 +0200 Subject: [PATCH 1/3] test: Set only non-null ephemeral variables for apply time operations --- internal/command/test_test.go | 10 ++++++++-- .../testdata/test/ephemeral_input/main.tf | 11 +++++++++++ .../command/testdata/test/no_state/main.tf | 8 +++----- .../testdata/test/no_state/main.tftest.hcl | 6 ++---- internal/command/testing/test_provider.go | 11 +++++++++++ internal/moduletest/graph/apply.go | 11 +++++++++-- internal/terraform/context_apply.go | 19 +++++++++++++------ internal/terraform/context_plan.go | 4 ++++ 8 files changed, 61 insertions(+), 19 deletions(-) diff --git a/internal/command/test_test.go b/internal/command/test_test.go index c5b7d233a5bd..dceeb7cae562 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -189,8 +189,8 @@ func TestTest_Runs(t *testing.T) { }, "no_state": { expectedOut: []string{"0 passed, 1 failed"}, - expectedErr: []string{"No value for required variable"}, - description: "the run apply fails, causing it to produce a nil state.", + expectedErr: []string{"apply_fail is set to true"}, + description: "the run apply fails, causing it to produce a nil state. We expect a failure, but no panic", code: 1, }, "variables": { @@ -359,6 +359,12 @@ func TestTest_Runs(t *testing.T) { expectedOut: []string{"2 passed, 0 failed."}, code: 0, }, + "ephemeral_input_optional_unset": { + override: "ephemeral_input", + expectedOut: []string{"2 passed, 0 failed."}, + args: []string{"-var=foo=\"triple\""}, + code: 0, + }, "ephemeral_input_with_error": { expectedOut: []string{"Error message refers to ephemeral values", "1 passed, 1 failed."}, expectedErr: []string{"Test assertion failed", diff --git a/internal/command/testdata/test/ephemeral_input/main.tf b/internal/command/testdata/test/ephemeral_input/main.tf index a0c5391a0d40..2ce0de8646e3 100644 --- a/internal/command/testdata/test/ephemeral_input/main.tf +++ b/internal/command/testdata/test/ephemeral_input/main.tf @@ -2,6 +2,17 @@ variable "foo" { ephemeral = true type = string } +variable "bar" { + ephemeral = true + default = null + type = string +} + +resource "test_resource" "bar" { + write_only = var.bar +} + + output "value" { value = "Hello, World!" } diff --git a/internal/command/testdata/test/no_state/main.tf b/internal/command/testdata/test/no_state/main.tf index 4a8ea3c79714..d1783bd60915 100644 --- a/internal/command/testdata/test/no_state/main.tf +++ b/internal/command/testdata/test/no_state/main.tf @@ -3,12 +3,10 @@ variable "input" { type = number } -variable "input2" { - type = number - ephemeral = true - default = 0 +resource "test_resource" "my_resource" { + apply_fail = true } output "output" { - value = var.input > 5 ? var.input : null + value = var.input } diff --git a/internal/command/testdata/test/no_state/main.tftest.hcl b/internal/command/testdata/test/no_state/main.tftest.hcl index bb175c177eed..0a6a1d40b5ba 100644 --- a/internal/command/testdata/test/no_state/main.tftest.hcl +++ b/internal/command/testdata/test/no_state/main.tftest.hcl @@ -4,10 +4,8 @@ run "first" { input = 2 } -// var.input2 is ephemeral, and this would cause it to not be set during the plan phase, -// but will be set to default during the apply phase. This leads to the apply update being null assert { - condition = output.output == var.input2 - error_message = "output should have been null" + condition = output.output != var.input + error_message = "condition should fail" } } diff --git a/internal/command/testing/test_provider.go b/internal/command/testing/test_provider.go index f1a8e95f9751..9eb60f360aa3 100644 --- a/internal/command/testing/test_provider.go +++ b/internal/command/testing/test_provider.go @@ -37,6 +37,7 @@ var ( "value": {Type: cty.String, Optional: true}, "interrupt_count": {Type: cty.Number, Optional: true}, "destroy_fail": {Type: cty.Bool, Optional: true, Computed: true}, + "apply_fail": {Type: cty.Bool, Optional: true}, "create_wait_seconds": {Type: cty.Number, Optional: true}, "destroy_wait_seconds": {Type: cty.Number, Optional: true}, "write_only": {Type: cty.String, Optional: true, WriteOnly: true}, @@ -60,6 +61,7 @@ var ( "interrupt_count": {Type: cty.Number, Computed: true}, "destroy_fail": {Type: cty.Bool, Computed: true}, + "apply_fail": {Type: cty.Bool, Optional: true}, "create_wait_seconds": {Type: cty.Number, Computed: true}, "destroy_wait_seconds": {Type: cty.Number, Computed: true}, "defer": {Type: cty.Bool, Computed: true}, @@ -337,6 +339,15 @@ func (provider *TestProvider) ApplyResourceChange(request providers.ApplyResourc resource = cty.ObjectVal(vals) } + if applyFail := resource.GetAttr("apply_fail"); !applyFail.IsNull() && applyFail.IsKnown() && applyFail.True() { + var diags tfdiags.Diagnostics + diags = diags.Append(fmt.Errorf("apply_fail is set to true")) + return providers.ApplyResourceChangeResponse{ + NewState: cty.NilVal, + Diagnostics: diags, + } + } + provider.Store.Put(provider.GetResourceKey(id.AsString()), resource) return providers.ApplyResourceChangeResponse{ NewState: resource, diff --git a/internal/moduletest/graph/apply.go b/internal/moduletest/graph/apply.go index 07d352db1764..a68e1fa86e90 100644 --- a/internal/moduletest/graph/apply.go +++ b/internal/moduletest/graph/apply.go @@ -142,15 +142,22 @@ func apply(tfCtx *terraform.Context, run *configs.TestRun, module *configs.Confi created = append(created, change) } - // We only need to pass ephemeral variables to the apply operation, as the + // We only need to pass ephemeral variables that are needed to the apply operation, as the // plan has already been evaluated with the full set of variables. ephemeralVariables := make(terraform.InputValues) for k, v := range module.Root.Module.Variables { - if v.EphemeralSet { + // [plan.ApplyTimeVariables] only contains ephemeral variables whose value are non-null. + // These are the only ones we need to set during the apply operation. + // The only way for such a variable to obtain values beyond this point + // is via defaults, and for that, we can count on the defaults being + // resolved in the core graph later on, so we do not need to include + // them here. + if v.EphemeralSet && plan.ApplyTimeVariables.Has(k) { if value, ok := variables[k]; ok { ephemeralVariables[k] = value } } + } applyOpts := &terraform.ApplyOpts{ diff --git a/internal/terraform/context_apply.go b/internal/terraform/context_apply.go index 5f9ec52d17be..bf1a52340aac 100644 --- a/internal/terraform/context_apply.go +++ b/internal/terraform/context_apply.go @@ -278,6 +278,8 @@ Note that the -target option is not suitable for routine use, and is provided on return newState, evalScope, diags } +// checkApplyTimeVariables checks that the ephemeral variables needed in the configuration +// are also set during apply. Variables that are not needed should not be set at all. func checkApplyTimeVariables(needed collections.Set[string], gotValues InputValues, config *configs.Config) tfdiags.Diagnostics { var diags tfdiags.Diagnostics for name := range needed.All() { @@ -294,16 +296,21 @@ func checkApplyTimeVariables(needed collections.Set[string], gotValues InputValu )) } } - for name := range gotValues { + for name, value := range gotValues { if !needed.Has(name) { // We'll treat this a little differently depending on whether // the variable is declared as ephemeral or not. if vc, ok := config.Module.Variables[name]; ok && vc.Ephemeral { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "No value for required variable", - fmt.Sprintf("The ephemeral input variable %q was not set during the plan phase, and so must remain unset during the apply phase.", name), - )) + + // Only non-null ephemeral variables are recorded in the plan as needed, + // therefore we can treat a supplied null value here if it was not set + if !value.Value.IsNull() { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "No value for required variable", + fmt.Sprintf("The ephemeral input variable %q was not set during the plan phase, and so must remain unset during the apply phase.", name), + )) + } } else { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, diff --git a/internal/terraform/context_plan.go b/internal/terraform/context_plan.go index 770117a68d22..fcdfa8977b4a 100644 --- a/internal/terraform/context_plan.go +++ b/internal/terraform/context_plan.go @@ -57,6 +57,10 @@ type PlanOpts struct { // by the user who is requesting the run, prior to any normalization or // substitution of defaults. See the documentation for the InputValue // type for more information on how to correctly populate this. + // Variables that are declared but not set are also included here, + // but their values are set to nil, so that Terraform Core + // can later subst substitute a default if available, or generate an error + // if not. SetVariables InputValues // If Targets has a non-zero length then it activates targeted planning From 6cb4091c86df33354b08c04cb5231d045bfc571c Mon Sep 17 00:00:00 2001 From: Samsondeen Dare Date: Wed, 12 Aug 2026 17:36:30 +0200 Subject: [PATCH 2/3] add changelog --- .changes/v1.17/BUG FIXES-20260812-172344.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/v1.17/BUG FIXES-20260812-172344.yaml diff --git a/.changes/v1.17/BUG FIXES-20260812-172344.yaml b/.changes/v1.17/BUG FIXES-20260812-172344.yaml new file mode 100644 index 000000000000..953697432c8a --- /dev/null +++ b/.changes/v1.17/BUG FIXES-20260812-172344.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: 'test: Optional ephemeral values do not have to be set at plan time' +time: 2026-08-12T17:23:44.947966+02:00 +custom: + Issue: "38974" From fd2b40d3d827a7363802a06c60b48aff55d32179 Mon Sep 17 00:00:00 2001 From: Samsondeen Dare Date: Wed, 12 Aug 2026 17:48:22 +0200 Subject: [PATCH 3/3] fix tests --- internal/command/test_test.go | 1 + .../valid-use-local-backend/with-prior-state/terraform.tfstate | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/command/test_test.go b/internal/command/test_test.go index dceeb7cae562..e38d464a155b 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -5162,6 +5162,7 @@ test_resource_id = %s`, resourceId, resourceId) "write_only": cty.NullVal(cty.String), "create_wait_seconds": cty.NullVal(cty.Number), "destroy_fail": cty.False, + "apply_fail": cty.False, "destroy_wait_seconds": cty.NullVal(cty.Number), "defer": cty.NullVal(cty.Bool), })}, diff --git a/internal/command/testdata/test/valid-use-local-backend/with-prior-state/terraform.tfstate b/internal/command/testdata/test/valid-use-local-backend/with-prior-state/terraform.tfstate index bc5577bac99e..c4527248e153 100644 --- a/internal/command/testdata/test/valid-use-local-backend/with-prior-state/terraform.tfstate +++ b/internal/command/testdata/test/valid-use-local-backend/with-prior-state/terraform.tfstate @@ -25,6 +25,7 @@ "attributes": { "create_wait_seconds": null, "destroy_fail": false, + "apply_fail": false, "destroy_wait_seconds": null, "id": "53d69028-477d-7ba0-83c3-ff3807e3756f", "interrupt_count": null, @@ -38,4 +39,4 @@ } ], "check_results": null -} \ No newline at end of file +}