| package terraform |
| |
| import ( |
| "fmt" |
| "strings" |
| "testing" |
| |
| "github.com/davecgh/go-spew/spew" |
| "github.com/zclconf/go-cty/cty" |
| |
| "github.com/hashicorp/terraform/internal/addrs" |
| "github.com/hashicorp/terraform/internal/dag" |
| "github.com/hashicorp/terraform/internal/plans" |
| "github.com/hashicorp/terraform/internal/states" |
| ) |
| |
| func TestDestroyEdgeTransformer_basic(t *testing.T) { |
| g := Graph{Path: addrs.RootModuleInstance} |
| g.Add(testDestroyNode("test_object.A")) |
| g.Add(testDestroyNode("test_object.B")) |
| |
| state := states.NewState() |
| root := state.EnsureModule(addrs.RootModuleInstance) |
| root.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.A").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"A"}`), |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| root.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.B").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"B","test_string":"x"}`), |
| Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A")}, |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil { |
| t.Fatal(err) |
| } |
| |
| tf := &DestroyEdgeTransformer{} |
| if err := tf.Transform(&g); err != nil { |
| t.Fatalf("err: %s", err) |
| } |
| |
| actual := strings.TrimSpace(g.String()) |
| expected := strings.TrimSpace(testTransformDestroyEdgeBasicStr) |
| if actual != expected { |
| t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) |
| } |
| } |
| |
| func TestDestroyEdgeTransformer_multi(t *testing.T) { |
| g := Graph{Path: addrs.RootModuleInstance} |
| g.Add(testDestroyNode("test_object.A")) |
| g.Add(testDestroyNode("test_object.B")) |
| g.Add(testDestroyNode("test_object.C")) |
| |
| state := states.NewState() |
| root := state.EnsureModule(addrs.RootModuleInstance) |
| root.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.A").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"A"}`), |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| root.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.B").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"B","test_string":"x"}`), |
| Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A")}, |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| root.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.C").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"C","test_string":"x"}`), |
| Dependencies: []addrs.ConfigResource{ |
| mustConfigResourceAddr("test_object.A"), |
| mustConfigResourceAddr("test_object.B"), |
| }, |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| |
| if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil { |
| t.Fatal(err) |
| } |
| |
| tf := &DestroyEdgeTransformer{} |
| if err := tf.Transform(&g); err != nil { |
| t.Fatalf("err: %s", err) |
| } |
| |
| actual := strings.TrimSpace(g.String()) |
| expected := strings.TrimSpace(testTransformDestroyEdgeMultiStr) |
| if actual != expected { |
| t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) |
| } |
| } |
| |
| func TestDestroyEdgeTransformer_selfRef(t *testing.T) { |
| g := Graph{Path: addrs.RootModuleInstance} |
| g.Add(testDestroyNode("test_object.A")) |
| tf := &DestroyEdgeTransformer{} |
| if err := tf.Transform(&g); err != nil { |
| t.Fatalf("err: %s", err) |
| } |
| |
| actual := strings.TrimSpace(g.String()) |
| expected := strings.TrimSpace(testTransformDestroyEdgeSelfRefStr) |
| if actual != expected { |
| t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) |
| } |
| } |
| |
| func TestDestroyEdgeTransformer_module(t *testing.T) { |
| g := Graph{Path: addrs.RootModuleInstance} |
| g.Add(testDestroyNode("module.child.test_object.b")) |
| g.Add(testDestroyNode("test_object.a")) |
| state := states.NewState() |
| root := state.EnsureModule(addrs.RootModuleInstance) |
| child := state.EnsureModule(addrs.RootModuleInstance.Child("child", addrs.NoKey)) |
| root.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.a").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"a"}`), |
| Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("module.child.test_object.b")}, |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| child.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.b").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"b","test_string":"x"}`), |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| |
| if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil { |
| t.Fatal(err) |
| } |
| |
| tf := &DestroyEdgeTransformer{} |
| if err := tf.Transform(&g); err != nil { |
| t.Fatalf("err: %s", err) |
| } |
| |
| actual := strings.TrimSpace(g.String()) |
| expected := strings.TrimSpace(testTransformDestroyEdgeModuleStr) |
| if actual != expected { |
| t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) |
| } |
| } |
| |
| func TestDestroyEdgeTransformer_moduleOnly(t *testing.T) { |
| g := Graph{Path: addrs.RootModuleInstance} |
| |
| state := states.NewState() |
| for moduleIdx := 0; moduleIdx < 2; moduleIdx++ { |
| g.Add(testDestroyNode(fmt.Sprintf("module.child[%d].test_object.a", moduleIdx))) |
| g.Add(testDestroyNode(fmt.Sprintf("module.child[%d].test_object.b", moduleIdx))) |
| g.Add(testDestroyNode(fmt.Sprintf("module.child[%d].test_object.c", moduleIdx))) |
| |
| child := state.EnsureModule(addrs.RootModuleInstance.Child("child", addrs.IntKey(moduleIdx))) |
| child.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.a").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"a"}`), |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| child.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.b").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"b","test_string":"x"}`), |
| Dependencies: []addrs.ConfigResource{ |
| mustConfigResourceAddr("module.child.test_object.a"), |
| }, |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| child.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.c").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"c","test_string":"x"}`), |
| Dependencies: []addrs.ConfigResource{ |
| mustConfigResourceAddr("module.child.test_object.a"), |
| mustConfigResourceAddr("module.child.test_object.b"), |
| }, |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| } |
| |
| if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil { |
| t.Fatal(err) |
| } |
| |
| tf := &DestroyEdgeTransformer{} |
| if err := tf.Transform(&g); err != nil { |
| t.Fatalf("err: %s", err) |
| } |
| |
| // The analyses done in the destroy edge transformer are between |
| // not-yet-expanded objects, which is conservative and so it will generate |
| // edges that aren't strictly necessary. As a special case we filter out |
| // any edges that are between resources instances that are in different |
| // instances of the same module, because those edges are never needed |
| // (one instance of a module cannot depend on another instance of the |
| // same module) and including them can, in complex cases, cause cycles due |
| // to unnecessary interactions between destroyed and created module |
| // instances in the same plan. |
| // |
| // Therefore below we expect to see the dependencies within each instance |
| // of module.child reflected, but we should not see any dependencies |
| // _between_ instances of module.child. |
| |
| actual := strings.TrimSpace(g.String()) |
| expected := strings.TrimSpace(` |
| module.child[0].test_object.a (destroy) |
| module.child[0].test_object.b (destroy) |
| module.child[0].test_object.c (destroy) |
| module.child[0].test_object.b (destroy) |
| module.child[0].test_object.c (destroy) |
| module.child[0].test_object.c (destroy) |
| module.child[1].test_object.a (destroy) |
| module.child[1].test_object.b (destroy) |
| module.child[1].test_object.c (destroy) |
| module.child[1].test_object.b (destroy) |
| module.child[1].test_object.c (destroy) |
| module.child[1].test_object.c (destroy) |
| `) |
| if actual != expected { |
| t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) |
| } |
| } |
| |
| func TestDestroyEdgeTransformer_destroyThenUpdate(t *testing.T) { |
| g := Graph{Path: addrs.RootModuleInstance} |
| g.Add(testUpdateNode("test_object.A")) |
| g.Add(testDestroyNode("test_object.B")) |
| |
| state := states.NewState() |
| root := state.EnsureModule(addrs.RootModuleInstance) |
| root.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.A").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"A","test_string":"old"}`), |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| root.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.B").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"B","test_string":"x"}`), |
| Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A")}, |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| |
| if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil { |
| t.Fatal(err) |
| } |
| |
| tf := &DestroyEdgeTransformer{} |
| if err := tf.Transform(&g); err != nil { |
| t.Fatalf("err: %s", err) |
| } |
| |
| expected := strings.TrimSpace(` |
| test_object.A |
| test_object.B (destroy) |
| test_object.B (destroy) |
| `) |
| actual := strings.TrimSpace(g.String()) |
| |
| if actual != expected { |
| t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) |
| } |
| } |
| |
| func TestPruneUnusedNodesTransformer_rootModuleOutputValues(t *testing.T) { |
| // This is a kinda-weird test case covering the very narrow situation |
| // where a root module output value depends on a resource, where we |
| // need to make sure that the output value doesn't block pruning of |
| // the resource from the graph. This special case exists because although |
| // root module objects are "expanders", they in practice always expand |
| // to exactly one instance and so don't have the usual requirement of |
| // needing to stick around in order to support downstream expanders |
| // when there are e.g. nested expanding modules. |
| |
| // In order to keep this test focused on the pruneUnusedNodesTransformer |
| // as much as possible we're using a minimal graph construction here which |
| // is just enough to get the nodes we need, but this does mean that this |
| // test might be invalidated by future changes to the apply graph builder, |
| // and so if something seems off here it might help to compare the |
| // following with the real apply graph transformer and verify whether |
| // this smaller construction is still realistic enough to be a valid test. |
| // It might be valid to change or remove this test to "make it work", as |
| // long as you verify that there is still _something_ upholding the |
| // invariant that a root module output value should not block a resource |
| // node from being pruned from the graph. |
| |
| concreteResource := func(a *NodeAbstractResource) dag.Vertex { |
| return &nodeExpandApplyableResource{ |
| NodeAbstractResource: a, |
| } |
| } |
| |
| concreteResourceInstance := func(a *NodeAbstractResourceInstance) dag.Vertex { |
| return &NodeApplyableResourceInstance{ |
| NodeAbstractResourceInstance: a, |
| } |
| } |
| |
| resourceInstAddr := mustResourceInstanceAddr("test.a") |
| providerCfgAddr := addrs.AbsProviderConfig{ |
| Module: addrs.RootModule, |
| Provider: addrs.MustParseProviderSourceString("foo/test"), |
| } |
| emptyObjDynamicVal, err := plans.NewDynamicValue(cty.EmptyObjectVal, cty.EmptyObject) |
| if err != nil { |
| t.Fatal(err) |
| } |
| nullObjDynamicVal, err := plans.NewDynamicValue(cty.NullVal(cty.EmptyObject), cty.EmptyObject) |
| if err != nil { |
| t.Fatal(err) |
| } |
| |
| config := testModuleInline(t, map[string]string{ |
| "main.tf": ` |
| resource "test" "a" { |
| } |
| |
| output "test" { |
| value = test.a.foo |
| } |
| `, |
| }) |
| state := states.BuildState(func(s *states.SyncState) { |
| s.SetResourceInstanceCurrent( |
| resourceInstAddr, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{}`), |
| }, |
| providerCfgAddr, |
| ) |
| }) |
| changes := plans.NewChanges() |
| changes.SyncWrapper().AppendResourceInstanceChange(&plans.ResourceInstanceChangeSrc{ |
| Addr: resourceInstAddr, |
| PrevRunAddr: resourceInstAddr, |
| ProviderAddr: providerCfgAddr, |
| ChangeSrc: plans.ChangeSrc{ |
| Action: plans.Delete, |
| Before: emptyObjDynamicVal, |
| After: nullObjDynamicVal, |
| }, |
| }) |
| |
| builder := &BasicGraphBuilder{ |
| Steps: []GraphTransformer{ |
| &ConfigTransformer{ |
| Concrete: concreteResource, |
| Config: config, |
| }, |
| &OutputTransformer{ |
| Config: config, |
| }, |
| &DiffTransformer{ |
| Concrete: concreteResourceInstance, |
| State: state, |
| Changes: changes, |
| }, |
| &ReferenceTransformer{}, |
| &AttachDependenciesTransformer{}, |
| &pruneUnusedNodesTransformer{}, |
| &CloseRootModuleTransformer{}, |
| }, |
| } |
| graph, diags := builder.Build(addrs.RootModuleInstance) |
| assertNoDiagnostics(t, diags) |
| |
| // At this point, thanks to pruneUnusedNodesTransformer, we should still |
| // have the node for the output value, but the "test.a (expand)" node |
| // should've been pruned in recognition of the fact that we're performing |
| // a destroy and therefore we only need the "test.a (destroy)" node. |
| |
| nodesByName := make(map[string]dag.Vertex) |
| nodesByResourceExpand := make(map[string]dag.Vertex) |
| for _, n := range graph.Vertices() { |
| name := dag.VertexName(n) |
| if _, exists := nodesByName[name]; exists { |
| t.Fatalf("multiple nodes have name %q", name) |
| } |
| nodesByName[name] = n |
| |
| if exp, ok := n.(*nodeExpandApplyableResource); ok { |
| addr := exp.Addr |
| if _, exists := nodesByResourceExpand[addr.String()]; exists { |
| t.Fatalf("multiple nodes are expanders for %s", addr) |
| } |
| nodesByResourceExpand[addr.String()] = exp |
| } |
| } |
| |
| // NOTE: The following is sensitive to the current name string formats we |
| // use for these particular node types. These names are not contractual |
| // so if this breaks in future it is fine to update these names to the new |
| // names as long as you verify first that the new names correspond to |
| // the same meaning as what we're assuming below. |
| if _, exists := nodesByName["test.a (destroy)"]; !exists { |
| t.Errorf("missing destroy node for resource instance test.a") |
| } |
| if _, exists := nodesByName["output.test (expand)"]; !exists { |
| t.Errorf("missing expand for output value 'test'") |
| } |
| |
| // We _must not_ have any node that expands a resource. |
| if len(nodesByResourceExpand) != 0 { |
| t.Errorf("resource expand nodes remain the graph after transform; should've been pruned\n%s", spew.Sdump(nodesByResourceExpand)) |
| } |
| } |
| |
| // NoOp changes should not be participating in the destroy sequence |
| func TestDestroyEdgeTransformer_noOp(t *testing.T) { |
| g := Graph{Path: addrs.RootModuleInstance} |
| g.Add(testDestroyNode("test_object.A")) |
| g.Add(testUpdateNode("test_object.B")) |
| g.Add(testDestroyNode("test_object.C")) |
| |
| state := states.NewState() |
| root := state.EnsureModule(addrs.RootModuleInstance) |
| root.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.A").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"A"}`), |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| root.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.B").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"B","test_string":"x"}`), |
| Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A")}, |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| root.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.C").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"C","test_string":"x"}`), |
| Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A"), |
| mustConfigResourceAddr("test_object.B")}, |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| |
| if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil { |
| t.Fatal(err) |
| } |
| |
| tf := &DestroyEdgeTransformer{ |
| // We only need a minimal object to indicate GraphNodeCreator change is |
| // a NoOp here. |
| Changes: &plans.Changes{ |
| Resources: []*plans.ResourceInstanceChangeSrc{ |
| { |
| Addr: mustResourceInstanceAddr("test_object.B"), |
| ChangeSrc: plans.ChangeSrc{Action: plans.NoOp}, |
| }, |
| }, |
| }, |
| } |
| if err := tf.Transform(&g); err != nil { |
| t.Fatalf("err: %s", err) |
| } |
| |
| expected := strings.TrimSpace(` |
| test_object.A (destroy) |
| test_object.C (destroy) |
| test_object.B |
| test_object.C (destroy)`) |
| |
| actual := strings.TrimSpace(g.String()) |
| if actual != expected { |
| t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) |
| } |
| } |
| |
| func TestDestroyEdgeTransformer_dataDependsOn(t *testing.T) { |
| g := Graph{Path: addrs.RootModuleInstance} |
| |
| addrA := mustResourceInstanceAddr("test_object.A") |
| instA := NewNodeAbstractResourceInstance(addrA) |
| a := &NodeDestroyResourceInstance{NodeAbstractResourceInstance: instA} |
| g.Add(a) |
| |
| // B here represents a data sources, which is effectively an update during |
| // apply, but won't have dependencies stored in the state. |
| addrB := mustResourceInstanceAddr("test_object.B") |
| instB := NewNodeAbstractResourceInstance(addrB) |
| instB.Dependencies = append(instB.Dependencies, addrA.ConfigResource()) |
| b := &NodeApplyableResourceInstance{NodeAbstractResourceInstance: instB} |
| |
| g.Add(b) |
| |
| state := states.NewState() |
| root := state.EnsureModule(addrs.RootModuleInstance) |
| root.SetResourceInstanceCurrent( |
| mustResourceInstanceAddr("test_object.A").Resource, |
| &states.ResourceInstanceObjectSrc{ |
| Status: states.ObjectReady, |
| AttrsJSON: []byte(`{"id":"A"}`), |
| }, |
| mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), |
| ) |
| |
| if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil { |
| t.Fatal(err) |
| } |
| |
| tf := &DestroyEdgeTransformer{} |
| if err := tf.Transform(&g); err != nil { |
| t.Fatalf("err: %s", err) |
| } |
| |
| actual := strings.TrimSpace(g.String()) |
| expected := strings.TrimSpace(` |
| test_object.A (destroy) |
| test_object.B |
| test_object.A (destroy) |
| `) |
| if actual != expected { |
| t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) |
| } |
| } |
| |
| func testDestroyNode(addrString string) GraphNodeDestroyer { |
| instAddr := mustResourceInstanceAddr(addrString) |
| inst := NewNodeAbstractResourceInstance(instAddr) |
| return &NodeDestroyResourceInstance{NodeAbstractResourceInstance: inst} |
| } |
| |
| func testUpdateNode(addrString string) GraphNodeCreator { |
| instAddr := mustResourceInstanceAddr(addrString) |
| inst := NewNodeAbstractResourceInstance(instAddr) |
| return &NodeApplyableResourceInstance{NodeAbstractResourceInstance: inst} |
| } |
| |
| const testTransformDestroyEdgeBasicStr = ` |
| test_object.A (destroy) |
| test_object.B (destroy) |
| test_object.B (destroy) |
| ` |
| |
| const testTransformDestroyEdgeMultiStr = ` |
| test_object.A (destroy) |
| test_object.B (destroy) |
| test_object.C (destroy) |
| test_object.B (destroy) |
| test_object.C (destroy) |
| test_object.C (destroy) |
| ` |
| |
| const testTransformDestroyEdgeSelfRefStr = ` |
| test_object.A (destroy) |
| ` |
| |
| const testTransformDestroyEdgeModuleStr = ` |
| module.child.test_object.b (destroy) |
| test_object.a (destroy) |
| test_object.a (destroy) |
| ` |