From 3d399a6bb81d8642be1a13989f810d8da2b64d84 Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Wed, 18 Feb 2026 12:21:02 +0200 Subject: [PATCH] Allow ephemeral resource in plan during `-refresh-only` (#3776) Signed-off-by: Andrei Ciobanu --- internal/plans/changes.go | 17 ++++++ internal/tofu/context_plan.go | 7 ++- internal/tofu/context_plan2_test.go | 88 +++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/internal/plans/changes.go b/internal/plans/changes.go index a8f89b923b..b4890b6a9a 100644 --- a/internal/plans/changes.go +++ b/internal/plans/changes.go @@ -73,6 +73,23 @@ func (c *Changes) Empty() bool { return true } +// ActionableResources returns all the [Changes.Resources] that are changes that would actually +// update the resources. +// This method's main purpose is to exclude from [Changes.Resources] the changes that are +// in the plan strictly for building the graph and are not going to change the resource state. +// In case of [Open] actions, these are needed to build the required ephemeral nodes +// in [DiffTransformer]. +func (c *Changes) ActionableResources() []*ResourceInstanceChangeSrc { + var ret []*ResourceInstanceChangeSrc + for _, r := range c.Resources { + if r.Action == Open { + continue + } + ret = append(ret, r) + } + return ret +} + // ResourceInstance returns the planned change for the current object of the // resource instance of the given address, if any. Returns nil if no change is // planned. diff --git a/internal/tofu/context_plan.go b/internal/tofu/context_plan.go index ba6d1f6dfa..97b7a335b4 100644 --- a/internal/tofu/context_plan.go +++ b/internal/tofu/context_plan.go @@ -386,10 +386,13 @@ func (c *Context) refreshOnlyPlan(ctx context.Context, config *configs.Config, p // to refresh only, the set of resource changes should always be empty. // We'll safety-check that here so we can return a clear message about it, // rather than probably just generating confusing output at the UI layer. - if len(plan.Changes.Resources) != 0 { + // Because the ephemeral resources changes in the plan are meant to be used + // later to build the apply graph, those shouldn't be counted when we are + // doing this check. + if changes := plan.Changes.ActionableResources(); len(changes) != 0 { // Some extra context in the logs in case the user reports this message // as a bug, as a starting point for debugging. - for _, rc := range plan.Changes.Resources { + for _, rc := range changes { if depKey := rc.DeposedKey; depKey == states.NotDeposed { log.Printf("[DEBUG] Refresh-only plan includes %s change for %s", rc.Action, rc.Addr) } else { diff --git a/internal/tofu/context_plan2_test.go b/internal/tofu/context_plan2_test.go index 80e76b902e..8e6a430030 100644 --- a/internal/tofu/context_plan2_test.go +++ b/internal/tofu/context_plan2_test.go @@ -2683,6 +2683,94 @@ func TestContext2Plan_refreshOnlyMode(t *testing.T) { } } +func TestContext2Plan_refreshOnlyMode_ephemeral(t *testing.T) { + addr := mustResourceInstanceAddr("ephemeral.test_object.a") + + // The configuration, the prior state, and the refresh result intentionally + // have different values for "test_string" so we can observe that the + // refresh took effect but the configuration change wasn't considered. + m := testModuleInline(t, map[string]string{ + "main.tf": ` + ephemeral "test_object" "a" { + arg = "after" + } + `, + }) + state := states.NewState() + + p := simpleMockProvider() + p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ + Provider: providers.Schema{Block: simpleTestSchema()}, + EphemeralResources: map[string]providers.Schema{ + "test_object": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "arg": {Type: cty.String, Optional: true}, + }, + }, + }, + }, + } + p.OpenEphemeralResourceFn = func(req providers.OpenEphemeralResourceRequest) providers.OpenEphemeralResourceResponse { + newVal, err := cty.Transform(req.Config, func(path cty.Path, v cty.Value) (cty.Value, error) { + if len(path) == 1 && path[0] == (cty.GetAttrStep{Name: "arg"}) { + return cty.StringVal("current"), nil + } + return v, nil + }) + if err != nil { + // shouldn't get here + t.Fatalf("OpenResourceFn transform failed") + return providers.OpenEphemeralResourceResponse{} + } + return providers.OpenEphemeralResourceResponse{ + Result: newVal, + } + } + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(context.Background(), m, state, &PlanOpts{ + Mode: plans.RefreshOnlyMode, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors\n%s", diags.Err().Error()) + } + + if !p.OpenEphemeralResourceCalled { + t.Errorf("Provider's OpenEphemeralResource wasn't called; should've been") + } + + if got, want := len(plan.Changes.Resources), 1; got != want { + t.Fatalf("expected to have exactly %d resource but got %d", want, got) + } + if gotResAddr := plan.Changes.Resources[0].Addr; !gotResAddr.Equal(addr) { + t.Errorf("plan contains one resource and that's NOT an ephemeral as expected; instead, got %s", gotResAddr) + } + if got, want := len(plan.Changes.ActionableResources()), 0; got != want { + t.Errorf( + "changes.ActionableResources() returned more than %d resources, meaning that didn't exclude ephemeral resources. Instead returned %d\nChanges:\n%s", + want, + got, + spew.Sdump(plan.Changes.Resources), + ) + } + + if instState := plan.PlannedState.ResourceInstance(addr); instState == nil { + t.Errorf("%s has no planned state, but it should have since it's needed to build the apply graph correctly", addr) + } else { + want := `{"arg":"current"}` + got := string(instState.Current.AttrsJSON) + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("unexpected attributes for the planned ephemeral:\n%s", diff) + } + } +} + func TestContext2Plan_refreshOnlyMode_deposed(t *testing.T) { addr := mustResourceInstanceAddr("test_object.a") deposedKey := states.DeposedKey("byebye")