diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable.go b/internal/stacks/stackruntime/internal/stackeval/input_variable.go index da9004626faf..2dbab6d8c19a 100644 --- a/internal/stacks/stackruntime/internal/stackeval/input_variable.go +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable.go @@ -45,43 +45,59 @@ func newInputVariable(main *Main, addr stackaddrs.AbsInputVariable, stack *Stack } // DefinedByStackCallInstance returns the stack call which ought to provide -// the definition (i.e. the final value) of this input variable. +// the definition (i.e. the final value) of this input variable. The source +// of the stack could either be a regular stack call instance or a removed +// stack call instance. One of the two will be returned. They are mutually +// exclusive as it is an error for two blocks to create the same stack instance. // // Returns nil if this input variable belongs to the main stack, because // the main stack's input variables come from the planning options instead. -// Also returns nil if the reciever belongs to a stack config instance +// +// Also returns nil if the receiver belongs to a stack config instance // that isn't actually declared in the configuration, which typically suggests // that we don't yet know the number of instances of one of the stack calls // along the chain. -func (v *InputVariable) DefinedByStackCallInstance(ctx context.Context, phase EvalPhase) *StackCallInstance { +func (v *InputVariable) DefinedByStackCallInstance(ctx context.Context, phase EvalPhase) (*StackCallInstance, *RemovedStackCallInstance) { declarerAddr := v.addr.Stack if declarerAddr.IsRoot() { - return nil + return nil, nil } callAddr := declarerAddr.Call() - callerCalls := v.stack.parent.EmbeddedStackCalls() - call := callerCalls[callAddr.Item] - if call == nil { - // Suggests that we're descended from a stack call that doesn't - // actually exist, which is odd but we'll tolerate it. - return nil - } - lastStep := declarerAddr[len(declarerAddr)-1] - instKey := lastStep.Key + if call := v.stack.parent.EmbeddedStackCall(callAddr.Item); call != nil { + lastStep := declarerAddr[len(declarerAddr)-1] + instKey := lastStep.Key + + callInsts, unknown := call.Instances(ctx, phase) + if unknown { + // Return our static unknown instance for this variable. + return call.UnknownInstance(ctx, instKey, phase), nil + } + if inst, ok := callInsts[instKey]; ok { + return inst, nil + } - callInsts, unknown := call.Instances(ctx, phase) - if unknown { - // Return our static unknown instance for this variable. - return call.UnknownInstance(ctx, instKey, phase) + // otherwise, let's check if we have any removed calls that match the + // target instance } - if callInsts == nil { - // Could get here if the call's for_each is invalid. - return nil + + if calls := v.stack.parent.RemovedEmbeddedStackCall(callAddr.Item); calls != nil { + for _, call := range calls { + callInsts, unknown := call.InstancesFor(ctx, v.stack.addr, phase) + if unknown { + return nil, call.UnknownInstance(ctx, v.stack.addr, phase) + } + for _, inst := range callInsts { + // because we used the exact v.stack.addr in InstancesFor above + // then we should have at most one entry here if there were any + // matches. + return nil, inst + } + } } - return callInsts[instKey] + return nil, nil } func (v *InputVariable) Value(ctx context.Context, phase EvalPhase) cty.Value { @@ -175,8 +191,25 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va return cfg.markValue(val), diags default: - definedByCallInst := v.DefinedByStackCallInstance(ctx, phase) - if definedByCallInst == nil { + definedByCallInst, definedByRemovedCallInst := v.DefinedByStackCallInstance(ctx, phase) + switch { + case definedByCallInst != nil: + allVals := definedByCallInst.InputVariableValues(ctx, phase) + val := allVals.GetAttr(v.addr.Item.Name) + + // TODO: check the value against any custom validation rules + // declared in the configuration. + + return cfg.markValue(val), diags + case definedByRemovedCallInst != nil: + allVals, _ := definedByRemovedCallInst.InputVariableValues(ctx, phase) + val := allVals.GetAttr(v.addr.Item.Name) + + // TODO: check the value against any custom validation rules + // declared in the configuration. + + return cfg.markValue(val), diags + default: // We seem to belong to a call instance that doesn't actually // exist in the configuration. That either means that // something's gone wrong or we are descended from a stack @@ -184,14 +217,6 @@ func (v *InputVariable) CheckValue(ctx context.Context, phase EvalPhase) (cty.Va // the latter and return a placeholder. return cfg.markValue(cty.UnknownVal(v.config.config.Type.Constraint)), diags } - - allVals := definedByCallInst.InputVariableValues(ctx, phase) - val := allVals.GetAttr(v.addr.Item.Name) - - // TODO: check the value against any custom validation rules - // declared in the configuration. - - return cfg.markValue(val), diags } }, ) diff --git a/internal/stacks/stackruntime/internal/stackeval/main.go b/internal/stacks/stackruntime/internal/stackeval/main.go index 863d4dcf1a60..0559ca91dc1e 100644 --- a/internal/stacks/stackruntime/internal/stackeval/main.go +++ b/internal/stacks/stackruntime/internal/stackeval/main.go @@ -310,13 +310,7 @@ func (m *Main) MainStack() *Stack { defer m.mu.Unlock() if m.mainStack == nil { - - mode := plans.NormalMode - if m.Planning() { - mode = m.PlanningOpts().PlanningMode - } - - m.mainStack = newStack(m, stackaddrs.RootStackInstance, nil, config, newRemoved(), mode, false) + m.mainStack = newStack(m, stackaddrs.RootStackInstance, nil, config, newRemoved(), m.PlanningMode(), false) } return m.mainStack } @@ -621,6 +615,16 @@ func (m *Main) PlanTimestamp() time.Time { return time.Now().UTC() } +func (m *Main) PlanningMode() plans.Mode { + if m.applying != nil { + return m.applying.plan.Mode + } + if m.planning != nil { + return m.planning.opts.PlanningMode + } + return plans.NormalMode +} + // DependencyLocks returns the dependency locks for the given phase. func (m *Main) DependencyLocks(phase EvalPhase) *depsfile.Locks { switch phase { diff --git a/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go b/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go index a0f80a150c82..3a182d7c6e16 100644 --- a/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go +++ b/internal/stacks/stackruntime/internal/stackeval/removed_component_instance.go @@ -121,10 +121,15 @@ func (r *RemovedComponentInstance) ModuleTreePlan(ctx context.Context) (*plans.P } } + mode := plans.DestroyMode + if r.main.PlanningOpts().PlanningMode == plans.RefreshOnlyMode { + mode = plans.RefreshOnlyMode + } + plantimestamp := r.main.PlanTimestamp() forget := !r.call.config.config.Destroy opts := &terraform.PlanOpts{ - Mode: plans.DestroyMode, + Mode: mode, SetVariables: r.PlanPrevInputs(), ExternalProviders: providerClients, DeferralAllowed: true, @@ -244,10 +249,11 @@ func (r *RemovedComponentInstance) PlanChanges(ctx context.Context) ([]stackplan var changes []stackplan.PlannedChange if plan != nil { - var action plans.Action - if r.call.config.config.Destroy { - action = plans.Delete - } else { + action := plans.Delete + switch { + case r.main.PlanningOpts().PlanningMode == plans.RefreshOnlyMode: + action = plans.Read + case !r.call.config.config.Destroy: action = plans.Forget } changes, moreDiags = stackplan.FromPlan(ctx, r.ModuleTree(ctx), plan, nil, action, r) diff --git a/internal/stacks/stackruntime/internal/stackeval/removed_stack_call_instance.go b/internal/stacks/stackruntime/internal/stackeval/removed_stack_call_instance.go index df7296c182b6..1c386fc38919 100644 --- a/internal/stacks/stackruntime/internal/stackeval/removed_stack_call_instance.go +++ b/internal/stacks/stackruntime/internal/stackeval/removed_stack_call_instance.go @@ -48,7 +48,13 @@ func newRemovedStackCallInstance(call *RemovedStackCall, from stackaddrs.StackIn func (r *RemovedStackCallInstance) Stack(ctx context.Context, phase EvalPhase) *Stack { stack, err := r.stack.For(phase).Do(ctx, r.from.String()+" create", func(ctx context.Context) (*Stack, error) { - return newStack(r.main, r.from, r.call.stack, r.call.config.TargetConfig(), r.call.GetExternalRemovedBlocks(), plans.DestroyMode, r.deferred), nil + + mode := plans.DestroyMode + if r.main.PlanningMode() == plans.RefreshOnlyMode { + mode = plans.RefreshOnlyMode + } + + return newStack(r.main, r.from, r.call.stack, r.call.config.TargetConfig(), r.call.GetExternalRemovedBlocks(), mode, r.deferred), nil }) if err != nil { // we never return an error from within the once call, so this shouldn't diff --git a/internal/stacks/stackruntime/plan_refresh_test.go b/internal/stacks/stackruntime/plan_refresh_test.go new file mode 100644 index 000000000000..d77185632f4a --- /dev/null +++ b/internal/stacks/stackruntime/plan_refresh_test.go @@ -0,0 +1,317 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package stackruntime + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/depsfile" + "github.com/hashicorp/terraform/internal/getproviders/providerreqs" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/stacks/stackaddrs" + "github.com/hashicorp/terraform/internal/stacks/stackplan" + stacks_testing_provider "github.com/hashicorp/terraform/internal/stacks/stackruntime/testing" + "github.com/hashicorp/terraform/internal/stacks/stackstate" + "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/version" +) + +func TestRefreshPlan(t *testing.T) { + fakePlanTimestamp, err := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z") + if err != nil { + t.Fatal(err) + } + + tcs := map[string]struct { + path string + state *stackstate.State + store *stacks_testing_provider.ResourceStore + cycle TestCycle + }{ + "simple-valid": { + path: filepath.Join("with-single-input", "valid"), + state: stackstate.NewStateBuilder(). + AddComponentInstance(stackstate.NewComponentInstanceBuilder(mustAbsComponentInstance("component.self"))). + AddResourceInstance(stackstate.NewResourceInstanceBuilder(). + SetAddr(mustAbsResourceInstanceObject("component.self.testing_resource.data")). + SetProviderAddr(mustDefaultRootProvider("testing")). + SetResourceInstanceObjectSrc(states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: mustMarshalJSONAttrs(map[string]any{ + "id": "old", + "value": "old", + }), + })). + Build(), + store: stacks_testing_provider.NewResourceStoreBuilder(). + AddResource("old", cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("old"), + "value": cty.StringVal("new"), + })). + Build(), + cycle: TestCycle{ + planInputs: map[string]cty.Value{ + "id": cty.StringVal("old"), + "input": cty.StringVal("old"), + }, + wantPlannedChanges: []stackplan.PlannedChange{ + &stackplan.PlannedChangeApplyable{ + Applyable: true, + }, + &stackplan.PlannedChangeComponentInstance{ + Addr: mustAbsComponentInstance("component.self"), + PlanApplyable: true, + PlanComplete: true, + Action: plans.Read, + Mode: plans.RefreshOnlyMode, + PlannedInputValues: map[string]plans.DynamicValue{ + "id": mustPlanDynamicValueDynamicType(cty.StringVal("old")), + "input": mustPlanDynamicValueDynamicType(cty.StringVal("old")), + }, + PlannedInputValueMarks: map[string][]cty.PathValueMarks{ + "id": nil, + "input": nil, + }, + PlannedOutputValues: make(map[string]cty.Value), + PlannedCheckResults: &states.CheckResults{}, + PlanTimestamp: fakePlanTimestamp, + }, + &stackplan.PlannedChangeResourceInstancePlanned{ + ResourceInstanceObjectAddr: mustAbsResourceInstanceObject("component.self.testing_resource.data"), + PriorStateSrc: &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: mustMarshalJSONAttrs(map[string]any{ + "id": "old", + "value": "new", + }), + Dependencies: make([]addrs.ConfigResource, 0), + }, + ProviderConfigAddr: mustDefaultRootProvider("testing"), + Schema: stacks_testing_provider.TestingResourceSchema, + }, + &stackplan.PlannedChangeHeader{ + TerraformVersion: version.SemVer, + }, + &stackplan.PlannedChangePlannedTimestamp{ + PlannedTimestamp: fakePlanTimestamp, + }, + &stackplan.PlannedChangeRootInputValue{ + Addr: mustStackInputVariable("id"), + Action: plans.Create, + Before: cty.NullVal(cty.DynamicPseudoType), + After: cty.StringVal("old"), + }, + &stackplan.PlannedChangeRootInputValue{ + Addr: mustStackInputVariable("input"), + Action: plans.Create, + Before: cty.NullVal(cty.DynamicPseudoType), + After: cty.StringVal("old"), + }, + }, + }, + }, + "removed-component": { + path: filepath.Join("with-single-input", "removed-component"), + state: stackstate.NewStateBuilder(). + AddComponentInstance(stackstate.NewComponentInstanceBuilder(mustAbsComponentInstance("component.self")). + AddInputVariable("id", cty.StringVal("old")). + AddInputVariable("input", cty.StringVal("old"))). + AddResourceInstance(stackstate.NewResourceInstanceBuilder(). + SetAddr(mustAbsResourceInstanceObject("component.self.testing_resource.data")). + SetProviderAddr(mustDefaultRootProvider("testing")). + SetResourceInstanceObjectSrc(states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: mustMarshalJSONAttrs(map[string]any{ + "id": "old", + "value": "old", + }), + })). + Build(), + store: stacks_testing_provider.NewResourceStoreBuilder(). + AddResource("old", cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("old"), + "value": cty.StringVal("new"), + })). + Build(), + cycle: TestCycle{ + wantPlannedChanges: []stackplan.PlannedChange{ + &stackplan.PlannedChangeApplyable{ + Applyable: true, + }, + &stackplan.PlannedChangeComponentInstance{ + Addr: mustAbsComponentInstance("component.self"), + PlanApplyable: true, + PlanComplete: true, + Action: plans.Read, + Mode: plans.RefreshOnlyMode, + PlannedInputValues: map[string]plans.DynamicValue{ + "id": mustPlanDynamicValueDynamicType(cty.StringVal("old")), + "input": mustPlanDynamicValueDynamicType(cty.StringVal("old")), + }, + PlannedInputValueMarks: map[string][]cty.PathValueMarks{ + "id": nil, + "input": nil, + }, + PlannedOutputValues: make(map[string]cty.Value), + PlannedCheckResults: &states.CheckResults{}, + PlanTimestamp: fakePlanTimestamp, + }, + &stackplan.PlannedChangeResourceInstancePlanned{ + ResourceInstanceObjectAddr: mustAbsResourceInstanceObject("component.self.testing_resource.data"), + PriorStateSrc: &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: mustMarshalJSONAttrs(map[string]any{ + "id": "old", + "value": "new", + }), + Dependencies: make([]addrs.ConfigResource, 0), + }, + ProviderConfigAddr: mustDefaultRootProvider("testing"), + Schema: stacks_testing_provider.TestingResourceSchema, + }, + &stackplan.PlannedChangeHeader{ + TerraformVersion: version.SemVer, + }, + &stackplan.PlannedChangePlannedTimestamp{ + PlannedTimestamp: fakePlanTimestamp, + }, + }, + }, + }, + "removed-stack": { + path: filepath.Join("with-single-input", "removed-stack-instance-dynamic"), + state: stackstate.NewStateBuilder(). + AddComponentInstance(stackstate.NewComponentInstanceBuilder(mustAbsComponentInstance("stack.simple[\"old\"].component.self")). + AddInputVariable("id", cty.StringVal("old")). + AddInputVariable("input", cty.StringVal("old"))). + AddResourceInstance(stackstate.NewResourceInstanceBuilder(). + SetAddr(mustAbsResourceInstanceObject("stack.simple[\"old\"].component.self.testing_resource.data")). + SetProviderAddr(mustDefaultRootProvider("testing")). + SetResourceInstanceObjectSrc(states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: mustMarshalJSONAttrs(map[string]any{ + "id": "old", + "value": "old", + }), + })). + Build(), + store: stacks_testing_provider.NewResourceStoreBuilder(). + AddResource("old", cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("old"), + "value": cty.StringVal("new"), + })). + Build(), + cycle: TestCycle{ + planInputs: map[string]cty.Value{ + "removed": cty.MapVal(map[string]cty.Value{ + "old": cty.StringVal("old"), + }), + }, + wantPlannedChanges: []stackplan.PlannedChange{ + &stackplan.PlannedChangeApplyable{ + Applyable: true, + }, + &stackplan.PlannedChangeHeader{ + TerraformVersion: version.SemVer, + }, + &stackplan.PlannedChangePlannedTimestamp{ + PlannedTimestamp: fakePlanTimestamp, + }, + &stackplan.PlannedChangeComponentInstance{ + Addr: mustAbsComponentInstance("stack.simple[\"old\"].component.self"), + PlanApplyable: true, + PlanComplete: true, + Action: plans.Read, + Mode: plans.RefreshOnlyMode, + PlannedInputValues: map[string]plans.DynamicValue{ + "id": mustPlanDynamicValueDynamicType(cty.StringVal("old")), + "input": mustPlanDynamicValueDynamicType(cty.StringVal("old")), + }, + PlannedInputValueMarks: map[string][]cty.PathValueMarks{ + "id": nil, + "input": nil, + }, + PlannedOutputValues: make(map[string]cty.Value), + PlannedCheckResults: &states.CheckResults{}, + PlanTimestamp: fakePlanTimestamp, + }, + &stackplan.PlannedChangeResourceInstancePlanned{ + ResourceInstanceObjectAddr: mustAbsResourceInstanceObject("stack.simple[\"old\"].component.self.testing_resource.data"), + PriorStateSrc: &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: mustMarshalJSONAttrs(map[string]any{ + "id": "old", + "value": "new", + }), + Dependencies: make([]addrs.ConfigResource, 0), + }, + ProviderConfigAddr: mustDefaultRootProvider("testing"), + Schema: stacks_testing_provider.TestingResourceSchema, + }, + &stackplan.PlannedChangeRootInputValue{ + Addr: stackaddrs.InputVariable{Name: "input"}, + Action: plans.Create, + Before: cty.NullVal(cty.DynamicPseudoType), + After: cty.MapValEmpty(cty.String), + }, + &stackplan.PlannedChangeRootInputValue{ + Addr: stackaddrs.InputVariable{Name: "removed"}, + Action: plans.Create, + Before: cty.NullVal(cty.DynamicPseudoType), + After: cty.MapVal(map[string]cty.Value{ + "old": cty.StringVal("old"), + }), + }, + &stackplan.PlannedChangeRootInputValue{ + Addr: stackaddrs.InputVariable{Name: "removed-direct"}, + Action: plans.Create, + Before: cty.NullVal(cty.DynamicPseudoType), + After: cty.SetValEmpty(cty.String), + }, + }, + }, + }, + } + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + cycle := tc.cycle + cycle.planMode = plans.RefreshOnlyMode // set this for all the tests here + + ctx := context.Background() + + lock := depsfile.NewLocks() + lock.SetProvider( + addrs.NewDefaultProvider("testing"), + providerreqs.MustParseVersion("0.0.0"), + providerreqs.MustParseVersionConstraints("=0.0.0"), + providerreqs.PreferredHashes([]providerreqs.Hash{}), + ) + + store := tc.store + if store == nil { + store = stacks_testing_provider.NewResourceStore() + } + + testContext := TestContext{ + timestamp: &fakePlanTimestamp, + config: loadMainBundleConfigForTest(t, tc.path), + providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) { + return stacks_testing_provider.NewProviderWithData(t, store), nil + }, + }, + dependencyLocks: *lock, + } + + testContext.Plan(t, ctx, tc.state, cycle) + }) + } +}