diff --git a/internal/backend/backendrun/operation.go b/internal/backend/backendrun/operation.go index 10ec63e64c..38ac3b0839 100644 --- a/internal/backend/backendrun/operation.go +++ b/internal/backend/backendrun/operation.go @@ -237,6 +237,12 @@ type RunningOperation struct { // this state is managed by the backend. This should only be read // after the operation completes to avoid read/write races. State *states.State + + // EphemeralOutputValues is populated only after an Apply operation + // completes, and contains the value for each ephemeral output in the root + // module. + // Ephemeral output values are not stored in the state file. + EphemeralOutputValues map[string]*states.OutputValue } // OperationResult describes the result status of an operation. diff --git a/internal/backend/local/backend_apply.go b/internal/backend/local/backend_apply.go index eeeaa1a2d0..37e02468b5 100644 --- a/internal/backend/local/backend_apply.go +++ b/internal/backend/local/backend_apply.go @@ -376,6 +376,8 @@ func (b *Local) opApply( return } + runningOp.EphemeralOutputValues = applyState.EphemeralRootOutputValues + // Store the final state runningOp.State = applyState err := statemgr.WriteAndPersist(opState, applyState, schemas) diff --git a/internal/backend/local/backend_local_test.go b/internal/backend/local/backend_local_test.go index 788886b83d..22cc754133 100644 --- a/internal/backend/local/backend_local_test.go +++ b/internal/backend/local/backend_local_test.go @@ -266,6 +266,10 @@ func (s *stateStorageThatFailsRefresh) GetRootOutputValues(ctx context.Context) return nil, fmt.Errorf("unimplemented") } +func (s *stateStorageThatFailsRefresh) GetEphemeralRootOutputValues(ctx context.Context) (map[string]*states.OutputValue, error) { + return nil, fmt.Errorf("unimplemented") +} + func (s *stateStorageThatFailsRefresh) WriteState(*states.State) error { return fmt.Errorf("unimplemented") } diff --git a/internal/cloud/state.go b/internal/cloud/state.go index 36fa4dcf2f..eef2ec8efd 100644 --- a/internal/cloud/state.go +++ b/internal/cloud/state.go @@ -598,6 +598,11 @@ func (s *State) GetRootOutputValues(ctx context.Context) (map[string]*states.Out return result, nil } +func (s *State) GetEphemeralRootOutputValues(ctx context.Context) (map[string]*states.OutputValue, error) { + // NOTE Ephemeral output values are not yet supported by the cloud backend. + return nil, nil +} + func clamp(val, min, max int64) int64 { if val < min { return min diff --git a/internal/command/jsonformat/state.go b/internal/command/jsonformat/state.go index f7780def85..de3c83f491 100644 --- a/internal/command/jsonformat/state.go +++ b/internal/command/jsonformat/state.go @@ -85,7 +85,6 @@ func (state State) renderHumanStateModule(renderer Renderer, module jsonstate.Mo } func (state State) renderHumanStateOutputs(renderer Renderer, opts computed.RenderHumanOpts) { - if len(state.RootModuleOutputs) > 0 { renderer.Streams.Printf("\n\nOutputs:\n\n") diff --git a/internal/command/jsonstate/state.go b/internal/command/jsonstate/state.go index f77da0e036..3ff0b8cca9 100644 --- a/internal/command/jsonstate/state.go +++ b/internal/command/jsonstate/state.go @@ -221,6 +221,12 @@ func MarshalOutputs(outputs map[string]*states.OutputValue) (map[string]Output, ret := make(map[string]Output) for k, v := range outputs { + + if v.Ephemeral { + // should never happen + panic(fmt.Sprintf("Ephemeral output value %s passed to state.MarshalOutputs. This is a bug in Terraform - please report it.", k)) + } + ty := v.Value.Type() ov, err := ctyjson.Marshal(v.Value, ty) if err != nil { diff --git a/internal/command/output.go b/internal/command/output.go index 9b4bf9d7c3..37a4eb19cf 100644 --- a/internal/command/output.go +++ b/internal/command/output.go @@ -7,6 +7,8 @@ import ( "fmt" "strings" + "maps" + "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/states" @@ -89,12 +91,17 @@ func (c *OutputCommand) Outputs(statePath string) (map[string]*states.OutputValu return nil, diags } - output, err := stateStore.GetRootOutputValues(ctx) + outputs, err := stateStore.GetRootOutputValues(ctx) + if err != nil { + return nil, diags.Append(err) + } + ephemeralOutputs, err := stateStore.GetEphemeralRootOutputValues(ctx) if err != nil { return nil, diags.Append(err) } + maps.Copy(outputs, ephemeralOutputs) - return output, diags + return outputs, diags } func (c *OutputCommand) Help() string { diff --git a/internal/command/output_test.go b/internal/command/output_test.go index 28bbc6d1f2..3d3f6a97b4 100644 --- a/internal/command/output_test.go +++ b/internal/command/output_test.go @@ -80,7 +80,43 @@ func TestOutput_json(t *testing.T) { } actual := strings.TrimSpace(output.Stdout()) - expected := "{\n \"foo\": {\n \"sensitive\": false,\n \"type\": \"string\",\n \"value\": \"bar\"\n }\n}" + expected := "{\n \"foo\": {\n \"ephemeral\": false,\n \"sensitive\": false,\n \"type\": \"string\",\n \"value\": \"bar\"\n }\n}" + if actual != expected { + t.Fatalf("wrong output\ngot: %#v\nwant: %#v", actual, expected) + } +} + +func TestOutput_jsonEphemeral(t *testing.T) { + originalState := states.BuildState(func(s *states.SyncState) { + s.SetEphemeralOutputValue( + addrs.OutputValue{Name: "foo"}.Absolute(addrs.RootModuleInstance), + cty.StringVal("bar"), + false, + ) + }) + + statePath := testStateFile(t, originalState) + + view, done := testView(t) + c := &OutputCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + View: view, + }, + } + + args := []string{ + "-state", statePath, + "-json", + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: \n%s", output.Stderr()) + } + + actual := strings.TrimSpace(output.Stdout()) + expected := "{\n \"foo\": {\n \"ephemeral\": true,\n \"sensitive\": false,\n \"type\": \"string\",\n \"value\": null\n }\n}" if actual != expected { t.Fatalf("wrong output\ngot: %#v\nwant: %#v", actual, expected) } diff --git a/internal/command/refresh_test.go b/internal/command/refresh_test.go index 403d97ca40..854920d498 100644 --- a/internal/command/refresh_test.go +++ b/internal/command/refresh_test.go @@ -559,7 +559,7 @@ func TestRefresh_backup(t *testing.T) { statePath := testStateFile(t, state) // Output path - outf, err := ioutil.TempFile(td, "tf") + outf, err := os.CreateTemp(td, "tf") if err != nil { t.Fatalf("err: %s", err) } @@ -574,7 +574,7 @@ func TestRefresh_backup(t *testing.T) { } // Backup path - backupf, err := ioutil.TempFile(td, "tf") + backupf, err := os.CreateTemp(td, "tf") if err != nil { t.Fatalf("err: %s", err) } diff --git a/internal/command/views/output.go b/internal/command/views/output.go index c7fdee2712..4eb8ed4694 100644 --- a/internal/command/views/output.go +++ b/internal/command/views/output.go @@ -217,7 +217,11 @@ func (v *OutputJSON) Output(name string, outputs map[string]*states.OutputValue) // show in the single value case. We must now maintain that behavior // for compatibility, so this is an emulation of the JSON // serialization of outputs used in state format version 3. + // + // Note that when running the output command, the value of an ephemeral + // output is always nil and its type is always cty.DynamicPseudoType. type OutputMeta struct { + Ephemeral bool `json:"ephemeral"` Sensitive bool `json:"sensitive"` Type json.RawMessage `json:"type"` Value json.RawMessage `json:"value"` @@ -236,6 +240,7 @@ func (v *OutputJSON) Output(name string, outputs map[string]*states.OutputValue) return diags } outputMetas[n] = OutputMeta{ + Ephemeral: os.Ephemeral, Sensitive: os.Sensitive, Type: json.RawMessage(jsonType), Value: json.RawMessage(jsonVal), diff --git a/internal/command/views/output_test.go b/internal/command/views/output_test.go index cb59b6937c..3132a0afdb 100644 --- a/internal/command/views/output_test.go +++ b/internal/command/views/output_test.go @@ -149,6 +149,7 @@ foo = arguments.ViewJSON, `{ "bar": { + "ephemeral": false, "sensitive": false, "type": [ "list", @@ -161,6 +162,7 @@ foo = ] }, "baz": { + "ephemeral": false, "sensitive": false, "type": [ "object", @@ -175,6 +177,7 @@ foo = } }, "foo": { + "ephemeral": false, "sensitive": true, "type": "string", "value": "secret" diff --git a/internal/states/output_value.go b/internal/states/output_value.go index 98a3606c22..f59b1b5942 100644 --- a/internal/states/output_value.go +++ b/internal/states/output_value.go @@ -16,4 +16,5 @@ type OutputValue struct { Addr addrs.AbsOutputValue Value cty.Value Sensitive bool + Ephemeral bool } diff --git a/internal/states/remote/state.go b/internal/states/remote/state.go index 34fbbc638c..6d13411418 100644 --- a/internal/states/remote/state.go +++ b/internal/states/remote/state.go @@ -73,6 +73,19 @@ func (s *State) GetRootOutputValues(ctx context.Context) (map[string]*states.Out return state.RootOutputValues, nil } +func (s *State) GetEphemeralRootOutputValues(ctx context.Context) (map[string]*states.OutputValue, error) { + if err := s.RefreshState(); err != nil { + return nil, fmt.Errorf("Failed to load state: %s", err) + } + + state := s.State() + if state == nil { + state = states.NewState() + } + + return state.EphemeralRootOutputValues, nil +} + // StateForMigration is part of our implementation of statemgr.Migrator. func (s *State) StateForMigration() *statefile.File { s.mu.Lock() diff --git a/internal/states/state.go b/internal/states/state.go index 0fb1e71a82..371c8e177e 100644 --- a/internal/states/state.go +++ b/internal/states/state.go @@ -29,14 +29,21 @@ type State struct { // an implementation detail and must not be used by outside callers. Modules map[string]*Module - // OutputValues contains the state for each output value defined in the - // root module. + // RootOutputValues contains the state for each non-ephemeral output value + // defined in the root module. // // Output values in other modules don't persist anywhere between runs, // so Terraform Core tracks those only internally and does not expose // them in any artifacts that survive between runs. RootOutputValues map[string]*OutputValue + // EphemeralRootOutputValues contains the state for each ephemeral output + // value defined in the root module. + // + // Ephemeral outputs are treated separately from non-ephemeral outputs, to + // ensure that their values are never written to the state file. + EphemeralRootOutputValues map[string]*OutputValue + // CheckResults contains a snapshot of the statuses of checks at the // end of the most recent update to the state. Callers might compare // checks between runs to see if e.g. a previously-failing check has @@ -56,8 +63,9 @@ func NewState() *State { modules := map[string]*Module{} modules[addrs.RootModuleInstance.String()] = NewModule(addrs.RootModuleInstance) return &State{ - Modules: modules, - RootOutputValues: make(map[string]*OutputValue), + Modules: modules, + RootOutputValues: make(map[string]*OutputValue), + EphemeralRootOutputValues: make(map[string]*OutputValue), } } @@ -77,7 +85,7 @@ func (s *State) Empty() bool { if s == nil { return true } - if len(s.RootOutputValues) != 0 { + if len(s.RootOutputValues) != 0 || len(s.EphemeralRootOutputValues) != 0 { return false } for _, ms := range s.Modules { @@ -301,9 +309,9 @@ func (s *State) OutputValue(addr addrs.AbsOutputValue) *OutputValue { // SetOutputValue updates the value stored for the given output value if and // only if it's a root module output value. // -// All other output values will just be silently ignored, because we don't -// store those here anymore. (They live in a namedvals.State object hidden -// in the internals of Terraform Core.) +// All child module output values will just be silently ignored, because we +// don't store those here any more. (They live in a namedvals.State object +// hidden in the internals of Terraform Core.) func (s *State) SetOutputValue(addr addrs.AbsOutputValue, value cty.Value, sensitive bool) { if !addr.Module.IsRoot() { return @@ -323,6 +331,41 @@ func (s *State) RemoveOutputValue(addr addrs.AbsOutputValue) { delete(s.RootOutputValues, addr.OutputValue.Name) } +// EphemeralOutputValue returns the state for the output value with the given +// address, or nil if no such ephemeral output value is tracked in the state. +// +// Only root module output values are tracked in the state, so this always +// returns nil for output values in any other module. +func (s *State) EphemeralOutputValue(addr addrs.AbsOutputValue) *OutputValue { + if !addr.Module.IsRoot() { + return nil + } + return s.EphemeralRootOutputValues[addr.OutputValue.Name] +} + +// SetEphemeralOutputValue updates the value stored for the given ephemeral +// output value if and only if it's a root module output value. +func (s *State) SetEphemeralOutputValue(addr addrs.AbsOutputValue, value cty.Value, sensitive bool) { + if !addr.Module.IsRoot() { + return + } + s.EphemeralRootOutputValues[addr.OutputValue.Name] = &OutputValue{ + Addr: addr, + Value: value, + Sensitive: sensitive, + Ephemeral: true, + } +} + +// RemoveOutputValue removes the record of a previously-stored ephemeral output +// value. +func (s *State) RemoveEphemeralOutputValue(addr addrs.AbsOutputValue) { + if !addr.Module.IsRoot() { + return + } + delete(s.EphemeralRootOutputValues, addr.OutputValue.Name) +} + // ProviderAddrs returns a list of all of the provider configuration addresses // referenced throughout the receiving state. // diff --git a/internal/states/state_deepcopy.go b/internal/states/state_deepcopy.go index 8486e95485..69f7ef907a 100644 --- a/internal/states/state_deepcopy.go +++ b/internal/states/state_deepcopy.go @@ -35,10 +35,15 @@ func (s *State) DeepCopy() *State { for k, v := range s.RootOutputValues { outputValues[k] = v.DeepCopy() } + ephemeralOutputValues := make(map[string]*OutputValue, len(s.EphemeralRootOutputValues)) + for k, v := range s.EphemeralRootOutputValues { + ephemeralOutputValues[k] = v.DeepCopy() + } return &State{ - Modules: modules, - RootOutputValues: outputValues, - CheckResults: s.CheckResults.DeepCopy(), + Modules: modules, + RootOutputValues: outputValues, + EphemeralRootOutputValues: ephemeralOutputValues, + CheckResults: s.CheckResults.DeepCopy(), } } @@ -228,5 +233,6 @@ func (os *OutputValue) DeepCopy() *OutputValue { Addr: os.Addr, Value: os.Value, Sensitive: os.Sensitive, + Ephemeral: os.Ephemeral, } } diff --git a/internal/states/statefile/version4.go b/internal/states/statefile/version4.go index 518f8fb9ec..6cf16f9daa 100644 --- a/internal/states/statefile/version4.go +++ b/internal/states/statefile/version4.go @@ -247,7 +247,7 @@ func prepareStateV4(sV4 *stateV4) (*File, tfdiags.Diagnostics) { ms.SetResourceProvider(rAddr, providerAddr) } - // The root module is special in that we persist its attributes and thus + // The root module is special in that we persist its outputs and thus // need to reload them now. (For descendent modules we just re-calculate // them based on the latest configuration on each run.) { @@ -260,6 +260,7 @@ func prepareStateV4(sV4 *stateV4) (*File, tfdiags.Diagnostics) { }, } os.Sensitive = fos.Sensitive + os.Ephemeral = fos.Ephemeral ty, err := ctyjson.UnmarshalType([]byte(fos.ValueTypeRaw)) if err != nil { @@ -282,7 +283,11 @@ func prepareStateV4(sV4 *stateV4) (*File, tfdiags.Diagnostics) { } os.Value = val - state.RootOutputValues[name] = os + if os.Ephemeral { + state.EphemeralRootOutputValues[name] = os + } else { + state.RootOutputValues[name] = os + } } } @@ -356,6 +361,26 @@ func writeStateV4(file *File, w io.Writer) tfdiags.Diagnostics { } } + // Ephemeral outputs are always saved to the state with a value of null. + for name, eos := range file.State.EphemeralRootOutputValues { + typeSrc, err := ctyjson.MarshalType(eos.Value.Type()) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to serialize output value in state", + fmt.Sprintf("An error occured while serializing the type of output value %q: %s.", name, err), + )) + continue + } + + sV4.RootOutputs[name] = outputStateV4{ + Ephemeral: true, + Sensitive: eos.Sensitive, + ValueRaw: json.RawMessage("null"), + ValueTypeRaw: json.RawMessage(typeSrc), + } + } + for _, ms := range file.State.Modules { moduleAddr := ms.Addr for _, rs := range ms.Resources { @@ -680,6 +705,7 @@ type outputStateV4 struct { ValueRaw json.RawMessage `json:"value"` ValueTypeRaw json.RawMessage `json:"type"` Sensitive bool `json:"sensitive,omitempty"` + Ephemeral bool `json:"ephemeral,omitempty"` } type resourceStateV4 struct { diff --git a/internal/states/statemgr/filesystem.go b/internal/states/statemgr/filesystem.go index 7a7a63e3ca..61f02740b5 100644 --- a/internal/states/statemgr/filesystem.go +++ b/internal/states/statemgr/filesystem.go @@ -251,6 +251,20 @@ func (s *Filesystem) GetRootOutputValues(ctx context.Context) (map[string]*state return state.RootOutputValues, nil } +func (s *Filesystem) GetEphemeralRootOutputValues(ctx context.Context) (map[string]*states.OutputValue, error) { + err := s.RefreshState() + if err != nil { + return nil, err + } + + state := s.State() + if state == nil { + state = states.NewState() + } + + return state.EphemeralRootOutputValues, nil +} + func (s *Filesystem) refreshState() error { var reader io.Reader diff --git a/internal/states/statemgr/lock.go b/internal/states/statemgr/lock.go index 9d34c20415..a05b73ab3c 100644 --- a/internal/states/statemgr/lock.go +++ b/internal/states/statemgr/lock.go @@ -27,6 +27,10 @@ func (s *LockDisabled) GetRootOutputValues(ctx context.Context) (map[string]*sta return s.Inner.GetRootOutputValues(ctx) } +func (s *LockDisabled) GetEphemeralRootOutputValues(ctx context.Context) (map[string]*states.OutputValue, error) { + return s.Inner.GetEphemeralRootOutputValues(ctx) +} + func (s *LockDisabled) WriteState(v *states.State) error { return s.Inner.WriteState(v) } diff --git a/internal/states/statemgr/persistent.go b/internal/states/statemgr/persistent.go index 1e2c82a735..4ebcf56acf 100644 --- a/internal/states/statemgr/persistent.go +++ b/internal/states/statemgr/persistent.go @@ -33,8 +33,13 @@ type Persistent interface { // the output values from it because enhanced backends can apply special permissions // to differentiate reading the state and reading the outputs within the state. type OutputReader interface { - // GetRootOutputValues fetches the root module output values from state or another source + // GetRootOutputValues fetches the non-ephemeral root module output values + // from state or another source. GetRootOutputValues(ctx context.Context) (map[string]*states.OutputValue, error) + + // GetEphemeralRootOutputValues fetches the ephemeral root module output values + // from state or another source. + GetEphemeralRootOutputValues(ctx context.Context) (map[string]*states.OutputValue, error) } // Refresher is the interface for managers that can read snapshots from diff --git a/internal/states/statemgr/statemgr_fake.go b/internal/states/statemgr/statemgr_fake.go index 25800fdbbd..fcc302073e 100644 --- a/internal/states/statemgr/statemgr_fake.go +++ b/internal/states/statemgr/statemgr_fake.go @@ -74,6 +74,10 @@ func (m *fakeFull) GetRootOutputValues(ctx context.Context) (map[string]*states. return m.State().RootOutputValues, nil } +func (m *fakeFull) GetEphemeralRootOutputValues(ctx context.Context) (map[string]*states.OutputValue, error) { + return m.State().EphemeralRootOutputValues, nil +} + func (m *fakeFull) Lock(info *LockInfo) (string, error) { m.lockLock.Lock() defer m.lockLock.Unlock() @@ -124,6 +128,10 @@ func (m *fakeErrorFull) GetRootOutputValues(ctx context.Context) (map[string]*st return nil, errors.New("fake state manager error") } +func (m *fakeErrorFull) GetEphemeralRootOutputValues(ctx context.Context) (map[string]*states.OutputValue, error) { + return nil, errors.New("fake state manager error") +} + func (m *fakeErrorFull) WriteState(s *states.State) error { return errors.New("fake state manager error") } diff --git a/internal/states/sync.go b/internal/states/sync.go index f4cad3e0d6..eac01e323d 100644 --- a/internal/states/sync.go +++ b/internal/states/sync.go @@ -118,6 +118,47 @@ func (s *SyncState) RemoveOutputValue(addr addrs.AbsOutputValue) { s.state.RemoveOutputValue(addr) } +// EphemeralOutputValue returns a snapshot of the state of the ephemeral output +// value with the given address, or nil if no such ephemeral output value is +// tracked. +// +// The return value is a pointer to a copy of the output value state, which the +// caller may then freely access and mutate. +func (s *SyncState) EphemeralOutputValue(addr addrs.AbsOutputValue) *OutputValue { + s.lock.RLock() + ret := s.state.EphemeralOutputValue(addr).DeepCopy() + s.lock.RUnlock() + return ret +} + +// SetEphemeralOutputValue writes a given ephemeral output value into the +// state, overwriting any existing value of the same name. +// +// The state only tracks output values for the root module, so attempts to +// write output values for any other module will be silently ignored. +func (s *SyncState) SetEphemeralOutputValue(addr addrs.AbsOutputValue, value cty.Value, sensitive bool) { + if !addr.Module.IsRoot() { + return + } + + defer s.beginWrite()() + s.state.SetEphemeralOutputValue(addr, value, sensitive) +} + +// RemoveEphemeralOutputValue removes the stored value for the ephemeral output +// value with the given address. +// +// The state only tracks output values for the root module, so attempts to +// remove output values for any other module will be silently ignored. +func (s *SyncState) RemoveEphemeralOutputValue(addr addrs.AbsOutputValue) { + if !addr.Module.IsRoot() { + return + } + + defer s.beginWrite()() + s.state.RemoveEphemeralOutputValue(addr) +} + // Resource returns a snapshot of the state of the resource with the given // address, or nil if no such resource is tracked. // diff --git a/internal/terraform/context_apply_test.go b/internal/terraform/context_apply_test.go index 6cd77bd4d2..90c4b45348 100644 --- a/internal/terraform/context_apply_test.go +++ b/internal/terraform/context_apply_test.go @@ -12273,7 +12273,6 @@ output "out" { if diags.HasErrors() { t.Fatal(diags.ErrWithWarnings()) } - got := state.RootOutputValues["out"].Value want := cty.ObjectVal(map[string]cty.Value{ "required": cty.StringVal("boop"), diff --git a/internal/terraform/evaluate.go b/internal/terraform/evaluate.go index ce905b811b..eb5a7323c4 100644 --- a/internal/terraform/evaluate.go +++ b/internal/terraform/evaluate.go @@ -817,6 +817,7 @@ func (d *evaluationStateData) GetOutput(addr addrs.OutputValue, rng tfdiags.Sour Addr: addr.Absolute(d.ModulePath), Value: cty.NilVal, Sensitive: config.Sensitive, + Ephemeral: config.Ephemeral, } } else if output.Value == cty.NilVal || output.Value.IsNull() { // Then we did get a value but Terraform itself thought it was NilVal @@ -828,6 +829,9 @@ func (d *evaluationStateData) GetOutput(addr addrs.OutputValue, rng tfdiags.Sour if output.Sensitive { val = val.Mark(marks.Sensitive) } + if output.Ephemeral { + val = val.Mark(marks.Ephemeral) + } return val, diags } diff --git a/internal/terraform/node_output.go b/internal/terraform/node_output.go index b21a8ee7e7..7cde997fc1 100644 --- a/internal/terraform/node_output.go +++ b/internal/terraform/node_output.go @@ -10,6 +10,8 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" + "maps" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/dag" @@ -627,6 +629,7 @@ func (n *NodeDestroyableOutput) Execute(ctx EvalContext, op walkOperation) tfdia if n.Addr.Module.IsRoot() && mod != nil { s := state.Lock() rootOutputs := s.RootOutputValues + maps.Copy(rootOutputs, s.EphemeralRootOutputValues) if o, ok := rootOutputs[n.Addr.OutputValue.Name]; ok { sensitiveBefore = o.Sensitive before = o.Value @@ -750,7 +753,10 @@ func (n *NodeApplyableOutput) setValue(namedVals *namedvals.State, state *states // Null outputs must be saved for modules so that they can still be // evaluated. Null root outputs are removed entirely, which is always fine // because they can't be referenced by anything else in the configuration. - if n.Addr.Module.IsRoot() && val.IsNull() { + // + // This does not apply to ephemeral outputs, which always have a value of + // null in the state file. + if n.Addr.Module.IsRoot() && val.IsNull() && !n.Config.Ephemeral { log.Printf("[TRACE] setValue: Removing %s from state (it is now null)", n.Addr) state.RemoveOutputValue(n.Addr) return @@ -770,24 +776,26 @@ func (n *NodeApplyableOutput) setValue(namedVals *namedvals.State, state *states } // Non-ephemeral output values get saved in the state too - if !n.Config.Ephemeral { - // The state itself doesn't represent unknown values, so we null them - // out here and then we'll save the real unknown value in the planned - // changeset, if we have one on this graph walk. - log.Printf("[TRACE] setValue: Saving value for %s in state", n.Addr) - // non-root outputs need to keep sensitive marks for evaluation, but are - // not serialized. - if n.Addr.Module.IsRoot() { - val, _ = val.UnmarkDeep() - if deferred.DependenciesDeferred(n.Dependencies) { - // If the output is from deferred resources then we return a - // simple null value representing that the value is really - // unknown as the dependencies were not properly computed. - val = cty.NullVal(val.Type()) - } else { - val = cty.UnknownAsNull(val) - } + // The state itself doesn't represent unknown values, so we null them + // out here and then we'll save the real unknown value in the planned + // changeset, if we have one on this graph walk. + log.Printf("[TRACE] setValue: Saving value for %s in state", n.Addr) + // non-root outputs need to keep sensitive marks for evaluation, but are + // not serialized. + if n.Addr.Module.IsRoot() { + val, _ = val.UnmarkDeep() + if deferred.DependenciesDeferred(n.Dependencies) { + // If the output is from deferred resources then we return a + // simple null value representing that the value is really + // unknown as the dependencies were not properly computed. + val = cty.NullVal(val.Type()) + } else { + val = cty.UnknownAsNull(val) } + } + if n.Config.Ephemeral { + state.SetEphemeralOutputValue(n.Addr, val, n.Config.Sensitive) + } else { state.SetOutputValue(n.Addr, val, n.Config.Sensitive) } }