diff --git a/internal/engine/planning/plan_eval_glue.go b/internal/engine/planning/plan_eval_glue.go index bd9cdfb189..99cfd28676 100644 --- a/internal/engine/planning/plan_eval_glue.go +++ b/internal/engine/planning/plan_eval_glue.go @@ -16,6 +16,7 @@ import ( "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/collections" "github.com/opentofu/opentofu/internal/lang/eval" + "github.com/opentofu/opentofu/internal/plans/objchange" "github.com/opentofu/opentofu/internal/providers" "github.com/opentofu/opentofu/internal/states" "github.com/opentofu/opentofu/internal/tfdiags" @@ -101,6 +102,11 @@ func (p *planGlue) PlanModuleCallInstanceOrphans(ctx context.Context, moduleCall 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) @@ -146,6 +152,11 @@ func (p *planGlue) PlanModuleCallOrphans(ctx context.Context, callerModuleInstAd 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) @@ -182,6 +193,11 @@ func (p *planGlue) PlanResourceInstanceOrphans(ctx context.Context, resourceAddr 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) @@ -221,6 +237,11 @@ func (p *planGlue) PlanResourceOrphans(ctx context.Context, moduleInstAddr addrs 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) @@ -303,6 +324,38 @@ func (p *planGlue) providerInstanceCompletionEvents(ctx context.Context, addr ad } } +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) +} + +func (p *planGlue) resourceInstancePlaceholderValue(ctx context.Context, providerAddr addrs.Provider, resourceMode addrs.ResourceMode, resourceType string, priorVal, configVal cty.Value) cty.Value { + evalCtx := p.oracle.EvalContext(ctx) + schema, diags := evalCtx.Providers.ResourceTypeSchema(ctx, providerAddr, resourceMode, resourceType) + if diags.HasErrors() { + // If we can't get any schema information then we'll just return + // a completely-unknown object as our placeholder. We should get here + // only if the eval system already failed to use the provider to decode + // or validate the configuration, and so it should already have reported + // a related error upstream. + return cty.DynamicVal + } + + if configVal.IsNull() { + return cty.NullVal(schema.Block.ImpliedType().WithoutOptionalAttributesDeep()) + } + if !configVal.IsKnown() { + return cty.UnknownVal(schema.Block.ImpliedType().WithoutOptionalAttributesDeep()) + } + return objchange.ProposedNew( + schema.Block, + priorVal, + configVal, + ) +} + // resourceInstancesFilter returns a sequence of resource instances from the // given state whose addresses caused the "want" function to return true. // diff --git a/internal/engine/planning/plan_managed.go b/internal/engine/planning/plan_managed.go index 2dc20f8333..42e6a2d46d 100644 --- a/internal/engine/planning/plan_managed.go +++ b/internal/engine/planning/plan_managed.go @@ -7,21 +7,214 @@ package planning import ( "context" + "fmt" "github.com/zclconf/go-cty/cty" "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/lang/eval" + "github.com/opentofu/opentofu/internal/plans" + "github.com/opentofu/opentofu/internal/plans/objchange" + "github.com/opentofu/opentofu/internal/providers" "github.com/opentofu/opentofu/internal/states" "github.com/opentofu/opentofu/internal/tfdiags" ) -func (p *planGlue) planDesiredManagedResourceInstance(ctx context.Context, inst *eval.DesiredResourceInstance) (cty.Value, tfdiags.Diagnostics) { +func (p *planGlue) planDesiredManagedResourceInstance(ctx context.Context, inst *eval.DesiredResourceInstance) (plannedVal cty.Value, diags tfdiags.Diagnostics) { // Regardless of outcome we'll always report that we completed planning. defer p.planCtx.reportResourceInstancePlanCompletion(inst.Addr) - // TODO: Implement - panic("unimplemented") + // 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. + if p.desiredResourceInstanceMustBeDeferred(inst) { + p.planCtx.deferred.Put(inst.Addr, struct{}{}) + defer func() { + // Our result must be marked as deferred, whichever return path + // we leave through. + if plannedVal != cty.NilVal { + plannedVal = deferredVal(plannedVal) + } + }() + // We intentionally continue anyway, because we'll make a best effort + // to produce a speculative plan based on the information we _do_ know + // in case that allows us to detect a problem sooner. The important + // thing is that in the deferred case we won't actually propose any + // planned changes for this resource instance. + } + + evalCtx := p.oracle.EvalContext(ctx) + schema, schemaDiags := evalCtx.Providers.ResourceTypeSchema(ctx, + inst.Provider, + inst.Addr.Resource.Resource.Mode, + inst.Addr.Resource.Resource.Type, + ) + if schemaDiags.HasErrors() { + // We don't return the schema-loading diagnostics directly here because + // they should have already been returned by earlier code, but we do + // return a more specific error to make it clear that this specific + // resource instance was unplannable because of the problem. + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Resource type schema unavailable", + fmt.Sprintf( + "Cannot plan %s because provider %s failed to return the schema for its resource type %q.", + inst.Addr, inst.Provider, inst.Addr.Resource.Resource.Type, + ), + nil, // this error belongs to the whole resource config + )) + return cty.DynamicVal, diags + } + + var prevRoundVal cty.Value + var prevRoundPrivate []byte + prevRoundState := p.planCtx.prevRoundState.ResourceInstance(inst.Addr) + if prevRoundState != nil && prevRoundState.Current != nil { + obj, err := prevRoundState.Current.Decode(schema.Block.ImpliedType()) + if err != nil { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Invalid prior state for resource instance", + fmt.Sprintf( + "Cannot decode the most recent state snapshot for %s: %s.\n\nIs the selected version of %s incompatible with the provider that most recently changed this object?", + inst.Addr, tfdiags.FormatError(err), inst.Provider, + ), + nil, // this error belongs to the whole resource config + )) + return cty.DynamicVal, diags + } + prevRoundVal = obj.Value + prevRoundPrivate = obj.Private + } else { + // TODO: Ask the planning oracle whether there are any "moved" blocks + // that ultimately end up at inst.Addr (possibly through a chain of + // multiple moves) and check the source instance address of each + // one in turn in case we find an as-yet-unclaimed resource instance + // that wants to be rebound to the address in inst.Addr. + // (Note that by handling moved blocks at _this_ point we could + // potentially have the eval system allow dynamic instance keys etc, + // which the original runtime can't do because it always deals with + // moved blocks as a preprocessing step before doing other work.) + prevRoundVal = cty.NullVal(schema.Block.ImpliedType()) + } + + proposedNewVal := p.resourceInstancePlaceholderValue(ctx, + inst.Provider, + inst.Addr.Resource.Resource.Mode, + inst.Addr.Resource.Resource.Type, + prevRoundVal, + inst.ConfigVal, + ) + + if inst.ProviderInstance == nil { + // If we don't even know which provider instance we're supposed to be + // talking to then we'll just return a placeholder value, because + // we don't have any way to generate a speculative plan. + return proposedNewVal, diags + } + + providerClient, moreDiags := p.providerClient(ctx, *inst.ProviderInstance) + if providerClient == nil { + moreDiags = moreDiags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Provider instance not available", + fmt.Sprintf("Cannot plan %s because its associated provider instance %s cannot initialize.", inst.Addr, *inst.ProviderInstance), + nil, + )) + } + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + return proposedNewVal, diags + } + + // TODO: If inst.IgnoreChangesPaths has any entries then we need to + // transform effectiveConfigVal so that any paths specified in there are + // forced to match the corresponding value from prevRoundVal, if any. + effectiveConfigVal := inst.ConfigVal + + // TODO: Call providerClient.ReadResource and update the "refreshed state" + // and reassign this refreshedVal to the refreshed result. + refreshedVal := prevRoundVal + + // As long as we have a provider instance we should be able to ask the + // provider to plan _something_. If this is a placeholder for zero or more + // instances of a resource whose expansion isn't yet known then we're asking + // the provider to produce a speculative plan for all of them at once, + // so we can catch whatever subset of problems are already obvious across + // all of the potential resource instances. + planResp := providerClient.PlanResourceChange(ctx, providers.PlanResourceChangeRequest{ + TypeName: inst.Addr.Resource.Resource.Type, + PriorState: refreshedVal, + ProposedNewState: proposedNewVal, + Config: effectiveConfigVal, + PriorPrivate: prevRoundPrivate, + // TODO: ProviderMeta + }) + for _, err := range objchange.AssertPlanValid(schema.Block, refreshedVal, effectiveConfigVal, planResp.PlannedState) { + // TODO: If resp.LegacyTypeSystem is set then we should generate + // warnings in the log but continue anyway, like the original + // runtime does. + planResp.Diagnostics = planResp.Diagnostics.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Provider produced invalid plan", + // TODO: Bring over the full version of this error case from the + // original runtime. + fmt.Sprintf("Invalid planned new value: %s.", tfdiags.FormatError(err)), + nil, + )) + } + diags = diags.Append(planResp.Diagnostics) + if planResp.Diagnostics.HasErrors() { + return proposedNewVal, diags + } + + // TODO: Check for resp.Deferred once we've updated package providers to + // include it. If that's set then the _provider_ is telling us we must + // defer planning any action for this resource instance. We'd still + // return the planned new state as a placeholder for downstream planning in + // that case, but we would need to mark it as deferred and _not_ record a + // proposed change for it. + + plannedAction := plans.Update + if prevRoundState == nil { + plannedAction = plans.Create + } else if len(planResp.RequiresReplace) != 0 { + if inst.CreateBeforeDestroy { + plannedAction = plans.CreateThenDelete + } else { + plannedAction = plans.DeleteThenCreate + } + } + // (a "desired" object cannot have a Delete action; we handle those cases + // in planOrphanManagedResourceInstance and planDeposedManagedResourceInstanceObject below.) + plannedChange := &plans.ResourceInstanceChange{ + Addr: inst.Addr, + PrevRunAddr: inst.Addr, // TODO: If we add "moved" support above then this must record the original address + ProviderAddr: addrs.AbsProviderConfig{}, // FIXME: Old models are using the not-quite-correct provider address types, so we can't populate this properly + RequiredReplace: cty.NewPathSet(planResp.RequiresReplace...), + Private: planResp.PlannedPrivate, + Change: plans.Change{ + Action: plannedAction, + Before: refreshedVal, + After: planResp.PlannedState, + }, + + // TODO: ActionReason, but need to figure out how to get the information + // we'd need for that into here since most of the reasons are + // configuration-related and so would need to be driven by stuff in + // [eval.DesiredResourceInstance]. + } + plannedChangeSrc, err := plannedChange.Encode(schema.Block.ImpliedType()) + if err != nil { + // TODO: Make a proper error diagnostic for this, like the original + // runtime does. + diags = diags.Append(err) + return planResp.PlannedState, diags + } + p.planCtx.plannedChanges.AppendResourceInstanceChange(plannedChangeSrc) + + // Our result value for ongoing downstream planning is the planned new state. + return planResp.PlannedState, diags } func (p *planGlue) planOrphanManagedResourceInstance(ctx context.Context, addr addrs.AbsResourceInstance, state *states.ResourceInstance) tfdiags.Diagnostics {