github.com/opentofu/opentofu@v1.7.1/internal/tofu/transform_destroy_edge_test.go (about)

     1  // Copyright (c) The OpenTofu Authors
     2  // SPDX-License-Identifier: MPL-2.0
     3  // Copyright (c) 2023 HashiCorp, Inc.
     4  // SPDX-License-Identifier: MPL-2.0
     5  
     6  package tofu
     7  
     8  import (
     9  	"fmt"
    10  	"strings"
    11  	"testing"
    12  
    13  	"github.com/davecgh/go-spew/spew"
    14  	"github.com/zclconf/go-cty/cty"
    15  
    16  	"github.com/opentofu/opentofu/internal/addrs"
    17  	"github.com/opentofu/opentofu/internal/dag"
    18  	"github.com/opentofu/opentofu/internal/plans"
    19  	"github.com/opentofu/opentofu/internal/states"
    20  )
    21  
    22  func TestDestroyEdgeTransformer_basic(t *testing.T) {
    23  	g := Graph{Path: addrs.RootModuleInstance}
    24  	g.Add(testDestroyNode("test_object.A"))
    25  	g.Add(testDestroyNode("test_object.B"))
    26  
    27  	state := states.NewState()
    28  	root := state.EnsureModule(addrs.RootModuleInstance)
    29  	root.SetResourceInstanceCurrent(
    30  		mustResourceInstanceAddr("test_object.A").Resource,
    31  		&states.ResourceInstanceObjectSrc{
    32  			Status:    states.ObjectReady,
    33  			AttrsJSON: []byte(`{"id":"A"}`),
    34  		},
    35  		mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
    36  	)
    37  	root.SetResourceInstanceCurrent(
    38  		mustResourceInstanceAddr("test_object.B").Resource,
    39  		&states.ResourceInstanceObjectSrc{
    40  			Status:       states.ObjectReady,
    41  			AttrsJSON:    []byte(`{"id":"B","test_string":"x"}`),
    42  			Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A")},
    43  		},
    44  		mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
    45  	)
    46  	if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil {
    47  		t.Fatal(err)
    48  	}
    49  
    50  	tf := &DestroyEdgeTransformer{}
    51  	if err := tf.Transform(&g); err != nil {
    52  		t.Fatalf("err: %s", err)
    53  	}
    54  
    55  	actual := strings.TrimSpace(g.String())
    56  	expected := strings.TrimSpace(testTransformDestroyEdgeBasicStr)
    57  	if actual != expected {
    58  		t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
    59  	}
    60  }
    61  
    62  func TestDestroyEdgeTransformer_multi(t *testing.T) {
    63  	g := Graph{Path: addrs.RootModuleInstance}
    64  	g.Add(testDestroyNode("test_object.A"))
    65  	g.Add(testDestroyNode("test_object.B"))
    66  	g.Add(testDestroyNode("test_object.C"))
    67  
    68  	state := states.NewState()
    69  	root := state.EnsureModule(addrs.RootModuleInstance)
    70  	root.SetResourceInstanceCurrent(
    71  		mustResourceInstanceAddr("test_object.A").Resource,
    72  		&states.ResourceInstanceObjectSrc{
    73  			Status:    states.ObjectReady,
    74  			AttrsJSON: []byte(`{"id":"A"}`),
    75  		},
    76  		mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
    77  	)
    78  	root.SetResourceInstanceCurrent(
    79  		mustResourceInstanceAddr("test_object.B").Resource,
    80  		&states.ResourceInstanceObjectSrc{
    81  			Status:       states.ObjectReady,
    82  			AttrsJSON:    []byte(`{"id":"B","test_string":"x"}`),
    83  			Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A")},
    84  		},
    85  		mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
    86  	)
    87  	root.SetResourceInstanceCurrent(
    88  		mustResourceInstanceAddr("test_object.C").Resource,
    89  		&states.ResourceInstanceObjectSrc{
    90  			Status:    states.ObjectReady,
    91  			AttrsJSON: []byte(`{"id":"C","test_string":"x"}`),
    92  			Dependencies: []addrs.ConfigResource{
    93  				mustConfigResourceAddr("test_object.A"),
    94  				mustConfigResourceAddr("test_object.B"),
    95  			},
    96  		},
    97  		mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
    98  	)
    99  
   100  	if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil {
   101  		t.Fatal(err)
   102  	}
   103  
   104  	tf := &DestroyEdgeTransformer{}
   105  	if err := tf.Transform(&g); err != nil {
   106  		t.Fatalf("err: %s", err)
   107  	}
   108  
   109  	actual := strings.TrimSpace(g.String())
   110  	expected := strings.TrimSpace(testTransformDestroyEdgeMultiStr)
   111  	if actual != expected {
   112  		t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
   113  	}
   114  }
   115  
   116  func TestDestroyEdgeTransformer_selfRef(t *testing.T) {
   117  	g := Graph{Path: addrs.RootModuleInstance}
   118  	g.Add(testDestroyNode("test_object.A"))
   119  	tf := &DestroyEdgeTransformer{}
   120  	if err := tf.Transform(&g); err != nil {
   121  		t.Fatalf("err: %s", err)
   122  	}
   123  
   124  	actual := strings.TrimSpace(g.String())
   125  	expected := strings.TrimSpace(testTransformDestroyEdgeSelfRefStr)
   126  	if actual != expected {
   127  		t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
   128  	}
   129  }
   130  
   131  func TestDestroyEdgeTransformer_module(t *testing.T) {
   132  	g := Graph{Path: addrs.RootModuleInstance}
   133  	g.Add(testDestroyNode("module.child.test_object.b"))
   134  	g.Add(testDestroyNode("test_object.a"))
   135  	state := states.NewState()
   136  	root := state.EnsureModule(addrs.RootModuleInstance)
   137  	child := state.EnsureModule(addrs.RootModuleInstance.Child("child", addrs.NoKey))
   138  	root.SetResourceInstanceCurrent(
   139  		mustResourceInstanceAddr("test_object.a").Resource,
   140  		&states.ResourceInstanceObjectSrc{
   141  			Status:       states.ObjectReady,
   142  			AttrsJSON:    []byte(`{"id":"a"}`),
   143  			Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("module.child.test_object.b")},
   144  		},
   145  		mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
   146  	)
   147  	child.SetResourceInstanceCurrent(
   148  		mustResourceInstanceAddr("test_object.b").Resource,
   149  		&states.ResourceInstanceObjectSrc{
   150  			Status:    states.ObjectReady,
   151  			AttrsJSON: []byte(`{"id":"b","test_string":"x"}`),
   152  		},
   153  		mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
   154  	)
   155  
   156  	if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil {
   157  		t.Fatal(err)
   158  	}
   159  
   160  	tf := &DestroyEdgeTransformer{}
   161  	if err := tf.Transform(&g); err != nil {
   162  		t.Fatalf("err: %s", err)
   163  	}
   164  
   165  	actual := strings.TrimSpace(g.String())
   166  	expected := strings.TrimSpace(testTransformDestroyEdgeModuleStr)
   167  	if actual != expected {
   168  		t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
   169  	}
   170  }
   171  
   172  func TestDestroyEdgeTransformer_moduleOnly(t *testing.T) {
   173  	g := Graph{Path: addrs.RootModuleInstance}
   174  
   175  	state := states.NewState()
   176  	for moduleIdx := 0; moduleIdx < 2; moduleIdx++ {
   177  		g.Add(testDestroyNode(fmt.Sprintf("module.child[%d].test_object.a", moduleIdx)))
   178  		g.Add(testDestroyNode(fmt.Sprintf("module.child[%d].test_object.b", moduleIdx)))
   179  		g.Add(testDestroyNode(fmt.Sprintf("module.child[%d].test_object.c", moduleIdx)))
   180  
   181  		child := state.EnsureModule(addrs.RootModuleInstance.Child("child", addrs.IntKey(moduleIdx)))
   182  		child.SetResourceInstanceCurrent(
   183  			mustResourceInstanceAddr("test_object.a").Resource,
   184  			&states.ResourceInstanceObjectSrc{
   185  				Status:    states.ObjectReady,
   186  				AttrsJSON: []byte(`{"id":"a"}`),
   187  			},
   188  			mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
   189  		)
   190  		child.SetResourceInstanceCurrent(
   191  			mustResourceInstanceAddr("test_object.b").Resource,
   192  			&states.ResourceInstanceObjectSrc{
   193  				Status:    states.ObjectReady,
   194  				AttrsJSON: []byte(`{"id":"b","test_string":"x"}`),
   195  				Dependencies: []addrs.ConfigResource{
   196  					mustConfigResourceAddr("module.child.test_object.a"),
   197  				},
   198  			},
   199  			mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
   200  		)
   201  		child.SetResourceInstanceCurrent(
   202  			mustResourceInstanceAddr("test_object.c").Resource,
   203  			&states.ResourceInstanceObjectSrc{
   204  				Status:    states.ObjectReady,
   205  				AttrsJSON: []byte(`{"id":"c","test_string":"x"}`),
   206  				Dependencies: []addrs.ConfigResource{
   207  					mustConfigResourceAddr("module.child.test_object.a"),
   208  					mustConfigResourceAddr("module.child.test_object.b"),
   209  				},
   210  			},
   211  			mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
   212  		)
   213  	}
   214  
   215  	if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil {
   216  		t.Fatal(err)
   217  	}
   218  
   219  	tf := &DestroyEdgeTransformer{}
   220  	if err := tf.Transform(&g); err != nil {
   221  		t.Fatalf("err: %s", err)
   222  	}
   223  
   224  	// The analyses done in the destroy edge transformer are between
   225  	// not-yet-expanded objects, which is conservative and so it will generate
   226  	// edges that aren't strictly necessary. As a special case we filter out
   227  	// any edges that are between resources instances that are in different
   228  	// instances of the same module, because those edges are never needed
   229  	// (one instance of a module cannot depend on another instance of the
   230  	// same module) and including them can, in complex cases, cause cycles due
   231  	// to unnecessary interactions between destroyed and created module
   232  	// instances in the same plan.
   233  	//
   234  	// Therefore below we expect to see the dependencies within each instance
   235  	// of module.child reflected, but we should not see any dependencies
   236  	// _between_ instances of module.child.
   237  
   238  	actual := strings.TrimSpace(g.String())
   239  	expected := strings.TrimSpace(`
   240  module.child[0].test_object.a (destroy)
   241    module.child[0].test_object.b (destroy)
   242    module.child[0].test_object.c (destroy)
   243  module.child[0].test_object.b (destroy)
   244    module.child[0].test_object.c (destroy)
   245  module.child[0].test_object.c (destroy)
   246  module.child[1].test_object.a (destroy)
   247    module.child[1].test_object.b (destroy)
   248    module.child[1].test_object.c (destroy)
   249  module.child[1].test_object.b (destroy)
   250    module.child[1].test_object.c (destroy)
   251  module.child[1].test_object.c (destroy)
   252  `)
   253  	if actual != expected {
   254  		t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
   255  	}
   256  }
   257  
   258  func TestDestroyEdgeTransformer_destroyThenUpdate(t *testing.T) {
   259  	g := Graph{Path: addrs.RootModuleInstance}
   260  	g.Add(testUpdateNode("test_object.A"))
   261  	g.Add(testDestroyNode("test_object.B"))
   262  
   263  	state := states.NewState()
   264  	root := state.EnsureModule(addrs.RootModuleInstance)
   265  	root.SetResourceInstanceCurrent(
   266  		mustResourceInstanceAddr("test_object.A").Resource,
   267  		&states.ResourceInstanceObjectSrc{
   268  			Status:    states.ObjectReady,
   269  			AttrsJSON: []byte(`{"id":"A","test_string":"old"}`),
   270  		},
   271  		mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
   272  	)
   273  	root.SetResourceInstanceCurrent(
   274  		mustResourceInstanceAddr("test_object.B").Resource,
   275  		&states.ResourceInstanceObjectSrc{
   276  			Status:       states.ObjectReady,
   277  			AttrsJSON:    []byte(`{"id":"B","test_string":"x"}`),
   278  			Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A")},
   279  		},
   280  		mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
   281  	)
   282  
   283  	if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil {
   284  		t.Fatal(err)
   285  	}
   286  
   287  	tf := &DestroyEdgeTransformer{}
   288  	if err := tf.Transform(&g); err != nil {
   289  		t.Fatalf("err: %s", err)
   290  	}
   291  
   292  	expected := strings.TrimSpace(`
   293  test_object.A
   294    test_object.B (destroy)
   295  test_object.B (destroy)
   296  `)
   297  	actual := strings.TrimSpace(g.String())
   298  
   299  	if actual != expected {
   300  		t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
   301  	}
   302  }
   303  
   304  func TestPruneUnusedNodesTransformer_rootModuleOutputValues(t *testing.T) {
   305  	// This is a kinda-weird test case covering the very narrow situation
   306  	// where a root module output value depends on a resource, where we
   307  	// need to make sure that the output value doesn't block pruning of
   308  	// the resource from the graph. This special case exists because although
   309  	// root module objects are "expanders", they in practice always expand
   310  	// to exactly one instance and so don't have the usual requirement of
   311  	// needing to stick around in order to support downstream expanders
   312  	// when there are e.g. nested expanding modules.
   313  
   314  	// In order to keep this test focused on the pruneUnusedNodesTransformer
   315  	// as much as possible we're using a minimal graph construction here which
   316  	// is just enough to get the nodes we need, but this does mean that this
   317  	// test might be invalidated by future changes to the apply graph builder,
   318  	// and so if something seems off here it might help to compare the
   319  	// following with the real apply graph transformer and verify whether
   320  	// this smaller construction is still realistic enough to be a valid test.
   321  	// It might be valid to change or remove this test to "make it work", as
   322  	// long as you verify that there is still _something_ upholding the
   323  	// invariant that a root module output value should not block a resource
   324  	// node from being pruned from the graph.
   325  
   326  	concreteResource := func(a *NodeAbstractResource) dag.Vertex {
   327  		return &nodeExpandApplyableResource{
   328  			NodeAbstractResource: a,
   329  		}
   330  	}
   331  
   332  	concreteResourceInstance := func(a *NodeAbstractResourceInstance) dag.Vertex {
   333  		return &NodeApplyableResourceInstance{
   334  			NodeAbstractResourceInstance: a,
   335  		}
   336  	}
   337  
   338  	resourceInstAddr := mustResourceInstanceAddr("test.a")
   339  	providerCfgAddr := addrs.AbsProviderConfig{
   340  		Module:   addrs.RootModule,
   341  		Provider: addrs.MustParseProviderSourceString("foo/test"),
   342  	}
   343  	emptyObjDynamicVal, err := plans.NewDynamicValue(cty.EmptyObjectVal, cty.EmptyObject)
   344  	if err != nil {
   345  		t.Fatal(err)
   346  	}
   347  	nullObjDynamicVal, err := plans.NewDynamicValue(cty.NullVal(cty.EmptyObject), cty.EmptyObject)
   348  	if err != nil {
   349  		t.Fatal(err)
   350  	}
   351  
   352  	config := testModuleInline(t, map[string]string{
   353  		"main.tf": `
   354  			resource "test" "a" {
   355  			}
   356  
   357  			output "test" {
   358  				value = test.a.foo
   359  			}
   360  		`,
   361  	})
   362  	state := states.BuildState(func(s *states.SyncState) {
   363  		s.SetResourceInstanceCurrent(
   364  			resourceInstAddr,
   365  			&states.ResourceInstanceObjectSrc{
   366  				Status:    states.ObjectReady,
   367  				AttrsJSON: []byte(`{}`),
   368  			},
   369  			providerCfgAddr,
   370  		)
   371  	})
   372  	changes := plans.NewChanges()
   373  	changes.SyncWrapper().AppendResourceInstanceChange(&plans.ResourceInstanceChangeSrc{
   374  		Addr:         resourceInstAddr,
   375  		PrevRunAddr:  resourceInstAddr,
   376  		ProviderAddr: providerCfgAddr,
   377  		ChangeSrc: plans.ChangeSrc{
   378  			Action: plans.Delete,
   379  			Before: emptyObjDynamicVal,
   380  			After:  nullObjDynamicVal,
   381  		},
   382  	})
   383  
   384  	builder := &BasicGraphBuilder{
   385  		Steps: []GraphTransformer{
   386  			&ConfigTransformer{
   387  				Concrete: concreteResource,
   388  				Config:   config,
   389  			},
   390  			&OutputTransformer{
   391  				Config: config,
   392  			},
   393  			&DiffTransformer{
   394  				Concrete: concreteResourceInstance,
   395  				State:    state,
   396  				Changes:  changes,
   397  			},
   398  			&ReferenceTransformer{},
   399  			&AttachDependenciesTransformer{},
   400  			&pruneUnusedNodesTransformer{},
   401  			&CloseRootModuleTransformer{},
   402  		},
   403  	}
   404  	graph, diags := builder.Build(addrs.RootModuleInstance)
   405  	assertNoDiagnostics(t, diags)
   406  
   407  	// At this point, thanks to pruneUnusedNodesTransformer, we should still
   408  	// have the node for the output value, but the "test.a (expand)" node
   409  	// should've been pruned in recognition of the fact that we're performing
   410  	// a destroy and therefore we only need the "test.a (destroy)" node.
   411  
   412  	nodesByName := make(map[string]dag.Vertex)
   413  	nodesByResourceExpand := make(map[string]dag.Vertex)
   414  	for _, n := range graph.Vertices() {
   415  		name := dag.VertexName(n)
   416  		if _, exists := nodesByName[name]; exists {
   417  			t.Fatalf("multiple nodes have name %q", name)
   418  		}
   419  		nodesByName[name] = n
   420  
   421  		if exp, ok := n.(*nodeExpandApplyableResource); ok {
   422  			addr := exp.Addr
   423  			if _, exists := nodesByResourceExpand[addr.String()]; exists {
   424  				t.Fatalf("multiple nodes are expanders for %s", addr)
   425  			}
   426  			nodesByResourceExpand[addr.String()] = exp
   427  		}
   428  	}
   429  
   430  	// NOTE: The following is sensitive to the current name string formats we
   431  	// use for these particular node types. These names are not contractual
   432  	// so if this breaks in future it is fine to update these names to the new
   433  	// names as long as you verify first that the new names correspond to
   434  	// the same meaning as what we're assuming below.
   435  	if _, exists := nodesByName["test.a (destroy)"]; !exists {
   436  		t.Errorf("missing destroy node for resource instance test.a")
   437  	}
   438  	if _, exists := nodesByName["output.test (expand)"]; !exists {
   439  		t.Errorf("missing expand for output value 'test'")
   440  	}
   441  
   442  	// We _must not_ have any node that expands a resource.
   443  	if len(nodesByResourceExpand) != 0 {
   444  		t.Errorf("resource expand nodes remain the graph after transform; should've been pruned\n%s", spew.Sdump(nodesByResourceExpand))
   445  	}
   446  }
   447  
   448  // NoOp changes should not be participating in the destroy sequence
   449  func TestDestroyEdgeTransformer_noOp(t *testing.T) {
   450  	g := Graph{Path: addrs.RootModuleInstance}
   451  	g.Add(testDestroyNode("test_object.A"))
   452  	g.Add(testUpdateNode("test_object.B"))
   453  	g.Add(testDestroyNode("test_object.C"))
   454  
   455  	state := states.NewState()
   456  	root := state.EnsureModule(addrs.RootModuleInstance)
   457  	root.SetResourceInstanceCurrent(
   458  		mustResourceInstanceAddr("test_object.A").Resource,
   459  		&states.ResourceInstanceObjectSrc{
   460  			Status:    states.ObjectReady,
   461  			AttrsJSON: []byte(`{"id":"A"}`),
   462  		},
   463  		mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
   464  	)
   465  	root.SetResourceInstanceCurrent(
   466  		mustResourceInstanceAddr("test_object.B").Resource,
   467  		&states.ResourceInstanceObjectSrc{
   468  			Status:       states.ObjectReady,
   469  			AttrsJSON:    []byte(`{"id":"B","test_string":"x"}`),
   470  			Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A")},
   471  		},
   472  		mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
   473  	)
   474  	root.SetResourceInstanceCurrent(
   475  		mustResourceInstanceAddr("test_object.C").Resource,
   476  		&states.ResourceInstanceObjectSrc{
   477  			Status:    states.ObjectReady,
   478  			AttrsJSON: []byte(`{"id":"C","test_string":"x"}`),
   479  			Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("test_object.A"),
   480  				mustConfigResourceAddr("test_object.B")},
   481  		},
   482  		mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
   483  	)
   484  
   485  	if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil {
   486  		t.Fatal(err)
   487  	}
   488  
   489  	tf := &DestroyEdgeTransformer{
   490  		// We only need a minimal object to indicate GraphNodeCreator change is
   491  		// a NoOp here.
   492  		Changes: &plans.Changes{
   493  			Resources: []*plans.ResourceInstanceChangeSrc{
   494  				{
   495  					Addr:      mustResourceInstanceAddr("test_object.B"),
   496  					ChangeSrc: plans.ChangeSrc{Action: plans.NoOp},
   497  				},
   498  			},
   499  		},
   500  	}
   501  	if err := tf.Transform(&g); err != nil {
   502  		t.Fatalf("err: %s", err)
   503  	}
   504  
   505  	expected := strings.TrimSpace(`
   506  test_object.A (destroy)
   507    test_object.C (destroy)
   508  test_object.B
   509  test_object.C (destroy)`)
   510  
   511  	actual := strings.TrimSpace(g.String())
   512  	if actual != expected {
   513  		t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
   514  	}
   515  }
   516  
   517  func TestDestroyEdgeTransformer_dataDependsOn(t *testing.T) {
   518  	g := Graph{Path: addrs.RootModuleInstance}
   519  
   520  	addrA := mustResourceInstanceAddr("test_object.A")
   521  	instA := NewNodeAbstractResourceInstance(addrA)
   522  	a := &NodeDestroyResourceInstance{NodeAbstractResourceInstance: instA}
   523  	g.Add(a)
   524  
   525  	// B here represents a data sources, which is effectively an update during
   526  	// apply, but won't have dependencies stored in the state.
   527  	addrB := mustResourceInstanceAddr("test_object.B")
   528  	instB := NewNodeAbstractResourceInstance(addrB)
   529  	instB.Dependencies = append(instB.Dependencies, addrA.ConfigResource())
   530  	b := &NodeApplyableResourceInstance{NodeAbstractResourceInstance: instB}
   531  
   532  	g.Add(b)
   533  
   534  	state := states.NewState()
   535  	root := state.EnsureModule(addrs.RootModuleInstance)
   536  	root.SetResourceInstanceCurrent(
   537  		mustResourceInstanceAddr("test_object.A").Resource,
   538  		&states.ResourceInstanceObjectSrc{
   539  			Status:    states.ObjectReady,
   540  			AttrsJSON: []byte(`{"id":"A"}`),
   541  		},
   542  		mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`),
   543  	)
   544  
   545  	if err := (&AttachStateTransformer{State: state}).Transform(&g); err != nil {
   546  		t.Fatal(err)
   547  	}
   548  
   549  	tf := &DestroyEdgeTransformer{}
   550  	if err := tf.Transform(&g); err != nil {
   551  		t.Fatalf("err: %s", err)
   552  	}
   553  
   554  	actual := strings.TrimSpace(g.String())
   555  	expected := strings.TrimSpace(`
   556  test_object.A (destroy)
   557  test_object.B
   558    test_object.A (destroy)
   559  `)
   560  	if actual != expected {
   561  		t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected)
   562  	}
   563  }
   564  
   565  func testDestroyNode(addrString string) GraphNodeDestroyer {
   566  	instAddr := mustResourceInstanceAddr(addrString)
   567  	inst := NewNodeAbstractResourceInstance(instAddr)
   568  	return &NodeDestroyResourceInstance{NodeAbstractResourceInstance: inst}
   569  }
   570  
   571  func testUpdateNode(addrString string) GraphNodeCreator {
   572  	instAddr := mustResourceInstanceAddr(addrString)
   573  	inst := NewNodeAbstractResourceInstance(instAddr)
   574  	return &NodeApplyableResourceInstance{NodeAbstractResourceInstance: inst}
   575  }
   576  
   577  const testTransformDestroyEdgeBasicStr = `
   578  test_object.A (destroy)
   579    test_object.B (destroy)
   580  test_object.B (destroy)
   581  `
   582  
   583  const testTransformDestroyEdgeMultiStr = `
   584  test_object.A (destroy)
   585    test_object.B (destroy)
   586    test_object.C (destroy)
   587  test_object.B (destroy)
   588    test_object.C (destroy)
   589  test_object.C (destroy)
   590  `
   591  
   592  const testTransformDestroyEdgeSelfRefStr = `
   593  test_object.A (destroy)
   594  `
   595  
   596  const testTransformDestroyEdgeModuleStr = `
   597  module.child.test_object.b (destroy)
   598    test_object.a (destroy)
   599  test_object.a (destroy)
   600  `