diff --git a/.changes/v1.14/BUG FIXES-20251024-164434.yaml b/.changes/v1.14/BUG FIXES-20251024-164434.yaml new file mode 100644 index 000000000000..c2fa4a88cfd9 --- /dev/null +++ b/.changes/v1.14/BUG FIXES-20251024-164434.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: 'test: allow ephemeral outputs in root modules' +time: 2025-10-24T16:44:34.197847+02:00 +custom: + Issue: "37813" diff --git a/.changes/v1.14/BUG FIXES-20251024-164448.yaml b/.changes/v1.14/BUG FIXES-20251024-164448.yaml new file mode 100644 index 000000000000..c4dd15c95278 --- /dev/null +++ b/.changes/v1.14/BUG FIXES-20251024-164448.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: 'stacks: allow ephemeral outputs in root modules' +time: 2025-10-24T16:44:48.264142+02:00 +custom: + Issue: "37813" diff --git a/internal/command/test_test.go b/internal/command/test_test.go index 7d8e6a91c5b4..fbf9272dc4ed 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -409,6 +409,9 @@ func TestTest_Runs(t *testing.T) { expectedOut: []string{"3 passed, 0 failed."}, code: 0, }, + "ephemeral_output": { + code: 0, + }, "no-tests": { code: 0, }, diff --git a/internal/command/testdata/test/ephemeral_output/main.tf b/internal/command/testdata/test/ephemeral_output/main.tf new file mode 100644 index 000000000000..6c92380f3345 --- /dev/null +++ b/internal/command/testdata/test/ephemeral_output/main.tf @@ -0,0 +1,8 @@ +variable "foo" { + ephemeral = true + type = string +} +output "value" { + value = var.foo + ephemeral = true +} diff --git a/internal/command/testdata/test/ephemeral_output/main.tftest.hcl b/internal/command/testdata/test/ephemeral_output/main.tftest.hcl new file mode 100644 index 000000000000..f687dfd87a26 --- /dev/null +++ b/internal/command/testdata/test/ephemeral_output/main.tftest.hcl @@ -0,0 +1,5 @@ +run "validate_ephemeral_input" { + variables { + foo = "whaaat" + } +} diff --git a/internal/configs/config.go b/internal/configs/config.go index 08828ce62646..f9cef3eb68a4 100644 --- a/internal/configs/config.go +++ b/internal/configs/config.go @@ -1039,6 +1039,9 @@ func (c *Config) EffectiveRequiredProviderConfigs() addrs.Map[addrs.RootProvider for _, rc := range c.Module.Actions { maybePutLocal(rc.ProviderConfigAddr(), false) } + for _, rc := range c.Module.EphemeralResources { + maybePutLocal(rc.ProviderConfigAddr(), false) + } for _, ic := range c.Module.Import { if ic.ProviderConfigRef != nil { maybePutLocal(addrs.LocalProviderConfig{ diff --git a/internal/moduletest/graph/apply.go b/internal/moduletest/graph/apply.go index 4c2e308e2ce3..0c9dfa21e9eb 100644 --- a/internal/moduletest/graph/apply.go +++ b/internal/moduletest/graph/apply.go @@ -154,8 +154,9 @@ func apply(tfCtx *terraform.Context, run *configs.TestRun, module *configs.Confi } applyOpts := &terraform.ApplyOpts{ - SetVariables: ephemeralVariables, - ExternalProviders: providers, + SetVariables: ephemeralVariables, + ExternalProviders: providers, + AllowRootEphemeralOutputs: true, } waiter.update(tfCtx, progress, created) diff --git a/internal/moduletest/graph/node_state_cleanup.go b/internal/moduletest/graph/node_state_cleanup.go index 973f11cf5972..96072bbe1cc4 100644 --- a/internal/moduletest/graph/node_state_cleanup.go +++ b/internal/moduletest/graph/node_state_cleanup.go @@ -126,13 +126,14 @@ func (n *NodeStateCleanup) restore(ctx *EvalContext, file *configs.TestFile, run setVariables, _, _ := FilterVariablesToModule(module, variables) planOpts := &terraform.PlanOpts{ - Mode: plans.NormalMode, - SetVariables: setVariables, - Overrides: mocking.PackageOverrides(run, file, mocks), - ExternalProviders: providers, - SkipRefresh: true, - OverridePreventDestroy: true, - DeferralAllowed: ctx.deferralAllowed, + Mode: plans.NormalMode, + SetVariables: setVariables, + Overrides: mocking.PackageOverrides(run, file, mocks), + ExternalProviders: providers, + SkipRefresh: true, + OverridePreventDestroy: true, + DeferralAllowed: ctx.deferralAllowed, + AllowRootEphemeralOutputs: true, } tfCtx, _ := terraform.NewContext(n.opts.ContextOpts) @@ -177,13 +178,14 @@ func (n *NodeStateCleanup) destroy(ctx *EvalContext, file *configs.TestFile, run setVariables, _, _ := FilterVariablesToModule(module, variables) planOpts := &terraform.PlanOpts{ - Mode: plans.DestroyMode, - SetVariables: setVariables, - Overrides: mocking.PackageOverrides(run, file, mocks), - ExternalProviders: providers, - SkipRefresh: true, - OverridePreventDestroy: true, - DeferralAllowed: ctx.deferralAllowed, + Mode: plans.DestroyMode, + SetVariables: setVariables, + Overrides: mocking.PackageOverrides(run, file, mocks), + ExternalProviders: providers, + SkipRefresh: true, + OverridePreventDestroy: true, + DeferralAllowed: ctx.deferralAllowed, + AllowRootEphemeralOutputs: true, } tfCtx, _ := terraform.NewContext(n.opts.ContextOpts) diff --git a/internal/moduletest/graph/node_test_run.go b/internal/moduletest/graph/node_test_run.go index 3845b65d8431..39413f383dcb 100644 --- a/internal/moduletest/graph/node_test_run.go +++ b/internal/moduletest/graph/node_test_run.go @@ -193,7 +193,8 @@ func (n *NodeTestRun) testValidate(providers map[addrs.RootProviderConfig]provid } waiter.update(tfCtx, moduletest.Running, nil) validateDiags := tfCtx.Validate(config, &terraform.ValidateOpts{ - ExternalProviders: providers, + ExternalProviders: providers, + AllowRootEphemeralOutputs: true, }) run.Diagnostics = run.Diagnostics.Append(validateDiags) if validateDiags.HasErrors() { diff --git a/internal/moduletest/graph/plan.go b/internal/moduletest/graph/plan.go index 29bb441eb76f..17eebf233127 100644 --- a/internal/moduletest/graph/plan.go +++ b/internal/moduletest/graph/plan.go @@ -127,14 +127,15 @@ func plan(ctx *EvalContext, tfCtx *terraform.Context, file *configs.TestFile, ru return plans.NormalMode } }(), - Targets: targets, - ForceReplace: replaces, - SkipRefresh: !run.Options.Refresh, - SetVariables: variables, - ExternalReferences: references, - ExternalProviders: providers, - Overrides: mocking.PackageOverrides(run, file, mocks), - DeferralAllowed: ctx.deferralAllowed, + Targets: targets, + ForceReplace: replaces, + SkipRefresh: !run.Options.Refresh, + SetVariables: variables, + ExternalReferences: references, + ExternalProviders: providers, + Overrides: mocking.PackageOverrides(run, file, mocks), + DeferralAllowed: ctx.deferralAllowed, + AllowRootEphemeralOutputs: true, } waiter.update(tfCtx, moduletest.Running, nil) diff --git a/internal/stacks/stackruntime/apply_test.go b/internal/stacks/stackruntime/apply_test.go index e220a7abd9ba..afe427e36ae0 100644 --- a/internal/stacks/stackruntime/apply_test.go +++ b/internal/stacks/stackruntime/apply_test.go @@ -2229,6 +2229,69 @@ After applying this plan, Terraform will no longer manage these objects. You wil }, }, }, + "ephemeral-module-outputs": { + path: "ephemeral-module-output", + cycles: []TestCycle{ + { + wantPlannedChanges: []stackplan.PlannedChange{ + &stackplan.PlannedChangeApplyable{ + Applyable: true, + }, + &stackplan.PlannedChangeComponentInstance{ + Addr: mustAbsComponentInstance("component.ephemeral_in"), + PlanApplyable: false, + PlanComplete: true, + Action: plans.Create, + RequiredComponents: collections.NewSet(mustAbsComponent("component.ephemeral_out")), + PlannedInputValues: make(map[string]plans.DynamicValue), + PlannedOutputValues: make(map[string]cty.Value), + PlannedCheckResults: new(states.CheckResults), + PlanTimestamp: fakePlanTimestamp, + }, + &stackplan.PlannedChangeComponentInstance{ + Addr: mustAbsComponentInstance("component.ephemeral_out"), + PlanApplyable: false, + PlanComplete: true, + Action: plans.Create, + PlannedInputValues: make(map[string]plans.DynamicValue), + PlannedOutputValues: map[string]cty.Value{ + "value": cty.DynamicVal, // ephemeral + }, + PlannedCheckResults: new(states.CheckResults), + PlanTimestamp: fakePlanTimestamp, + }, + &stackplan.PlannedChangeHeader{ + TerraformVersion: version.SemVer, + }, + &stackplan.PlannedChangePlannedTimestamp{ + PlannedTimestamp: fakePlanTimestamp, + }, + }, + wantAppliedChanges: []stackstate.AppliedChange{ + &stackstate.AppliedChangeComponentInstance{ + ComponentAddr: mustAbsComponent("component.ephemeral_in"), + ComponentInstanceAddr: mustAbsComponentInstance("component.ephemeral_in"), + Dependencies: collections.NewSet[stackaddrs.AbsComponent]( + mustAbsComponent("component.ephemeral_out"), + ), + OutputValues: make(map[addrs.OutputValue]cty.Value), + InputVariables: map[addrs.InputVariable]cty.Value{ + mustInputVariable("input"): cty.UnknownVal(cty.String), // ephemeral + }, + }, + &stackstate.AppliedChangeComponentInstance{ + ComponentAddr: mustAbsComponent("component.ephemeral_out"), + ComponentInstanceAddr: mustAbsComponentInstance("component.ephemeral_out"), + Dependents: collections.NewSet[stackaddrs.AbsComponent]( + mustAbsComponent("component.ephemeral_in"), + ), + OutputValues: make(map[addrs.OutputValue]cty.Value), + InputVariables: make(map[addrs.InputVariable]cty.Value), + }, + }, + }, + }, + }, } for name, tc := range tcs { diff --git a/internal/stacks/stackruntime/internal/stackeval/component_config.go b/internal/stacks/stackruntime/internal/stackeval/component_config.go index 330b52244e39..14e5cd443a21 100644 --- a/internal/stacks/stackruntime/internal/stackeval/component_config.go +++ b/internal/stacks/stackruntime/internal/stackeval/component_config.go @@ -378,7 +378,8 @@ func (c *ComponentConfig) checkValid(ctx context.Context, phase EvalPhase) tfdia }() diags = diags.Append(tfCtx.Validate(moduleTree, &terraform.ValidateOpts{ - ExternalProviders: providerClients, + ExternalProviders: providerClients, + AllowRootEphemeralOutputs: true, })) return diags, nil }) diff --git a/internal/stacks/stackruntime/internal/stackeval/component_instance.go b/internal/stacks/stackruntime/internal/stackeval/component_instance.go index bf79aebfdb2c..e7761d1bbb4c 100644 --- a/internal/stacks/stackruntime/internal/stackeval/component_instance.go +++ b/internal/stacks/stackruntime/internal/stackeval/component_instance.go @@ -171,6 +171,8 @@ func (c *ComponentInstance) PlanOpts(ctx context.Context, mode plans.Mode, skipR ExternalProviders: providerClients, ExternalDependencyDeferred: c.deferred, DeferralAllowed: true, + AllowRootEphemeralOutputs: true, + // We want the same plantimestamp between all components and the stacks language ForcePlanTimestamp: &plantimestamp, }, nil diff --git a/internal/stacks/stackruntime/internal/stackeval/removed_component_config.go b/internal/stacks/stackruntime/internal/stackeval/removed_component_config.go index 93bee6d26cd7..2c5517e34fb4 100644 --- a/internal/stacks/stackruntime/internal/stackeval/removed_component_config.go +++ b/internal/stacks/stackruntime/internal/stackeval/removed_component_config.go @@ -196,7 +196,8 @@ func (r *RemovedComponentConfig) CheckValid(ctx context.Context, phase EvalPhase }() diags = diags.Append(tfCtx.Validate(moduleTree, &terraform.ValidateOpts{ - ExternalProviders: providerClients, + ExternalProviders: providerClients, + AllowRootEphemeralOutputs: true, })) return diags, nil }) diff --git a/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go b/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go index f0641d6d1b48..b735928b4841 100644 --- a/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go +++ b/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go @@ -135,6 +135,7 @@ func (r *RemovedComponentInstance) ModuleTreePlan(ctx context.Context) (*plans.P DeferralAllowed: true, ExternalDependencyDeferred: deferred, Forget: forget, + AllowRootEphemeralOutputs: true, // We want the same plantimestamp between all components and the stacks language ForcePlanTimestamp: &plantimestamp, diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/ephemeral-module-output/ephemeral-input/ephemeral-input.tf b/internal/stacks/stackruntime/testdata/mainbundle/test/ephemeral-module-output/ephemeral-input/ephemeral-input.tf new file mode 100644 index 000000000000..84c447d90e34 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/ephemeral-module-output/ephemeral-input/ephemeral-input.tf @@ -0,0 +1,5 @@ + +variable "input" { + type = string + ephemeral = true +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/ephemeral-module-output/ephemeral-module-output.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/ephemeral-module-output/ephemeral-module-output.tfcomponent.hcl new file mode 100644 index 000000000000..d3f0eadb1dbd --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/ephemeral-module-output/ephemeral-module-output.tfcomponent.hcl @@ -0,0 +1,28 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +provider "testing" "main" {} + +component "ephemeral_out" { + source = "./ephemeral-output" + + providers = { + testing = provider.testing.main + } +} + +component "ephemeral_in" { + source = "./ephemeral-input" + + providers = { + testing = provider.testing.main + } + + inputs = { + input = component.ephemeral_out.value + } +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/ephemeral-module-output/ephemeral-output/ephemeral-output.tf b/internal/stacks/stackruntime/testdata/mainbundle/test/ephemeral-module-output/ephemeral-output/ephemeral-output.tf new file mode 100644 index 000000000000..399371e0fb46 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/ephemeral-module-output/ephemeral-output/ephemeral-output.tf @@ -0,0 +1,7 @@ + +ephemeral "testing_resource" "resource" {} + +output "value" { + value = ephemeral.testing_resource.resource.value + ephemeral = true +} diff --git a/internal/stacks/stackruntime/testing/provider.go b/internal/stacks/stackruntime/testing/provider.go index 210d50b143fa..f84db65cc4f3 100644 --- a/internal/stacks/stackruntime/testing/provider.go +++ b/internal/stacks/stackruntime/testing/provider.go @@ -28,6 +28,17 @@ var ( }, } + TestingEphemeralResourceSchema = providers.Schema{ + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Computed: true, + }, + }, + }, + } + DeferredResourceSchema = providers.Schema{ Body: &configschema.Block{ Attributes: map[string]*configschema.Attribute{ @@ -199,6 +210,11 @@ func NewProviderWithData(t *testing.T, store *ResourceStore) *MockProvider { Body: WriteOnlyDataSourceSchema.Body, }, }, + EphemeralResourceTypes: map[string]providers.Schema{ + "testing_resource": { + Body: TestingEphemeralResourceSchema.Body, + }, + }, Functions: map[string]providers.FunctionDecl{ "echo": { Parameters: []providers.FunctionParam{ @@ -299,6 +315,13 @@ func NewProviderWithData(t *testing.T, store *ResourceStore) *MockProvider { Result: request.Arguments[0], } }, + OpenEphemeralResourceFn: func(request providers.OpenEphemeralResourceRequest) providers.OpenEphemeralResourceResponse { + return providers.OpenEphemeralResourceResponse{ + Result: cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("secret"), + }), + } + }, }, ResourceStore: store, } diff --git a/internal/terraform/context_apply.go b/internal/terraform/context_apply.go index 0918e3c60851..dc894b075510 100644 --- a/internal/terraform/context_apply.go +++ b/internal/terraform/context_apply.go @@ -39,6 +39,13 @@ type ApplyOpts struct { // values that were declared as ephemeral, because all other input // values must retain the values that were specified during planning. SetVariables InputValues + + // AllowRootEphemeralOutputs overrides a specific check made within the + // output nodes that they cannot be ephemeral at within root modules. This + // should be set to true for plans executing from within either the stacks + // or test runtimes, where the root modules as Terraform sees them aren't + // the actual root modules. + AllowRootEphemeralOutputs bool } // ApplyOpts creates an [ApplyOpts] with copies of all of the elements that @@ -52,7 +59,8 @@ type ApplyOpts struct { // as in test cases. func (po *PlanOpts) ApplyOpts() *ApplyOpts { return &ApplyOpts{ - ExternalProviders: po.ExternalProviders, + ExternalProviders: po.ExternalProviders, + AllowRootEphemeralOutputs: po.AllowRootEphemeralOutputs, } } @@ -292,6 +300,10 @@ func checkApplyTimeVariables(needed collections.Set[string], gotValues InputValu func (c *Context) applyGraph(plan *plans.Plan, config *configs.Config, opts *ApplyOpts, validate bool) (*Graph, walkOperation, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics + if opts == nil { + opts = new(ApplyOpts) + } + variables := InputValues{} for name, dyVal := range plan.VariableValues { val, err := dyVal.Decode(cty.DynamicPseudoType) @@ -316,10 +328,8 @@ func (c *Context) applyGraph(plan *plans.Plan, config *configs.Config, opts *App // FIXME: We should check that all of these match declared variables and // that all of them are declared as ephemeral, because all non-ephemeral // variables are supposed to come exclusively from plan.VariableValues. - if opts != nil { - for n, vv := range opts.SetVariables { - variables[n] = vv - } + for n, vv := range opts.SetVariables { + variables[n] = vv } if diags.HasErrors() { return nil, walkApply, diags @@ -352,26 +362,22 @@ func (c *Context) applyGraph(plan *plans.Plan, config *configs.Config, opts *App operation = walkDestroy } - var externalProviderConfigs map[addrs.RootProviderConfig]providers.Interface - if opts != nil { - externalProviderConfigs = opts.ExternalProviders - } - graph, moreDiags := (&ApplyGraphBuilder{ - Config: config, - Changes: plan.Changes, - DeferredChanges: plan.DeferredResources, - State: plan.PriorState, - RootVariableValues: variables, - ExternalProviderConfigs: externalProviderConfigs, - Plugins: c.plugins, - Targets: plan.TargetAddrs, - ActionTargets: plan.ActionTargetAddrs, - ForceReplace: plan.ForceReplaceAddrs, - Operation: operation, - ExternalReferences: plan.ExternalReferences, - Overrides: plan.Overrides, - SkipGraphValidation: c.graphOpts.SkipGraphValidation, + Config: config, + Changes: plan.Changes, + DeferredChanges: plan.DeferredResources, + State: plan.PriorState, + RootVariableValues: variables, + ExternalProviderConfigs: opts.ExternalProviders, + Plugins: c.plugins, + Targets: plan.TargetAddrs, + ActionTargets: plan.ActionTargetAddrs, + ForceReplace: plan.ForceReplaceAddrs, + Operation: operation, + ExternalReferences: plan.ExternalReferences, + Overrides: plan.Overrides, + SkipGraphValidation: c.graphOpts.SkipGraphValidation, + AllowRootEphemeralOutputs: opts.AllowRootEphemeralOutputs, }).Build(addrs.RootModuleInstance) diags = diags.Append(moreDiags) if moreDiags.HasErrors() { diff --git a/internal/terraform/context_plan.go b/internal/terraform/context_plan.go index 87d00f2d8ba9..d6861f8fc2fd 100644 --- a/internal/terraform/context_plan.go +++ b/internal/terraform/context_plan.go @@ -153,6 +153,13 @@ type PlanOpts struct { // attribute is set. This can only be set during a destroy plan, and should // only be set during the test command. OverridePreventDestroy bool + + // AllowRootEphemeralOutputs overrides a specific check made within the + // output nodes that they cannot be ephemeral at within root modules. This + // should be set to true for plans executing from within either the stacks + // or test runtimes, where the root modules as Terraform sees them aren't + // the actual root modules. + AllowRootEphemeralOutputs bool } // Plan generates an execution plan by comparing the given configuration @@ -992,57 +999,60 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State, return nil, walkPlan, diags } graph, diags := (&PlanGraphBuilder{ - Config: config, - State: prevRunState, - RootVariableValues: opts.SetVariables, - ExternalProviderConfigs: externalProviderConfigs, - Plugins: c.plugins, - Targets: opts.Targets, - ForceReplace: opts.ForceReplace, - skipRefresh: opts.SkipRefresh, - preDestroyRefresh: opts.PreDestroyRefresh, - Operation: walkPlan, - ExternalReferences: opts.ExternalReferences, - Overrides: opts.Overrides, - ImportTargets: c.findImportTargets(config), - forgetResources: forgetResources, - forgetModules: forgetModules, - GenerateConfigPath: opts.GenerateConfigPath, - SkipGraphValidation: c.graphOpts.SkipGraphValidation, - queryPlan: opts.Query, - overridePreventDestroy: opts.OverridePreventDestroy, + Config: config, + State: prevRunState, + RootVariableValues: opts.SetVariables, + ExternalProviderConfigs: externalProviderConfigs, + Plugins: c.plugins, + Targets: opts.Targets, + ForceReplace: opts.ForceReplace, + skipRefresh: opts.SkipRefresh, + preDestroyRefresh: opts.PreDestroyRefresh, + Operation: walkPlan, + ExternalReferences: opts.ExternalReferences, + Overrides: opts.Overrides, + ImportTargets: c.findImportTargets(config), + forgetResources: forgetResources, + forgetModules: forgetModules, + GenerateConfigPath: opts.GenerateConfigPath, + SkipGraphValidation: c.graphOpts.SkipGraphValidation, + queryPlan: opts.Query, + overridePreventDestroy: opts.OverridePreventDestroy, + AllowRootEphemeralOutputs: opts.AllowRootEphemeralOutputs, }).Build(addrs.RootModuleInstance) return graph, walkPlan, diags case plans.RefreshOnlyMode: graph, diags := (&PlanGraphBuilder{ - Config: config, - State: prevRunState, - RootVariableValues: opts.SetVariables, - ExternalProviderConfigs: externalProviderConfigs, - Plugins: c.plugins, - Targets: append(opts.Targets, opts.ActionTargets...), - ActionTargets: opts.ActionTargets, - skipRefresh: opts.SkipRefresh, - skipPlanChanges: true, // this activates "refresh only" mode. - Operation: walkPlan, - ExternalReferences: opts.ExternalReferences, - Overrides: opts.Overrides, - SkipGraphValidation: c.graphOpts.SkipGraphValidation, + Config: config, + State: prevRunState, + RootVariableValues: opts.SetVariables, + ExternalProviderConfigs: externalProviderConfigs, + Plugins: c.plugins, + Targets: append(opts.Targets, opts.ActionTargets...), + ActionTargets: opts.ActionTargets, + skipRefresh: opts.SkipRefresh, + skipPlanChanges: true, // this activates "refresh only" mode. + Operation: walkPlan, + ExternalReferences: opts.ExternalReferences, + Overrides: opts.Overrides, + SkipGraphValidation: c.graphOpts.SkipGraphValidation, + AllowRootEphemeralOutputs: opts.AllowRootEphemeralOutputs, }).Build(addrs.RootModuleInstance) return graph, walkPlan, diags case plans.DestroyMode: graph, diags := (&PlanGraphBuilder{ - Config: config, - State: prevRunState, - RootVariableValues: opts.SetVariables, - ExternalProviderConfigs: externalProviderConfigs, - Plugins: c.plugins, - Targets: opts.Targets, - skipRefresh: opts.SkipRefresh, - Operation: walkPlanDestroy, - Overrides: opts.Overrides, - SkipGraphValidation: c.graphOpts.SkipGraphValidation, - overridePreventDestroy: opts.OverridePreventDestroy, + Config: config, + State: prevRunState, + RootVariableValues: opts.SetVariables, + ExternalProviderConfigs: externalProviderConfigs, + Plugins: c.plugins, + Targets: opts.Targets, + skipRefresh: opts.SkipRefresh, + Operation: walkPlanDestroy, + Overrides: opts.Overrides, + SkipGraphValidation: c.graphOpts.SkipGraphValidation, + overridePreventDestroy: opts.OverridePreventDestroy, + AllowRootEphemeralOutputs: opts.AllowRootEphemeralOutputs, }).Build(addrs.RootModuleInstance) return graph, walkPlanDestroy, diags default: diff --git a/internal/terraform/context_validate.go b/internal/terraform/context_validate.go index d154590afccc..af484a579535 100644 --- a/internal/terraform/context_validate.go +++ b/internal/terraform/context_validate.go @@ -37,6 +37,13 @@ type ValidateOpts struct { // When true, query files will also be validated. Query bool + + // AllowRootEphemeralOutputs overrides a specific check made within the + // output nodes that they cannot be ephemeral at within root modules. This + // should be set to true for plans executing from within either the stacks + // or test runtimes, where the root modules as Terraform sees them aren't + // the actual root modules. + AllowRootEphemeralOutputs bool } // Validate performs semantic validation of a configuration, and returns @@ -101,14 +108,15 @@ func (c *Context) Validate(config *configs.Config, opts *ValidateOpts) tfdiags.D } graph, moreDiags := (&PlanGraphBuilder{ - Config: config, - Plugins: c.plugins, - State: states.NewState(), - RootVariableValues: varValues, - Operation: walkValidate, - ExternalProviderConfigs: opts.ExternalProviders, - ImportTargets: c.findImportTargets(config), - queryPlan: opts.Query, + Config: config, + Plugins: c.plugins, + State: states.NewState(), + RootVariableValues: varValues, + Operation: walkValidate, + ExternalProviderConfigs: opts.ExternalProviders, + ImportTargets: c.findImportTargets(config), + queryPlan: opts.Query, + AllowRootEphemeralOutputs: opts.AllowRootEphemeralOutputs, }).Build(addrs.RootModuleInstance) diags = diags.Append(moreDiags) if moreDiags.HasErrors() { diff --git a/internal/terraform/graph_builder_apply.go b/internal/terraform/graph_builder_apply.go index f835b82f41fb..23cc87fafe75 100644 --- a/internal/terraform/graph_builder_apply.go +++ b/internal/terraform/graph_builder_apply.go @@ -81,6 +81,13 @@ type ApplyGraphBuilder struct { // SkipGraphValidation indicates whether the graph builder should skip // validation of the graph. SkipGraphValidation bool + + // AllowRootEphemeralOutputs overrides a specific check made within the + // output nodes that they cannot be ephemeral at within root modules. This + // should be set to true for plans executing from within either the stacks + // or test runtimes, where the root modules as Terraform sees them aren't + // the actual root modules. + AllowRootEphemeralOutputs bool } // See GraphBuilder @@ -137,9 +144,10 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer { &variableValidationTransformer{}, &LocalTransformer{Config: b.Config}, &OutputTransformer{ - Config: b.Config, - Destroying: b.Operation == walkDestroy, - Overrides: b.Overrides, + Config: b.Config, + Destroying: b.Operation == walkDestroy, + Overrides: b.Overrides, + AllowRootEphemeralOutputs: b.AllowRootEphemeralOutputs, }, // Creates all the resource instances represented in the diff, along diff --git a/internal/terraform/graph_builder_plan.go b/internal/terraform/graph_builder_plan.go index 4a395ed7e0b3..435b313137d2 100644 --- a/internal/terraform/graph_builder_plan.go +++ b/internal/terraform/graph_builder_plan.go @@ -125,6 +125,13 @@ type PlanGraphBuilder struct { // allows Terraform to ignore the configuration attribute prevent_destroy // to destroy resources regardless. overridePreventDestroy bool + + // AllowRootEphemeralOutputs overrides a specific check made within the + // output nodes that they cannot be ephemeral at within root modules. This + // should be set to true for plans executing from within either the stacks + // or test runtimes, where the root modules as Terraform sees them aren't + // the actual root modules. + AllowRootEphemeralOutputs bool } // See GraphBuilder @@ -205,10 +212,11 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer { }, &LocalTransformer{Config: b.Config}, &OutputTransformer{ - Config: b.Config, - RefreshOnly: b.skipPlanChanges || b.preDestroyRefresh, - Destroying: b.Operation == walkPlanDestroy, - Overrides: b.Overrides, + Config: b.Config, + RefreshOnly: b.skipPlanChanges || b.preDestroyRefresh, + Destroying: b.Operation == walkPlanDestroy, + Overrides: b.Overrides, + AllowRootEphemeralOutputs: b.AllowRootEphemeralOutputs, // NOTE: We currently treat anything built with the plan graph // builder as "planning" for our purposes here, because we share diff --git a/internal/terraform/node_output.go b/internal/terraform/node_output.go index 93f28d16c36c..98fc1ba7bb93 100644 --- a/internal/terraform/node_output.go +++ b/internal/terraform/node_output.go @@ -40,6 +40,13 @@ type nodeExpandOutput struct { // details. Planning bool + // AllowRootEphemeralOutputs overrides a specific check made within the + // output nodes that they cannot be ephemeral at within root modules. This + // should be set to true for plans executing from within either the stacks + // or test runtimes, where the root modules as Terraform sees them aren't + // the actual root modules. + AllowRootEphemeralOutputs bool + // Overrides is the set of overrides applied by the testing framework. We // may need to override the value for this output and if we do the value // comes from here. @@ -125,14 +132,15 @@ func (n *nodeExpandOutput) DynamicExpand(ctx EvalContext) (*Graph, tfdiags.Diagn default: node = &NodeApplyableOutput{ - Addr: absAddr, - Config: n.Config, - Change: change, - RefreshOnly: n.RefreshOnly, - DestroyApply: n.Destroying, - Planning: n.Planning, - Override: n.getOverrideValue(absAddr.Module), - Dependencies: n.Dependencies, + Addr: absAddr, + Config: n.Config, + Change: change, + RefreshOnly: n.RefreshOnly, + DestroyApply: n.Destroying, + Planning: n.Planning, + Override: n.getOverrideValue(absAddr.Module), + Dependencies: n.Dependencies, + AllowRootEphemeralOutputs: n.AllowRootEphemeralOutputs, } } @@ -280,6 +288,13 @@ type NodeApplyableOutput struct { // Dependencies is the full set of resources that are referenced by this // output. Dependencies []addrs.ConfigResource + + // AllowRootEphemeralOutputs overrides a specific check made within the + // output nodes that they cannot be ephemeral at within root modules. This + // should be set to true for plans executing from within either the stacks + // or test runtimes, where the root modules as Terraform sees them aren't + // the actual root modules. + AllowRootEphemeralOutputs bool } var ( @@ -391,7 +406,7 @@ func (n *NodeApplyableOutput) Execute(ctx EvalContext, op walkOperation) (diags val = n.Change.After } - if n.Addr.Module.IsRoot() && n.Config.Ephemeral { + if (n.Addr.Module.IsRoot() && n.Config.Ephemeral) && !n.AllowRootEphemeralOutputs { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Ephemeral output not allowed", diff --git a/internal/terraform/transform_output.go b/internal/terraform/transform_output.go index 3bcfa5558f55..7e21f131daed 100644 --- a/internal/terraform/transform_output.go +++ b/internal/terraform/transform_output.go @@ -32,6 +32,13 @@ type OutputTransformer struct { // so we need to record that we wish to remove them. Destroying bool + // AllowRootEphemeralOutputs overrides a specific check made within the + // output nodes that they cannot be ephemeral at within root modules. This + // should be set to true for plans executing from within either the stacks + // or test runtimes, where the root modules as Terraform sees them aren't + // the actual root modules. + AllowRootEphemeralOutputs bool + // Overrides supplies the values for any output variables that should be // overridden by the testing framework. Overrides *mocking.Overrides @@ -60,13 +67,14 @@ func (t *OutputTransformer) transform(g *Graph, c *configs.Config) error { addr := addrs.OutputValue{Name: o.Name} node := &nodeExpandOutput{ - Addr: addr, - Module: c.Path, - Config: o, - Destroying: t.Destroying, - RefreshOnly: t.RefreshOnly, - Planning: t.Planning, - Overrides: t.Overrides, + Addr: addr, + Module: c.Path, + Config: o, + Destroying: t.Destroying, + RefreshOnly: t.RefreshOnly, + Planning: t.Planning, + Overrides: t.Overrides, + AllowRootEphemeralOutputs: t.AllowRootEphemeralOutputs, } log.Printf("[TRACE] OutputTransformer: adding %s as %T", o.Name, node)