Skip to content

Commit

Permalink
stacks: skip full plan/apply cycles when deleting empty state (#35831)
Browse files Browse the repository at this point in the history
  • Loading branch information
liamcervante authored Oct 9, 2024
1 parent 0be94d4 commit 669e8ff
Show file tree
Hide file tree
Showing 10 changed files with 496 additions and 11 deletions.
187 changes: 187 additions & 0 deletions internal/stacks/stackruntime/apply_destroy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,193 @@ func TestApplyDestroy(t *testing.T) {
},
},
},
"empty-destroy-with-data-source": {
path: path.Join("with-data-source", "dependent"),
cycles: []TestCycle{
{
planMode: plans.DestroyMode,
planInputs: map[string]cty.Value{
"id": cty.StringVal("foo"),
},
// deliberately empty, as we expect no changes from an
// empty state.
wantAppliedChanges: []stackstate.AppliedChange{
&stackstate.AppliedChangeComponentInstanceRemoved{
ComponentAddr: mustAbsComponent("component.data"),
ComponentInstanceAddr: mustAbsComponentInstance("component.data"),
},
&stackstate.AppliedChangeComponentInstanceRemoved{
ComponentAddr: mustAbsComponent("component.self"),
ComponentInstanceAddr: mustAbsComponentInstance("component.self"),
},
&stackstate.AppliedChangeInputVariable{
Addr: mustStackInputVariable("id"),
},
},
},
},
},
"partial destroy recovery": {
path: "component-chain",
description: "this test simulates a partial destroy recovery",
state: stackstate.NewStateBuilder().
// we only have data for the first component, indicating that
// the second and third components were destroyed but not the
// first one for some reason
AddComponentInstance(stackstate.NewComponentInstanceBuilder(mustAbsComponentInstance("component.one")).
AddDependent(mustAbsComponent("component.two")).
AddInputVariable("id", cty.StringVal("one")).
AddInputVariable("value", cty.StringVal("foo")).
AddOutputValue("value", cty.StringVal("foo"))).
AddResourceInstance(stackstate.NewResourceInstanceBuilder().
SetAddr(mustAbsResourceInstanceObject("component.one.testing_resource.data")).
SetProviderAddr(mustDefaultRootProvider("testing")).
SetResourceInstanceObjectSrc(states.ResourceInstanceObjectSrc{
AttrsJSON: mustMarshalJSONAttrs(map[string]interface{}{
"id": "one",
"value": "foo",
}),
Status: states.ObjectReady,
})).
AddInput("value", cty.StringVal("foo")).
AddOutput("value", cty.StringVal("foo")).
Build(),
store: stacks_testing_provider.NewResourceStoreBuilder().
AddResource("one", cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("one"),
"value": cty.StringVal("foo"),
})).
Build(),
cycles: []TestCycle{
{
planMode: plans.DestroyMode,
planInputs: map[string]cty.Value{
"value": cty.StringVal("foo"),
},
wantPlannedChanges: []stackplan.PlannedChange{
&stackplan.PlannedChangeApplyable{
Applyable: true,
},
&stackplan.PlannedChangeComponentInstance{
Addr: mustAbsComponentInstance("component.one"),
Action: plans.Delete,
Mode: plans.DestroyMode,
PlanComplete: true,
PlanApplyable: true,
PlannedInputValues: map[string]plans.DynamicValue{
"id": mustPlanDynamicValueDynamicType(cty.StringVal("one")),
"value": mustPlanDynamicValueDynamicType(cty.StringVal("foo")),
},
PlannedInputValueMarks: map[string][]cty.PathValueMarks{
"id": nil,
"value": nil,
},
PlannedOutputValues: map[string]cty.Value{
"value": cty.StringVal("foo"),
},
PlannedCheckResults: &states.CheckResults{},
PlanTimestamp: fakePlanTimestamp,
},
&stackplan.PlannedChangeResourceInstancePlanned{
ResourceInstanceObjectAddr: mustAbsResourceInstanceObject("component.one.testing_resource.data"),
ChangeSrc: &plans.ResourceInstanceChangeSrc{
Addr: mustAbsResourceInstance("testing_resource.data"),
PrevRunAddr: mustAbsResourceInstance("testing_resource.data"),
ProviderAddr: mustDefaultRootProvider("testing"),
ChangeSrc: plans.ChangeSrc{
Action: plans.Delete,
Before: mustPlanDynamicValue(cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("one"),
"value": cty.StringVal("foo"),
})),
After: mustPlanDynamicValue(cty.NullVal(cty.Object(map[string]cty.Type{
"id": cty.String,
"value": cty.String,
}))),
},
},
PriorStateSrc: &states.ResourceInstanceObjectSrc{
AttrsJSON: mustMarshalJSONAttrs(map[string]interface{}{
"id": "one",
"value": "foo",
}),
Status: states.ObjectReady,
Dependencies: make([]addrs.ConfigResource, 0),
},
ProviderConfigAddr: mustDefaultRootProvider("testing"),
Schema: stacks_testing_provider.TestingResourceSchema,
},
&stackplan.PlannedChangeComponentInstance{
Addr: mustAbsComponentInstance("component.three"),
Action: plans.Delete,
Mode: plans.DestroyMode,
PlanComplete: true,
PlanApplyable: true,
RequiredComponents: collections.NewSet(mustAbsComponent("component.two")),
PlannedOutputValues: map[string]cty.Value{
"value": cty.DynamicVal,
},
PlanTimestamp: fakePlanTimestamp,
},
&stackplan.PlannedChangeComponentInstance{
Addr: mustAbsComponentInstance("component.two"),
Action: plans.Delete,
Mode: plans.DestroyMode,
PlanComplete: true,
PlanApplyable: true,
RequiredComponents: collections.NewSet(mustAbsComponent("component.one")),
PlannedOutputValues: map[string]cty.Value{
"value": cty.DynamicVal,
},
PlanTimestamp: fakePlanTimestamp,
},
&stackplan.PlannedChangeHeader{
TerraformVersion: version.SemVer,
},
&stackplan.PlannedChangeOutputValue{
Addr: mustStackOutputValue("value"),
Action: plans.Delete,
Before: cty.StringVal("foo"),
After: cty.NullVal(cty.String),
},
&stackplan.PlannedChangePlannedTimestamp{
PlannedTimestamp: fakePlanTimestamp,
},
&stackplan.PlannedChangeRootInputValue{
Addr: mustStackInputVariable("value"),
Action: plans.NoOp,
Before: cty.StringVal("foo"),
After: cty.StringVal("foo"),
DeleteOnApply: true,
},
},
wantAppliedChanges: []stackstate.AppliedChange{
&stackstate.AppliedChangeComponentInstanceRemoved{
ComponentAddr: mustAbsComponent("component.one"),
ComponentInstanceAddr: mustAbsComponentInstance("component.one"),
},
&stackstate.AppliedChangeResourceInstanceObject{
ResourceInstanceObjectAddr: mustAbsResourceInstanceObject("component.one.testing_resource.data"),
ProviderConfigAddr: mustDefaultRootProvider("testing"),
},
&stackstate.AppliedChangeComponentInstanceRemoved{
ComponentAddr: mustAbsComponent("component.three"),
ComponentInstanceAddr: mustAbsComponentInstance("component.three"),
},
&stackstate.AppliedChangeComponentInstanceRemoved{
ComponentAddr: mustAbsComponent("component.two"),
ComponentInstanceAddr: mustAbsComponentInstance("component.two"),
},
&stackstate.AppliedChangeOutputValue{
Addr: mustStackOutputValue("value"),
},
&stackstate.AppliedChangeInputVariable{
Addr: mustStackInputVariable("value"),
},
},
},
},
},
}
for name, tc := range tcs {
t.Run(name, func(t *testing.T) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,26 @@ func (c *ComponentInstance) CheckModuleTreePlan(ctx context.Context) (*plans.Pla

mode := c.main.PlanningOpts().PlanningMode
if mode == plans.DestroyMode {

if !c.main.PlanPrevState().HasComponentInstance(c.Addr()) {
// If the component instance doesn't exist in the previous
// state at all, then we don't need to do anything.
//
// This means the component instance was added to the config
// and never applied, or that it was previously destroyed
// via an earlier destroy operation.
//
// Return a dummy plan:
return &plans.Plan{
UIMode: plans.DestroyMode,
Complete: true,
Applyable: true,
Errored: false,
Timestamp: c.main.PlanTimestamp(),
Changes: plans.NewChangesSrc(), // no changes
}, nil
}

// If we are destroying, then we are going to do the refresh
// and destroy plan in two separate stages. This helps resolves
// cycles within the dependency graph, as anything requiring
Expand Down Expand Up @@ -331,6 +351,18 @@ func (c *ComponentInstance) ApplyModuleTreePlan(ctx context.Context, plan *plans
panic("called ApplyModuleTreePlan with an evaluator not instantiated for applying")
}

if plan.UIMode == plans.DestroyMode && plan.Changes.Empty() {
stackPlan := c.main.PlanBeingApplied().Components.Get(c.Addr())

// If we're destroying and there's nothing to destroy, then we can
// consider this a no-op.
return &ComponentInstanceApplyResult{
FinalState: plan.PriorState, // after refresh
AffectedResourceInstanceObjects: resourceInstanceObjectsAffectedByStackPlan(stackPlan),
Complete: true,
}, diags
}

// This is the result to return along with any errors that prevent us from
// even starting the modules runtime apply phase. It reports that nothing
// changed at all.
Expand Down
102 changes: 102 additions & 0 deletions internal/stacks/stackruntime/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,108 @@ func TestPlan_invalid(t *testing.T) {
}
}

// TestPlan uses a generic framework for running plan integration tests
// against Stacks. Generally, new tests should be added into this function
// rather than copying the large amount of duplicate code from the other
// tests in this file.
//
// If you are editing other tests in this file, please consider moving them
// into this test function so they can reuse the shared setup and boilerplate
// code managing the boring parts of the test.
func TestPlan(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
}{
"empty-destroy-with-data-source": {
path: path.Join("with-data-source", "dependent"),
cycle: TestCycle{
planMode: plans.DestroyMode,
planInputs: map[string]cty.Value{
"id": cty.StringVal("foo"),
},
wantPlannedChanges: []stackplan.PlannedChange{
&stackplan.PlannedChangeApplyable{
Applyable: true,
},
&stackplan.PlannedChangeComponentInstance{
Addr: mustAbsComponentInstance("component.data"),
PlanApplyable: true,
PlanComplete: true,
Action: plans.Delete,
Mode: plans.DestroyMode,
RequiredComponents: collections.NewSet(mustAbsComponent("component.self")),
PlannedOutputValues: make(map[string]cty.Value),
PlanTimestamp: fakePlanTimestamp,
},
&stackplan.PlannedChangeComponentInstance{
Addr: mustAbsComponentInstance("component.self"),
PlanComplete: true,
PlanApplyable: true,
Action: plans.Delete,
Mode: plans.DestroyMode,
PlannedOutputValues: map[string]cty.Value{
"id": cty.NullVal(cty.DynamicPseudoType),
},
PlanTimestamp: fakePlanTimestamp,
},
&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("foo"),
DeleteOnApply: true,
},
},
},
},
}
for name, tc := range tcs {
t.Run(name, func(t *testing.T) {
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, tc.cycle)
})
}
}

func TestPlanWithMissingInputVariable(t *testing.T) {
ctx := context.Background()
cfg := loadMainBundleConfigForTest(t, "plan-undeclared-variable-in-component")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ provider "testing" "main" {}

provider "testing" "credentialed" {
config {
require_auth = true
authentication = component.load.credentials
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ provider "testing" "main" {}

provider "testing" "credentialed" {
config {
require_auth = true
authentication = component.load.credentials
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
terraform {
required_providers {
testing = {
source = "hashicorp/testing"
version = "0.1.0"
}
}
}


variable "id" {
type = string
default = null
nullable = true # We'll generate an ID if none provided.
}

variable "value" {
type = string
}

resource "testing_resource" "data" {
id = var.id
value = var.value
}

output "value" {
value = testing_resource.data.value
}
Loading

0 comments on commit 669e8ff

Please sign in to comment.