Files
opentf/internal/engine/planning/plan_eval_glue.go
Martin Atkins 468d66678d states: Separate SyncState method for removing "full" objects
Our new language runtime uses a set of new methods on SyncState to work
with its preferred "full" representation of resource instance objects, but
those are implemented in terms of methods that already existed for the old
runtime's benefit and so we need to deal with some quirks of those existing
methods.

One such quirk is that the operations to write or remove objects also want
to update some resource-level and instance-level metadata as a side-effect,
and we need to carry through that metadata even when we're intending to
completely remove a resource instance object.

To preserve our goal of leaving the existing codepaths untouched for now,
this pushes a little complexity back up into the main caller in the apply
engine, forcing it to call a different method when it knows it has deleted
an object. That new method then only takes the metadata we need and not
an actual resource instance object, so it gels better with the underlying
ModuleState methods it's implemented in terms of.

Hopefully in the long run we'll rethink the state models to not rely on
these hidden side-effects, but that's beyond the scope of our current phase
of work on the new language runtime.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2026-03-11 07:28:09 -07:00

348 lines
14 KiB
Go

// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package planning
import (
"context"
"fmt"
"iter"
"log"
"github.com/zclconf/go-cty/cty"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/collections"
"github.com/opentofu/opentofu/internal/lang/eval"
"github.com/opentofu/opentofu/internal/providers"
"github.com/opentofu/opentofu/internal/states"
"github.com/opentofu/opentofu/internal/tfdiags"
)
// planGlue is our implementation of [eval.PlanGlue], which the evaluation
// system uses to help the planning engine drive the planning process forward
// as it learns information from the configuration.
//
// The methods of this type can all be called concurrently with themselves and
// each other, so they must use appropriate synchronization to avoid races.
type planGlue struct {
planCtx *planContext
oracle *eval.PlanningOracle
}
var _ eval.PlanGlue = (*planGlue)(nil)
// I'm not sure that this belongs here
func (p *planGlue) ValidateProviderConfig(ctx context.Context, provider addrs.Provider, configVal cty.Value) tfdiags.Diagnostics {
return p.planCtx.providers.ValidateProviderConfig(ctx, provider, configVal)
}
// PlanDesiredResourceInstance implements eval.PlanGlue.
//
// This is called each time the evaluation system discovers a new resource
// instance in the configuration, and there are likely to be multiple calls
// active concurrently and so this function must take care to avoid races.
func (p *planGlue) PlanDesiredResourceInstance(ctx context.Context, inst *eval.DesiredResourceInstance) (cty.Value, tfdiags.Diagnostics) {
log.Printf("[TRACE] planContext: planning desired resource instance %s", inst.Addr)
// The details of how we plan vary considerably depending on the resource
// mode, so we'll dispatch each one to a separate function after we've
// dealt with some common preparation work.
var obj *resourceInstanceObject
var diags tfdiags.Diagnostics
switch mode := inst.Addr.Resource.Resource.Mode; mode {
case addrs.ManagedResourceMode:
obj, diags = p.planDesiredManagedResourceInstance(ctx, inst)
case addrs.DataResourceMode:
obj, diags = p.planDesiredDataResourceInstance(ctx, inst)
case addrs.EphemeralResourceMode:
obj, diags = p.planDesiredEphemeralResourceInstance(ctx, inst)
default:
// We should not get here because the cases above should always be
// exhaustive for all of the valid resource modes.
diags = diags.Append(fmt.Errorf("the planning engine does not support %s; this is a bug in OpenTofu", mode))
return cty.DynamicVal, diags
}
p.planCtx.resourceInstObjs.Put(obj)
return obj.ResultValue(), diags
}
func (p *planGlue) planOrphanResourceInstance(ctx context.Context, addr addrs.AbsResourceInstance, state *states.ResourceInstanceObjectFullSrc) tfdiags.Diagnostics {
log.Printf("[TRACE] planContext: planning orphan resource instance %s", addr)
var obj *resourceInstanceObject
var diags tfdiags.Diagnostics
switch mode := addr.Resource.Resource.Mode; mode {
case addrs.ManagedResourceMode:
obj, diags = p.planOrphanManagedResourceInstance(ctx, addr, state)
case addrs.DataResourceMode:
obj, diags = p.planOrphanDataResourceInstance(ctx, addr, state)
case addrs.EphemeralResourceMode:
// It should not be possible for an ephemeral resource to be an
// orphan because ephemeral resources should never be persisted
// in a state snapshot.
diags = diags.Append(fmt.Errorf("unexpected ephemeral resource instance %s in prior state; this is a bug in OpenTofu", addr))
return diags
default:
// We should not get here because the cases above should always be
// exhaustive for all of the valid resource modes.
diags = diags.Append(fmt.Errorf("the planning engine does not support %s; this is a bug in OpenTofu", mode))
return diags
}
p.planCtx.resourceInstObjs.Put(obj)
return diags
}
func (p *planGlue) planDeposedResourceInstanceObject(ctx context.Context, addr addrs.AbsResourceInstance, deposedKey states.DeposedKey, state *states.ResourceInstanceObjectFullSrc) tfdiags.Diagnostics {
log.Printf("[TRACE] planContext: planning deposed resource instance object %s %s", addr, deposedKey)
if addr.Resource.Resource.Mode != addrs.ManagedResourceMode {
// Should not be possible because only managed resource instances
// support "replace" and so nothing else can have deposed objects.
var diags tfdiags.Diagnostics
diags = diags.Append(fmt.Errorf("deposed object for non-managed resource instance %s; this is a bug in OpenTofu", addr))
return diags
}
obj, diags := p.planDeposedManagedResourceInstanceObject(ctx, addr, deposedKey, state)
p.planCtx.resourceInstObjs.Put(obj)
return diags
}
// PlanModuleCallInstanceOrphans implements eval.PlanGlue.
func (p *planGlue) PlanModuleCallInstanceOrphans(ctx context.Context, moduleCallAddr addrs.AbsModuleCall, desiredInstances iter.Seq[addrs.InstanceKey]) tfdiags.Diagnostics {
if moduleCallAddr.Module.IsPlaceholder() {
// can't predict anything about what might be desired or orphaned
// under this module instance.
// FIXME: _Something_ still needs to make sure we call
// p.planCtx.reportResourceInstancePlanCompletion for any
// potentially-matching instances in the previous round state, because
// nothing in the desired state is going to match them and so they
// won't actually get planned.
return nil
}
desiredSet := collections.CollectSet(desiredInstances)
for key := range desiredSet {
if _, ok := key.(addrs.WildcardKey); ok {
// can't predict what instances are desired for this module call
return nil
}
}
orphaned := resourceInstancesFilter(p.planCtx.prevRoundState, func(instAddr addrs.AbsResourceInstance) bool {
// This should return true for any resource instance in the given
// module instance that belongs to a module call not included in
// desiredCalls, and false otherwise.
if instAddr.Module.IsRoot() {
// A resource in the root module cannot possibly belong to a
// module call.
return false
}
instCallerModuleInstAddr, instModuleCallInstance := instAddr.Module.CallInstance()
if !instCallerModuleInstAddr.Equal(moduleCallAddr.Module) {
return false // not in the relevant calling module instance
}
if !instModuleCallInstance.Call.Equal(moduleCallAddr.Call) {
return false // not in the relevant module call
}
if desiredSet.Has(instModuleCallInstance.Key) {
return false
}
return true
})
var diags tfdiags.Diagnostics
for addr, state := range orphaned {
diags = diags.Append(
p.planOrphanResourceInstance(ctx, addr, state),
)
}
return diags
}
// PlanModuleCallOrphans implements eval.PlanGlue.
func (p *planGlue) PlanModuleCallOrphans(ctx context.Context, callerModuleInstAddr addrs.ModuleInstance, desiredCalls iter.Seq[addrs.ModuleCall]) tfdiags.Diagnostics {
if callerModuleInstAddr.IsPlaceholder() {
// can't predict anything about what might be desired or orphaned
// under this module instance.
// FIXME: _Something_ still needs to make sure we call
// p.planCtx.reportResourceInstancePlanCompletion for any
// potentially-matching instances in the previous round state, because
// nothing in the desired state is going to match them and so they
// won't actually get planned.
return nil
}
desiredSet := addrs.CollectSet(desiredCalls)
orphaned := resourceInstancesFilter(p.planCtx.prevRoundState, func(instAddr addrs.AbsResourceInstance) bool {
// This should return true for any resource instance in the given
// module instance that belongs to a module call not included in
// desiredCalls, and false otherwise.
if instAddr.Module.IsRoot() {
// A resource in the root module cannot possibly belong to a
// module call.
return false
}
instCallerModuleInstAddr, instModuleCall := instAddr.Module.Call()
if !instCallerModuleInstAddr.Equal(callerModuleInstAddr) {
return false // not in the relevant module instance
}
if desiredSet.Has(instModuleCall) {
return false
}
return true
})
var diags tfdiags.Diagnostics
for addr, state := range orphaned {
diags = diags.Append(
p.planOrphanResourceInstance(ctx, addr, state),
)
}
return diags
}
// PlanResourceInstanceOrphans implements eval.PlanGlue.
func (p *planGlue) PlanResourceInstanceOrphans(ctx context.Context, resourceAddr addrs.AbsResource, desiredInstances iter.Seq[addrs.InstanceKey]) tfdiags.Diagnostics {
if resourceAddr.IsPlaceholder() {
// can't predict anything about what might be desired or orphaned
// under this resource.
// FIXME: _Something_ still needs to make sure we call
// p.planCtx.reportResourceInstancePlanCompletion for any
// potentially-matching instances in the previous round state, because
// nothing in the desired state is going to match them and so they
// won't actually get planned.
return nil
}
desiredSet := collections.CollectSet(desiredInstances)
for key := range desiredSet {
if _, ok := key.(addrs.WildcardKey); ok {
// can't predict what instances are desired for this resource
return nil
}
}
orphaned := resourceInstancesFilter(p.planCtx.prevRoundState, func(instAddr addrs.AbsResourceInstance) bool {
// This should return true for any resource instance in the given
// module instance that belongs to a resource not included in
// desiredResources, and false otherwise.
if !instAddr.Module.Equal(resourceAddr.Module) {
return false // not in the relevant module instance
}
if !instAddr.Resource.Resource.Equal(resourceAddr.Resource) {
return false // not in the relevant resource
}
if desiredSet.Has(instAddr.Resource.Key) {
return false
}
return true
})
var diags tfdiags.Diagnostics
for addr, state := range orphaned {
diags = diags.Append(
p.planOrphanResourceInstance(ctx, addr, state),
)
}
return diags
}
// PlanResourceOrphans implements eval.PlanGlue.
func (p *planGlue) PlanResourceOrphans(ctx context.Context, moduleInstAddr addrs.ModuleInstance, desiredResources iter.Seq[addrs.Resource]) tfdiags.Diagnostics {
if moduleInstAddr.IsPlaceholder() {
// can't predict anything about what might be desired or orphaned
// under this resource instance.
// FIXME: _Something_ still needs to make sure we call
// p.planCtx.reportResourceInstancePlanCompletion for any
// potentially-matching instances in the previous round state, because
// nothing in the desired state is going to match them and so they
// won't actually get planned.
return nil
}
desiredSet := addrs.CollectSet(desiredResources)
orphaned := resourceInstancesFilter(p.planCtx.prevRoundState, func(addr addrs.AbsResourceInstance) bool {
// This should return true for any resource instance in the given
// module instance that belongs to a resource not included in
// desiredResources, and false otherwise.
if !addr.Module.Equal(moduleInstAddr) {
return false // not in the relevant module instance
}
if desiredSet.Has(addr.Resource.Resource) {
return false
}
return true
})
var diags tfdiags.Diagnostics
for addr, state := range orphaned {
diags = diags.Append(
p.planOrphanResourceInstance(ctx, addr, state),
)
}
return diags
}
// ProviderClient returns a client for the requested provider instance, launching
// and configuring the provider first if no caller has previously requested a
// client for this instance.
//
// Returns nil if the configuration for the requested provider instance is too
// invalid to actually configure it. The diagnostics for such a problem would
// be reported by our main [ConfigInstance.DrivePlanning] call but the caller
// of this function will probably want to return a more specialized error saying
// that the corresponding resource cannot be planned because its associated
// provider has an invalid configuration.
func (p *planGlue) providerClient(ctx context.Context, addr addrs.AbsProviderInstanceCorrect) (providers.Configured, tfdiags.Diagnostics) {
return p.planCtx.providerInstances.ProviderClient(ctx, addr, p)
}
func (p *planGlue) desiredResourceInstanceMustBeDeferred(inst *eval.DesiredResourceInstance) bool {
// There are various reasons why we might need to defer final planning
// of this to a later round. The following is not exhaustive but is a
// placeholder to show where deferral might fit in.
return inst.IsPlaceholder() || inst.ProviderInstance == nil || derivedFromDeferredVal(inst.ConfigVal)
}
// resourceInstancesFilter returns a sequence of resource instances from the
// given state whose addresses caused the "want" function to return true.
//
// This is an inefficient way to implement detection of "orphans" with our
// current state model. If we decide to adopt a design like this then we
// should adopt a different representation of state which uses a tree structure
// where we can efficiently scan over subtrees that match a particular prefix,
// rather than always scanning over everything.
func resourceInstancesFilter(state *states.State, want func(addrs.AbsResourceInstance) bool) iter.Seq2[addrs.AbsResourceInstance, *states.ResourceInstanceObjectFullSrc] {
return func(yield func(addrs.AbsResourceInstance, *states.ResourceInstanceObjectFullSrc) bool) {
for _, modState := range state.Modules {
for _, resourceState := range modState.Resources {
for instKey, instanceState := range resourceState.Instances {
if instanceState.Current == nil {
// Only the current object for a resource instance
// can be an "orphan". (Deposed objects are handled
// elsewhere.)
continue
}
instAddr := resourceState.Addr.Instance(instKey)
if !want(instAddr) {
continue
}
// We currently have a schism where we do all of the
// discovery work using the traditional state model but
// we then switch to using our new-style "full" object model
// to act on what we've discovered. This is hopefully just
// a temporary situation while we're operating in a mixed
// world where most of the system doesn't know about the
// new runtime yet.
objState := state.SyncWrapper().ResourceInstanceObjectFull(instAddr.CurrentObject())
if objState == nil {
// If we get here then there's a bug in the
// ResourceInstanceObjectFull function, because we
// should only be here if instAddr corresponds to a
// to an instance with a current object.
panic(fmt.Sprintf("state has %s, but ResourceInstanceObjectFull didn't return it", instAddr))
}
if !yield(instAddr, objState) {
return
}
}
}
}
}
}