From ceb52e65bbfd88c3554719fe4406b6b2147aa06c Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 11 Sep 2024 10:58:09 +0200 Subject: [PATCH] stacks: support forgetting components --- internal/plans/planproto/convert.go | 4 + internal/stacks/stackruntime/apply_test.go | 113 ++++++++++++++++++ .../internal/stackeval/removed_instance.go | 10 +- .../forgotten-component.tfstack.hcl | 22 ++++ internal/terraform/context_apply2_test.go | 62 ++++++++++ internal/terraform/context_plan.go | 13 ++ internal/terraform/context_walk.go | 5 + internal/terraform/eval_context.go | 4 + internal/terraform/eval_context_builtin.go | 8 ++ internal/terraform/eval_context_mock.go | 8 ++ internal/terraform/graph_walk_context.go | 4 + .../node_resource_abstract_instance.go | 7 ++ 12 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/forgotten-component/forgotten-component.tfstack.hcl diff --git a/internal/plans/planproto/convert.go b/internal/plans/planproto/convert.go index d52e3420f8..4845105abe 100644 --- a/internal/plans/planproto/convert.go +++ b/internal/plans/planproto/convert.go @@ -75,6 +75,8 @@ func NewAction(action plans.Action) Action { return Action_DELETE_THEN_CREATE case plans.CreateThenDelete: return Action_CREATE_THEN_DELETE + case plans.Forget: + return Action_FORGET default: // The above should be exhaustive for all possible actions panic(fmt.Sprintf("unsupported change action %s", action)) @@ -97,6 +99,8 @@ func FromAction(protoAction Action) (plans.Action, error) { return plans.DeleteThenCreate, nil case Action_CREATE_THEN_DELETE: return plans.CreateThenDelete, nil + case Action_FORGET: + return plans.Forget, nil default: return plans.NoOp, fmt.Errorf("unsupported action %s", protoAction) } diff --git a/internal/stacks/stackruntime/apply_test.go b/internal/stacks/stackruntime/apply_test.go index 248c2e3e7c..567cb37ece 100644 --- a/internal/stacks/stackruntime/apply_test.go +++ b/internal/stacks/stackruntime/apply_test.go @@ -3545,6 +3545,119 @@ func TestApply_RemovedBlocks(t *testing.T) { }, wantApplyDiags: []expectedDiagnostic{}, }, + "forgotten component": { + source: filepath.Join("with-single-input", "forgotten-component"), + initialState: stackstate.NewStateBuilder(). + AddComponentInstance(stackstate.NewComponentInstanceBuilder(mustAbsComponentInstance("component.self")). + AddInputVariable("id", cty.StringVal("removed")). + AddInputVariable("input", cty.StringVal("removed"))). + 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": "removed", + "value": "removed", + }), + })). + Build(), + store: stacks_testing_provider.NewResourceStoreBuilder(). + AddResource("removed", cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("removed"), + "value": cty.StringVal("removed"), + })). + Build(), + inputs: map[string]cty.Value{ + "destroy": cty.BoolVal(false), + }, + wantPlanChanges: []stackplan.PlannedChange{ + &stackplan.PlannedChangeApplyable{ + Applyable: true, + }, + &stackplan.PlannedChangeComponentInstance{ + Addr: mustAbsComponentInstance("component.self"), + PlanComplete: true, + PlanApplyable: true, + Mode: plans.DestroyMode, + Action: plans.Forget, + PlannedInputValues: map[string]plans.DynamicValue{ + "id": mustPlanDynamicValueDynamicType(cty.StringVal("removed")), + "input": mustPlanDynamicValueDynamicType(cty.StringVal("removed")), + }, + PlannedInputValueMarks: map[string][]cty.PathValueMarks{ + "input": nil, + "id": nil, + }, + PlannedOutputValues: make(map[string]cty.Value), + PlannedCheckResults: &states.CheckResults{}, + PlanTimestamp: fakePlanTimestamp, + }, + &stackplan.PlannedChangeResourceInstancePlanned{ + ResourceInstanceObjectAddr: mustAbsResourceInstanceObject("component.self.testing_resource.data"), + ChangeSrc: &plans.ResourceInstanceChangeSrc{ + Addr: mustAbsResourceInstance("testing_resource.data"), + PrevRunAddr: mustAbsResourceInstance("testing_resource.data"), + ChangeSrc: plans.ChangeSrc{ + Action: plans.Forget, + Before: mustPlanDynamicValue(cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("removed"), + "value": cty.StringVal("removed"), + })), + After: mustPlanDynamicValue(cty.NullVal(cty.Object(map[string]cty.Type{ + "id": cty.String, + "value": cty.String, + }))), + }, + ProviderAddr: mustDefaultRootProvider("testing"), + }, + PriorStateSrc: &states.ResourceInstanceObjectSrc{ + AttrsJSON: mustMarshalJSONAttrs(map[string]any{ + "id": "removed", + "value": "removed", + }), + Dependencies: make([]addrs.ConfigResource, 0), + Status: states.ObjectReady, + }, + ProviderConfigAddr: mustDefaultRootProvider("testing"), + Schema: stacks_testing_provider.TestingResourceSchema, + }, + &stackplan.PlannedChangeHeader{ + TerraformVersion: version.SemVer, + }, + &stackplan.PlannedChangePlannedTimestamp{ + PlannedTimestamp: fakePlanTimestamp, + }, + }, + wantPlanDiags: []expectedDiagnostic{ + { + severity: tfdiags.Warning, + summary: "Some objects will no longer be managed by Terraform", + detail: `If you apply this plan, Terraform will discard its tracking information for the following objects, but it will not delete them: + - testing_resource.data + +After applying this plan, Terraform will no longer manage these objects. You will need to import them into Terraform to manage them again.`, + }, + }, + wantApplyChanges: []stackstate.AppliedChange{ + &stackstate.AppliedChangeComponentInstance{ + ComponentAddr: mustAbsComponent("component.self"), + ComponentInstanceAddr: mustAbsComponentInstance("component.self"), + OutputValues: make(map[addrs.OutputValue]cty.Value), + InputVariables: map[addrs.InputVariable]cty.Value{ + mustInputVariable("id"): cty.StringVal("removed"), + mustInputVariable("input"): cty.StringVal("removed"), + }, + }, + &stackstate.AppliedChangeResourceInstanceObject{ + ResourceInstanceObjectAddr: mustAbsResourceInstanceObject("component.self.testing_resource.data"), + ProviderConfigAddr: mustDefaultRootProvider("testing"), + NewStateSrc: nil, + Schema: nil, + }, + }, + wantApplyDiags: []expectedDiagnostic{}, + }, } for name, tc := range tcs { diff --git a/internal/stacks/stackruntime/internal/stackeval/removed_instance.go b/internal/stacks/stackruntime/internal/stackeval/removed_instance.go index 7aaf8d3731..590803f6f0 100644 --- a/internal/stacks/stackruntime/internal/stackeval/removed_instance.go +++ b/internal/stacks/stackruntime/internal/stackeval/removed_instance.go @@ -136,12 +136,14 @@ func (r *RemovedInstance) ModuleTreePlan(ctx context.Context) (*plans.Plan, tfdi } plantimestamp := r.main.PlanTimestamp() + forget := !r.call.Config(ctx).config.Destroy opts := &terraform.PlanOpts{ Mode: plans.DestroyMode, SetVariables: r.PlanPrevInputs(ctx), ExternalProviders: providerClients, DeferralAllowed: true, ExternalDependencyDeferred: deferred, + Forget: forget, // We want the same plantimestamp between all components and the stacks language ForcePlanTimestamp: &plantimestamp, @@ -256,7 +258,13 @@ func (r *RemovedInstance) PlanChanges(ctx context.Context) ([]stackplan.PlannedC var changes []stackplan.PlannedChange if plan != nil { - changes, moreDiags = stackplan.FromPlan(ctx, r.ModuleTree(ctx), plan, plans.Delete, r) + var action plans.Action + if r.call.Config(ctx).config.Destroy { + action = plans.Delete + } else { + action = plans.Forget + } + changes, moreDiags = stackplan.FromPlan(ctx, r.ModuleTree(ctx), plan, action, r) diags = diags.Append(moreDiags) } return changes, diags diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/forgotten-component/forgotten-component.tfstack.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/forgotten-component/forgotten-component.tfstack.hcl new file mode 100644 index 0000000000..11b3e1e103 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/with-single-input/forgotten-component/forgotten-component.tfstack.hcl @@ -0,0 +1,22 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +provider "testing" "default" {} + +removed { + from = component.self + + source = "../" + + lifecycle { + destroy = false + } + + providers = { + testing = provider.testing.default + } +} diff --git a/internal/terraform/context_apply2_test.go b/internal/terraform/context_apply2_test.go index 147d221306..06ab486806 100644 --- a/internal/terraform/context_apply2_test.go +++ b/internal/terraform/context_apply2_test.go @@ -2814,6 +2814,68 @@ removed { checkStateString(t, state, ``) } +// TestContext2Apply_destroy_and_forget tests that a destroy plan with the forget flag set to true. +// The expectation is that all resources should be forgotten and not destroyed. +func TestContext2Apply_destroy_and_forget(t *testing.T) { + addrA := mustResourceInstanceAddr("test_object.a") + addrB := mustResourceInstanceAddr("test_object.b") + m := testModuleInline(t, map[string]string{ + "main.tf": ` +resource "test_object" "a" { + test_string = "foo" +} + +resource "test_object" "b" { + test_string = "foo" +} +`}) + + state := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent(addrA, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"foo":"bar"}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + s.SetResourceInstanceCurrent(addrB, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"foo":"bar"}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + }) + + p := simpleMockProvider() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.DestroyMode, + Forget: true, + }) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + // We expect all actions to be forget + for i, change := range plan.Changes.Resources { + if change.Action != plans.Forget { + t.Fatalf("Expected all actions to be forget, but got %s at plan.Changes.Resources[%d]", change.Action, i) + } + } + + state, diags = ctx.Apply(plan, m, nil) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + // check that the provider was not asked to destroy the resource + if p.ApplyResourceChangeCalled { + t.Fatalf("Expected ApplyResourceChange not to be called, but it was called") + } + + checkStateString(t, state, ``) +} + func TestContext2Apply_sensitiveInputVariableValue(t *testing.T) { m := testModuleInline(t, map[string]string{ "main.tf": ` diff --git a/internal/terraform/context_plan.go b/internal/terraform/context_plan.go index e6e8501f11..da15f9bc63 100644 --- a/internal/terraform/context_plan.go +++ b/internal/terraform/context_plan.go @@ -131,6 +131,10 @@ type PlanOpts struct { // This is here only to allow producing fixed results for tests. Don't // use it for main code. ForcePlanTimestamp *time.Time + + // Forget if set to true will cause the plan to forget all resources. This is + // only allowd in the context of a destroy plan. + Forget bool } // Plan generates an execution plan by comparing the given configuration @@ -230,6 +234,14 @@ func (c *Context) PlanAndEval(config *configs.Config, prevRunState *states.State )) return nil, nil, diags } + if opts.Forget && opts.Mode != plans.DestroyMode { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Unsupported plan mode", + "Forgetting all resources is only allowed in the context of a destroy plan. This is a bug in Terraform, please report it.", + )) + return nil, nil, diags + } // By the time we get here, we should have values defined for all of // the root module variables, even if some of them are "unknown". It's the @@ -711,6 +723,7 @@ func (c *Context) planWalk(config *configs.Config, prevRunState *states.State, o Overrides: opts.Overrides, PlanTimeTimestamp: timestamp, ProviderFuncResults: providerFuncResults, + Forget: opts.Forget, }) diags = diags.Append(walker.NonFatalDiagnostics) diags = diags.Append(walkDiags) diff --git a/internal/terraform/context_walk.go b/internal/terraform/context_walk.go index 4cf70b6769..37831ccc20 100644 --- a/internal/terraform/context_walk.go +++ b/internal/terraform/context_walk.go @@ -73,6 +73,10 @@ type graphWalkOpts struct { MoveResults refactoring.MoveResults ProviderFuncResults *providers.FunctionResults + + // Forget if set to true will cause the plan to forget all resources. This is + // only allowd in the context of a destroy plan. + Forget bool } func (c *Context) walk(graph *Graph, operation walkOperation, opts *graphWalkOpts) (*ContextGraphWalker, tfdiags.Diagnostics) { @@ -191,5 +195,6 @@ func (c *Context) graphWalker(graph *Graph, operation walkOperation, opts *graph StopContext: c.runContext, PlanTimestamp: opts.PlanTimeTimestamp, providerFuncResults: opts.ProviderFuncResults, + Forget: opts.Forget, } } diff --git a/internal/terraform/eval_context.go b/internal/terraform/eval_context.go index ae784b7799..d204e6996a 100644 --- a/internal/terraform/eval_context.go +++ b/internal/terraform/eval_context.go @@ -199,6 +199,10 @@ type EvalContext interface { // withScope derives a new EvalContext that has all of the same global // context, but a new evaluation scope. withScope(scope evalContextScope) EvalContext + + // Forget if set to true will cause the plan to forget all resources. This is + // only allowed in the context of a destroy plan. + Forget() bool } func evalContextForModuleInstance(baseCtx EvalContext, addr addrs.ModuleInstance) EvalContext { diff --git a/internal/terraform/eval_context_builtin.go b/internal/terraform/eval_context_builtin.go index 902c38a3e5..4414062809 100644 --- a/internal/terraform/eval_context_builtin.go +++ b/internal/terraform/eval_context_builtin.go @@ -68,6 +68,10 @@ type BuiltinEvalContext struct { // DeferralsValue is the object returned by [BuiltinEvalContext.Deferrals]. DeferralsValue *deferring.Deferred + // forget if set to true will cause the plan to forget all resources. This is + // only allowd in the context of a destroy plan. + forget bool + Hooks []Hook InputValue UIInput ProviderCache map[string]providers.Interface @@ -598,3 +602,7 @@ func (ctx *BuiltinEvalContext) MoveResults() refactoring.MoveResults { func (ctx *BuiltinEvalContext) Overrides() *mocking.Overrides { return ctx.OverrideValues } + +func (ctx *BuiltinEvalContext) Forget() bool { + return ctx.forget +} diff --git a/internal/terraform/eval_context_mock.go b/internal/terraform/eval_context_mock.go index cabb210030..a5b9af9f38 100644 --- a/internal/terraform/eval_context_mock.go +++ b/internal/terraform/eval_context_mock.go @@ -152,6 +152,9 @@ type MockEvalContext struct { OverridesCalled bool OverrideValues *mocking.Overrides + + ForgetCalled bool + ForgetValues bool } // MockEvalContext implements EvalContext @@ -402,3 +405,8 @@ func (c *MockEvalContext) Overrides() *mocking.Overrides { c.OverridesCalled = true return c.OverrideValues } + +func (c *MockEvalContext) Forget() bool { + c.ForgetCalled = true + return c.ForgetValues +} diff --git a/internal/terraform/graph_walk_context.go b/internal/terraform/graph_walk_context.go index 7324b12def..662ae0ff42 100644 --- a/internal/terraform/graph_walk_context.go +++ b/internal/terraform/graph_walk_context.go @@ -48,6 +48,9 @@ type ContextGraphWalker struct { Config *configs.Config PlanTimestamp time.Time Overrides *mocking.Overrides + // Forget if set to true will cause the plan to forget all resources. This is + // only allowd in the context of a destroy plan. + Forget bool // This is an output. Do not set this, nor read it while a graph walk // is in progress. @@ -131,6 +134,7 @@ func (w *ContextGraphWalker) EvalContext() EvalContext { PrevRunStateValue: w.PrevRunState, Evaluator: evaluator, OverrideValues: w.Overrides, + forget: w.Forget, } return ctx diff --git a/internal/terraform/node_resource_abstract_instance.go b/internal/terraform/node_resource_abstract_instance.go index f664c2788d..a529c42f73 100644 --- a/internal/terraform/node_resource_abstract_instance.go +++ b/internal/terraform/node_resource_abstract_instance.go @@ -398,6 +398,13 @@ func (n *NodeAbstractResourceInstance) planDestroy(ctx EvalContext, currentState return noop, deferred, nil } + // If we are in a context where we forget instead of destroying, we can + // just return the forget change without consulting the provider. + if ctx.Forget() { + forget, diags := n.planForget(ctx, currentState, deposedKey) + return forget, deferred, diags + } + unmarkedPriorVal, _ := currentState.Value.UnmarkDeep() // The config and new value are null to signify that this is a destroy