Allow ephemeral resource in plan during -refresh-only (#3776)

Signed-off-by: Andrei Ciobanu <andrei.ciobanu@opentofu.org>
This commit is contained in:
Andrei Ciobanu 2026-02-18 12:21:02 +02:00 committed by GitHub
parent 70c1ab9be6
commit 3d399a6bb8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 110 additions and 2 deletions

View file

@ -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.

View file

@ -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 {

View file

@ -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")