github.com/opentofu/opentofu@v1.7.1/internal/command/views/operation_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 views
     7  
     8  import (
     9  	"bytes"
    10  	"encoding/json"
    11  	"strings"
    12  	"testing"
    13  
    14  	"github.com/opentofu/opentofu/internal/addrs"
    15  	"github.com/opentofu/opentofu/internal/command/arguments"
    16  	"github.com/opentofu/opentofu/internal/encryption"
    17  	"github.com/opentofu/opentofu/internal/lang/globalref"
    18  	"github.com/opentofu/opentofu/internal/plans"
    19  	"github.com/opentofu/opentofu/internal/states"
    20  	"github.com/opentofu/opentofu/internal/states/statefile"
    21  	"github.com/opentofu/opentofu/internal/terminal"
    22  	"github.com/opentofu/opentofu/internal/tofu"
    23  	"github.com/zclconf/go-cty/cty"
    24  )
    25  
    26  func TestOperation_stopping(t *testing.T) {
    27  	streams, done := terminal.StreamsForTesting(t)
    28  	v := NewOperation(arguments.ViewHuman, false, NewView(streams))
    29  
    30  	v.Stopping()
    31  
    32  	if got, want := done(t).Stdout(), "Stopping operation...\n"; got != want {
    33  		t.Errorf("wrong result\ngot:  %q\nwant: %q", got, want)
    34  	}
    35  }
    36  
    37  func TestOperation_cancelled(t *testing.T) {
    38  	testCases := map[string]struct {
    39  		planMode plans.Mode
    40  		want     string
    41  	}{
    42  		"apply": {
    43  			planMode: plans.NormalMode,
    44  			want:     "Apply cancelled.\n",
    45  		},
    46  		"destroy": {
    47  			planMode: plans.DestroyMode,
    48  			want:     "Destroy cancelled.\n",
    49  		},
    50  	}
    51  	for name, tc := range testCases {
    52  		t.Run(name, func(t *testing.T) {
    53  			streams, done := terminal.StreamsForTesting(t)
    54  			v := NewOperation(arguments.ViewHuman, false, NewView(streams))
    55  
    56  			v.Cancelled(tc.planMode)
    57  
    58  			if got, want := done(t).Stdout(), tc.want; got != want {
    59  				t.Errorf("wrong result\ngot:  %q\nwant: %q", got, want)
    60  			}
    61  		})
    62  	}
    63  }
    64  
    65  func TestOperation_emergencyDumpState(t *testing.T) {
    66  	streams, done := terminal.StreamsForTesting(t)
    67  	v := NewOperation(arguments.ViewHuman, false, NewView(streams))
    68  
    69  	stateFile := statefile.New(nil, "foo", 1)
    70  
    71  	err := v.EmergencyDumpState(stateFile, encryption.StateEncryptionDisabled())
    72  	if err != nil {
    73  		t.Fatalf("unexpected error dumping state: %s", err)
    74  	}
    75  
    76  	// Check that the result (on stderr) looks like JSON state
    77  	raw := done(t).Stderr()
    78  	var state map[string]interface{}
    79  	if err := json.Unmarshal([]byte(raw), &state); err != nil {
    80  		t.Fatalf("unexpected error parsing dumped state: %s\nraw:\n%s", err, raw)
    81  	}
    82  }
    83  
    84  func TestOperation_planNoChanges(t *testing.T) {
    85  
    86  	tests := map[string]struct {
    87  		plan     func(schemas *tofu.Schemas) *plans.Plan
    88  		wantText string
    89  	}{
    90  		"nothing at all in normal mode": {
    91  			func(schemas *tofu.Schemas) *plans.Plan {
    92  				return &plans.Plan{
    93  					UIMode:  plans.NormalMode,
    94  					Changes: plans.NewChanges(),
    95  				}
    96  			},
    97  			"no differences, so no changes are needed.",
    98  		},
    99  		"nothing at all in refresh-only mode": {
   100  			func(schemas *tofu.Schemas) *plans.Plan {
   101  				return &plans.Plan{
   102  					UIMode:  plans.RefreshOnlyMode,
   103  					Changes: plans.NewChanges(),
   104  				}
   105  			},
   106  			"OpenTofu has checked that the real remote objects still match",
   107  		},
   108  		"nothing at all in destroy mode": {
   109  			func(schemas *tofu.Schemas) *plans.Plan {
   110  				return &plans.Plan{
   111  					UIMode:  plans.DestroyMode,
   112  					Changes: plans.NewChanges(),
   113  				}
   114  			},
   115  			"No objects need to be destroyed.",
   116  		},
   117  		"no drift detected in normal noop": {
   118  			func(schemas *tofu.Schemas) *plans.Plan {
   119  				addr := addrs.Resource{
   120  					Mode: addrs.ManagedResourceMode,
   121  					Type: "test_resource",
   122  					Name: "somewhere",
   123  				}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
   124  				schema, _ := schemas.ResourceTypeConfig(
   125  					addrs.NewDefaultProvider("test"),
   126  					addr.Resource.Resource.Mode,
   127  					addr.Resource.Resource.Type,
   128  				)
   129  				ty := schema.ImpliedType()
   130  				rc := &plans.ResourceInstanceChange{
   131  					Addr:        addr,
   132  					PrevRunAddr: addr,
   133  					ProviderAddr: addrs.RootModuleInstance.ProviderConfigDefault(
   134  						addrs.NewDefaultProvider("test"),
   135  					),
   136  					Change: plans.Change{
   137  						Action: plans.Update,
   138  						Before: cty.NullVal(ty),
   139  						After: cty.ObjectVal(map[string]cty.Value{
   140  							"id":  cty.StringVal("1234"),
   141  							"foo": cty.StringVal("bar"),
   142  						}),
   143  					},
   144  				}
   145  				rcs, err := rc.Encode(ty)
   146  				if err != nil {
   147  					panic(err)
   148  				}
   149  				drs := []*plans.ResourceInstanceChangeSrc{rcs}
   150  				return &plans.Plan{
   151  					UIMode:           plans.NormalMode,
   152  					Changes:          plans.NewChanges(),
   153  					DriftedResources: drs,
   154  				}
   155  			},
   156  			"No changes",
   157  		},
   158  		"drift detected in normal mode": {
   159  			func(schemas *tofu.Schemas) *plans.Plan {
   160  				addr := addrs.Resource{
   161  					Mode: addrs.ManagedResourceMode,
   162  					Type: "test_resource",
   163  					Name: "somewhere",
   164  				}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
   165  				schema, _ := schemas.ResourceTypeConfig(
   166  					addrs.NewDefaultProvider("test"),
   167  					addr.Resource.Resource.Mode,
   168  					addr.Resource.Resource.Type,
   169  				)
   170  				ty := schema.ImpliedType()
   171  				rc := &plans.ResourceInstanceChange{
   172  					Addr:        addr,
   173  					PrevRunAddr: addr,
   174  					ProviderAddr: addrs.RootModuleInstance.ProviderConfigDefault(
   175  						addrs.NewDefaultProvider("test"),
   176  					),
   177  					Change: plans.Change{
   178  						Action: plans.Update,
   179  						Before: cty.NullVal(ty),
   180  						After: cty.ObjectVal(map[string]cty.Value{
   181  							"id":  cty.StringVal("1234"),
   182  							"foo": cty.StringVal("bar"),
   183  						}),
   184  					},
   185  				}
   186  				rcs, err := rc.Encode(ty)
   187  				if err != nil {
   188  					panic(err)
   189  				}
   190  				drs := []*plans.ResourceInstanceChangeSrc{rcs}
   191  				changes := plans.NewChanges()
   192  				changes.Resources = drs
   193  				return &plans.Plan{
   194  					UIMode:           plans.NormalMode,
   195  					Changes:          changes,
   196  					DriftedResources: drs,
   197  					RelevantAttributes: []globalref.ResourceAttr{{
   198  						Resource: addr,
   199  						Attr:     cty.GetAttrPath("id"),
   200  					}},
   201  				}
   202  			},
   203  			"Objects have changed outside of OpenTofu",
   204  		},
   205  		"drift detected in refresh-only mode": {
   206  			func(schemas *tofu.Schemas) *plans.Plan {
   207  				addr := addrs.Resource{
   208  					Mode: addrs.ManagedResourceMode,
   209  					Type: "test_resource",
   210  					Name: "somewhere",
   211  				}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
   212  				schema, _ := schemas.ResourceTypeConfig(
   213  					addrs.NewDefaultProvider("test"),
   214  					addr.Resource.Resource.Mode,
   215  					addr.Resource.Resource.Type,
   216  				)
   217  				ty := schema.ImpliedType()
   218  				rc := &plans.ResourceInstanceChange{
   219  					Addr:        addr,
   220  					PrevRunAddr: addr,
   221  					ProviderAddr: addrs.RootModuleInstance.ProviderConfigDefault(
   222  						addrs.NewDefaultProvider("test"),
   223  					),
   224  					Change: plans.Change{
   225  						Action: plans.Update,
   226  						Before: cty.NullVal(ty),
   227  						After: cty.ObjectVal(map[string]cty.Value{
   228  							"id":  cty.StringVal("1234"),
   229  							"foo": cty.StringVal("bar"),
   230  						}),
   231  					},
   232  				}
   233  				rcs, err := rc.Encode(ty)
   234  				if err != nil {
   235  					panic(err)
   236  				}
   237  				drs := []*plans.ResourceInstanceChangeSrc{rcs}
   238  				return &plans.Plan{
   239  					UIMode:           plans.RefreshOnlyMode,
   240  					Changes:          plans.NewChanges(),
   241  					DriftedResources: drs,
   242  				}
   243  			},
   244  			"If you were expecting these changes then you can apply this plan",
   245  		},
   246  		"move-only changes in refresh-only mode": {
   247  			func(schemas *tofu.Schemas) *plans.Plan {
   248  				addr := addrs.Resource{
   249  					Mode: addrs.ManagedResourceMode,
   250  					Type: "test_resource",
   251  					Name: "somewhere",
   252  				}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
   253  				addrPrev := addrs.Resource{
   254  					Mode: addrs.ManagedResourceMode,
   255  					Type: "test_resource",
   256  					Name: "anywhere",
   257  				}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
   258  				schema, _ := schemas.ResourceTypeConfig(
   259  					addrs.NewDefaultProvider("test"),
   260  					addr.Resource.Resource.Mode,
   261  					addr.Resource.Resource.Type,
   262  				)
   263  				ty := schema.ImpliedType()
   264  				rc := &plans.ResourceInstanceChange{
   265  					Addr:        addr,
   266  					PrevRunAddr: addrPrev,
   267  					ProviderAddr: addrs.RootModuleInstance.ProviderConfigDefault(
   268  						addrs.NewDefaultProvider("test"),
   269  					),
   270  					Change: plans.Change{
   271  						Action: plans.NoOp,
   272  						Before: cty.ObjectVal(map[string]cty.Value{
   273  							"id":  cty.StringVal("1234"),
   274  							"foo": cty.StringVal("bar"),
   275  						}),
   276  						After: cty.ObjectVal(map[string]cty.Value{
   277  							"id":  cty.StringVal("1234"),
   278  							"foo": cty.StringVal("bar"),
   279  						}),
   280  					},
   281  				}
   282  				rcs, err := rc.Encode(ty)
   283  				if err != nil {
   284  					panic(err)
   285  				}
   286  				drs := []*plans.ResourceInstanceChangeSrc{rcs}
   287  				return &plans.Plan{
   288  					UIMode:           plans.RefreshOnlyMode,
   289  					Changes:          plans.NewChanges(),
   290  					DriftedResources: drs,
   291  				}
   292  			},
   293  			"test_resource.anywhere has moved to test_resource.somewhere",
   294  		},
   295  		"drift detected in destroy mode": {
   296  			func(schemas *tofu.Schemas) *plans.Plan {
   297  				return &plans.Plan{
   298  					UIMode:  plans.DestroyMode,
   299  					Changes: plans.NewChanges(),
   300  					PrevRunState: states.BuildState(func(state *states.SyncState) {
   301  						state.SetResourceInstanceCurrent(
   302  							addrs.Resource{
   303  								Mode: addrs.ManagedResourceMode,
   304  								Type: "test_resource",
   305  								Name: "somewhere",
   306  							}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
   307  							&states.ResourceInstanceObjectSrc{
   308  								Status:    states.ObjectReady,
   309  								AttrsJSON: []byte(`{}`),
   310  							},
   311  							addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   312  						)
   313  					}),
   314  					PriorState: states.NewState(),
   315  				}
   316  			},
   317  			"No objects need to be destroyed.",
   318  		},
   319  	}
   320  
   321  	schemas := testSchemas()
   322  	for name, test := range tests {
   323  		t.Run(name, func(t *testing.T) {
   324  			streams, done := terminal.StreamsForTesting(t)
   325  			v := NewOperation(arguments.ViewHuman, false, NewView(streams))
   326  			plan := test.plan(schemas)
   327  			v.Plan(plan, schemas)
   328  			got := done(t).Stdout()
   329  			if want := test.wantText; want != "" && !strings.Contains(got, want) {
   330  				t.Errorf("missing expected message\ngot:\n%s\n\nwant substring: %s", got, want)
   331  			}
   332  		})
   333  	}
   334  }
   335  
   336  func TestOperation_plan(t *testing.T) {
   337  	streams, done := terminal.StreamsForTesting(t)
   338  	v := NewOperation(arguments.ViewHuman, true, NewView(streams))
   339  
   340  	plan := testPlan(t)
   341  	schemas := testSchemas()
   342  	v.Plan(plan, schemas)
   343  
   344  	want := `
   345  OpenTofu used the selected providers to generate the following execution
   346  plan. Resource actions are indicated with the following symbols:
   347    + create
   348  
   349  OpenTofu will perform the following actions:
   350  
   351    # test_resource.foo will be created
   352    + resource "test_resource" "foo" {
   353        + foo = "bar"
   354        + id  = (known after apply)
   355      }
   356  
   357  Plan: 1 to add, 0 to change, 0 to destroy.
   358  `
   359  
   360  	if got := done(t).Stdout(); got != want {
   361  		t.Errorf("unexpected output\ngot:\n%s\nwant:\n%s", got, want)
   362  	}
   363  }
   364  
   365  func TestOperation_planWithDatasource(t *testing.T) {
   366  	streams, done := terminal.StreamsForTesting(t)
   367  	v := NewOperation(arguments.ViewHuman, true, NewView(streams))
   368  
   369  	plan := testPlanWithDatasource(t)
   370  	schemas := testSchemas()
   371  	v.Plan(plan, schemas)
   372  
   373  	want := `
   374  OpenTofu used the selected providers to generate the following execution
   375  plan. Resource actions are indicated with the following symbols:
   376    + create
   377   <= read (data resources)
   378  
   379  OpenTofu will perform the following actions:
   380  
   381    # data.test_data_source.bar will be read during apply
   382   <= data "test_data_source" "bar" {
   383        + bar = "foo"
   384        + id  = "C6743020-40BD-4591-81E6-CD08494341D3"
   385      }
   386  
   387    # test_resource.foo will be created
   388    + resource "test_resource" "foo" {
   389        + foo = "bar"
   390        + id  = (known after apply)
   391      }
   392  
   393  Plan: 1 to add, 0 to change, 0 to destroy.
   394  `
   395  
   396  	if got := done(t).Stdout(); got != want {
   397  		t.Errorf("unexpected output\ngot:\n%s\nwant:\n%s", got, want)
   398  	}
   399  }
   400  
   401  func TestOperation_planWithDatasourceAndDrift(t *testing.T) {
   402  	streams, done := terminal.StreamsForTesting(t)
   403  	v := NewOperation(arguments.ViewHuman, true, NewView(streams))
   404  
   405  	plan := testPlanWithDatasource(t)
   406  	schemas := testSchemas()
   407  	v.Plan(plan, schemas)
   408  
   409  	want := `
   410  OpenTofu used the selected providers to generate the following execution
   411  plan. Resource actions are indicated with the following symbols:
   412    + create
   413   <= read (data resources)
   414  
   415  OpenTofu will perform the following actions:
   416  
   417    # data.test_data_source.bar will be read during apply
   418   <= data "test_data_source" "bar" {
   419        + bar = "foo"
   420        + id  = "C6743020-40BD-4591-81E6-CD08494341D3"
   421      }
   422  
   423    # test_resource.foo will be created
   424    + resource "test_resource" "foo" {
   425        + foo = "bar"
   426        + id  = (known after apply)
   427      }
   428  
   429  Plan: 1 to add, 0 to change, 0 to destroy.
   430  `
   431  
   432  	if got := done(t).Stdout(); got != want {
   433  		t.Errorf("unexpected output\ngot:\n%s\nwant:\n%s", got, want)
   434  	}
   435  }
   436  
   437  func TestOperation_planNextStep(t *testing.T) {
   438  	testCases := map[string]struct {
   439  		path string
   440  		want string
   441  	}{
   442  		"no state path": {
   443  			path: "",
   444  			want: "You didn't use the -out option",
   445  		},
   446  		"state path": {
   447  			path: "good plan.tfplan",
   448  			want: `tofu apply "good plan.tfplan"`,
   449  		},
   450  	}
   451  	for name, tc := range testCases {
   452  		t.Run(name, func(t *testing.T) {
   453  			streams, done := terminal.StreamsForTesting(t)
   454  			v := NewOperation(arguments.ViewHuman, false, NewView(streams))
   455  
   456  			v.PlanNextStep(tc.path, "")
   457  
   458  			if got := done(t).Stdout(); !strings.Contains(got, tc.want) {
   459  				t.Errorf("wrong result\ngot:  %q\nwant: %q", got, tc.want)
   460  			}
   461  		})
   462  	}
   463  }
   464  
   465  // The in-automation state is on the view itself, so testing it separately is
   466  // clearer.
   467  func TestOperation_planNextStepInAutomation(t *testing.T) {
   468  	streams, done := terminal.StreamsForTesting(t)
   469  	v := NewOperation(arguments.ViewHuman, true, NewView(streams))
   470  
   471  	v.PlanNextStep("", "")
   472  
   473  	if got := done(t).Stdout(); got != "" {
   474  		t.Errorf("unexpected output\ngot: %q", got)
   475  	}
   476  }
   477  
   478  // Test all the trivial OperationJSON methods together. Y'know, for brevity.
   479  // This test is not a realistic stream of messages.
   480  func TestOperationJSON_logs(t *testing.T) {
   481  	streams, done := terminal.StreamsForTesting(t)
   482  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
   483  
   484  	v.Cancelled(plans.NormalMode)
   485  	v.Cancelled(plans.DestroyMode)
   486  	v.Stopping()
   487  	v.Interrupted()
   488  	v.FatalInterrupt()
   489  
   490  	want := []map[string]interface{}{
   491  		{
   492  			"@level":   "info",
   493  			"@message": "Apply cancelled",
   494  			"@module":  "tofu.ui",
   495  			"type":     "log",
   496  		},
   497  		{
   498  			"@level":   "info",
   499  			"@message": "Destroy cancelled",
   500  			"@module":  "tofu.ui",
   501  			"type":     "log",
   502  		},
   503  		{
   504  			"@level":   "info",
   505  			"@message": "Stopping operation...",
   506  			"@module":  "tofu.ui",
   507  			"type":     "log",
   508  		},
   509  		{
   510  			"@level":   "info",
   511  			"@message": interrupted,
   512  			"@module":  "tofu.ui",
   513  			"type":     "log",
   514  		},
   515  		{
   516  			"@level":   "info",
   517  			"@message": fatalInterrupt,
   518  			"@module":  "tofu.ui",
   519  			"type":     "log",
   520  		},
   521  	}
   522  
   523  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   524  }
   525  
   526  // This is a fairly circular test, but it's such a rarely executed code path
   527  // that I think it's probably still worth having. We're not testing against
   528  // a fixed state JSON output because this test ought not fail just because
   529  // we upgrade state format in the future.
   530  func TestOperationJSON_emergencyDumpState(t *testing.T) {
   531  	streams, done := terminal.StreamsForTesting(t)
   532  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
   533  
   534  	stateFile := statefile.New(nil, "foo", 1)
   535  	stateBuf := new(bytes.Buffer)
   536  	err := statefile.Write(stateFile, stateBuf, encryption.StateEncryptionDisabled())
   537  	if err != nil {
   538  		t.Fatal(err)
   539  	}
   540  	var stateJSON map[string]interface{}
   541  	err = json.Unmarshal(stateBuf.Bytes(), &stateJSON)
   542  	if err != nil {
   543  		t.Fatal(err)
   544  	}
   545  
   546  	err = v.EmergencyDumpState(stateFile, encryption.StateEncryptionDisabled())
   547  	if err != nil {
   548  		t.Fatalf("unexpected error dumping state: %s", err)
   549  	}
   550  
   551  	want := []map[string]interface{}{
   552  		{
   553  			"@level":   "info",
   554  			"@message": "Emergency state dump",
   555  			"@module":  "tofu.ui",
   556  			"type":     "log",
   557  			"state":    stateJSON,
   558  		},
   559  	}
   560  
   561  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   562  }
   563  
   564  func TestOperationJSON_planNoChanges(t *testing.T) {
   565  	streams, done := terminal.StreamsForTesting(t)
   566  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
   567  
   568  	plan := &plans.Plan{
   569  		Changes: plans.NewChanges(),
   570  	}
   571  	v.Plan(plan, nil)
   572  
   573  	want := []map[string]interface{}{
   574  		{
   575  			"@level":   "info",
   576  			"@message": "Plan: 0 to add, 0 to change, 0 to destroy.",
   577  			"@module":  "tofu.ui",
   578  			"type":     "change_summary",
   579  			"changes": map[string]interface{}{
   580  				"operation": "plan",
   581  				"add":       float64(0),
   582  				"import":    float64(0),
   583  				"change":    float64(0),
   584  				"remove":    float64(0),
   585  			},
   586  		},
   587  	}
   588  
   589  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   590  }
   591  
   592  func TestOperationJSON_plan(t *testing.T) {
   593  	streams, done := terminal.StreamsForTesting(t)
   594  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
   595  
   596  	root := addrs.RootModuleInstance
   597  	vpc, diags := addrs.ParseModuleInstanceStr("module.vpc")
   598  	if len(diags) > 0 {
   599  		t.Fatal(diags.Err())
   600  	}
   601  	boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "boop"}
   602  	beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "beep"}
   603  	derp := addrs.Resource{Mode: addrs.DataResourceMode, Type: "test_source", Name: "derp"}
   604  
   605  	plan := &plans.Plan{
   606  		Changes: &plans.Changes{
   607  			Resources: []*plans.ResourceInstanceChangeSrc{
   608  				{
   609  					Addr:        boop.Instance(addrs.IntKey(0)).Absolute(root),
   610  					PrevRunAddr: boop.Instance(addrs.IntKey(0)).Absolute(root),
   611  					ChangeSrc:   plans.ChangeSrc{Action: plans.CreateThenDelete},
   612  				},
   613  				{
   614  					Addr:        boop.Instance(addrs.IntKey(1)).Absolute(root),
   615  					PrevRunAddr: boop.Instance(addrs.IntKey(1)).Absolute(root),
   616  					ChangeSrc:   plans.ChangeSrc{Action: plans.Create},
   617  				},
   618  				{
   619  					Addr:        boop.Instance(addrs.IntKey(0)).Absolute(vpc),
   620  					PrevRunAddr: boop.Instance(addrs.IntKey(0)).Absolute(vpc),
   621  					ChangeSrc:   plans.ChangeSrc{Action: plans.Delete},
   622  				},
   623  				{
   624  					Addr:        beep.Instance(addrs.NoKey).Absolute(root),
   625  					PrevRunAddr: beep.Instance(addrs.NoKey).Absolute(root),
   626  					ChangeSrc:   plans.ChangeSrc{Action: plans.DeleteThenCreate},
   627  				},
   628  				{
   629  					Addr:        beep.Instance(addrs.NoKey).Absolute(vpc),
   630  					PrevRunAddr: beep.Instance(addrs.NoKey).Absolute(vpc),
   631  					ChangeSrc:   plans.ChangeSrc{Action: plans.Update},
   632  				},
   633  				// Data source deletion should not show up in the logs
   634  				{
   635  					Addr:        derp.Instance(addrs.NoKey).Absolute(root),
   636  					PrevRunAddr: derp.Instance(addrs.NoKey).Absolute(root),
   637  					ChangeSrc:   plans.ChangeSrc{Action: plans.Delete},
   638  				},
   639  			},
   640  		},
   641  	}
   642  	v.Plan(plan, testSchemas())
   643  
   644  	want := []map[string]interface{}{
   645  		// Create-then-delete should result in replace
   646  		{
   647  			"@level":   "info",
   648  			"@message": "test_resource.boop[0]: Plan to replace",
   649  			"@module":  "tofu.ui",
   650  			"type":     "planned_change",
   651  			"change": map[string]interface{}{
   652  				"action": "replace",
   653  				"resource": map[string]interface{}{
   654  					"addr":             `test_resource.boop[0]`,
   655  					"implied_provider": "test",
   656  					"module":           "",
   657  					"resource":         `test_resource.boop[0]`,
   658  					"resource_key":     float64(0),
   659  					"resource_name":    "boop",
   660  					"resource_type":    "test_resource",
   661  				},
   662  			},
   663  		},
   664  		// Simple create
   665  		{
   666  			"@level":   "info",
   667  			"@message": "test_resource.boop[1]: Plan to create",
   668  			"@module":  "tofu.ui",
   669  			"type":     "planned_change",
   670  			"change": map[string]interface{}{
   671  				"action": "create",
   672  				"resource": map[string]interface{}{
   673  					"addr":             `test_resource.boop[1]`,
   674  					"implied_provider": "test",
   675  					"module":           "",
   676  					"resource":         `test_resource.boop[1]`,
   677  					"resource_key":     float64(1),
   678  					"resource_name":    "boop",
   679  					"resource_type":    "test_resource",
   680  				},
   681  			},
   682  		},
   683  		// Simple delete
   684  		{
   685  			"@level":   "info",
   686  			"@message": "module.vpc.test_resource.boop[0]: Plan to delete",
   687  			"@module":  "tofu.ui",
   688  			"type":     "planned_change",
   689  			"change": map[string]interface{}{
   690  				"action": "delete",
   691  				"resource": map[string]interface{}{
   692  					"addr":             `module.vpc.test_resource.boop[0]`,
   693  					"implied_provider": "test",
   694  					"module":           "module.vpc",
   695  					"resource":         `test_resource.boop[0]`,
   696  					"resource_key":     float64(0),
   697  					"resource_name":    "boop",
   698  					"resource_type":    "test_resource",
   699  				},
   700  			},
   701  		},
   702  		// Delete-then-create is also a replace
   703  		{
   704  			"@level":   "info",
   705  			"@message": "test_resource.beep: Plan to replace",
   706  			"@module":  "tofu.ui",
   707  			"type":     "planned_change",
   708  			"change": map[string]interface{}{
   709  				"action": "replace",
   710  				"resource": map[string]interface{}{
   711  					"addr":             `test_resource.beep`,
   712  					"implied_provider": "test",
   713  					"module":           "",
   714  					"resource":         `test_resource.beep`,
   715  					"resource_key":     nil,
   716  					"resource_name":    "beep",
   717  					"resource_type":    "test_resource",
   718  				},
   719  			},
   720  		},
   721  		// Simple update
   722  		{
   723  			"@level":   "info",
   724  			"@message": "module.vpc.test_resource.beep: Plan to update",
   725  			"@module":  "tofu.ui",
   726  			"type":     "planned_change",
   727  			"change": map[string]interface{}{
   728  				"action": "update",
   729  				"resource": map[string]interface{}{
   730  					"addr":             `module.vpc.test_resource.beep`,
   731  					"implied_provider": "test",
   732  					"module":           "module.vpc",
   733  					"resource":         `test_resource.beep`,
   734  					"resource_key":     nil,
   735  					"resource_name":    "beep",
   736  					"resource_type":    "test_resource",
   737  				},
   738  			},
   739  		},
   740  		// These counts are 3 add/1 change/3 destroy because the replace
   741  		// changes result in both add and destroy counts.
   742  		{
   743  			"@level":   "info",
   744  			"@message": "Plan: 3 to add, 1 to change, 3 to destroy.",
   745  			"@module":  "tofu.ui",
   746  			"type":     "change_summary",
   747  			"changes": map[string]interface{}{
   748  				"operation": "plan",
   749  				"add":       float64(3),
   750  				"import":    float64(0),
   751  				"change":    float64(1),
   752  				"remove":    float64(3),
   753  			},
   754  		},
   755  	}
   756  
   757  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   758  }
   759  
   760  func TestOperationJSON_planWithImport(t *testing.T) {
   761  	streams, done := terminal.StreamsForTesting(t)
   762  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
   763  
   764  	root := addrs.RootModuleInstance
   765  	vpc, diags := addrs.ParseModuleInstanceStr("module.vpc")
   766  	if len(diags) > 0 {
   767  		t.Fatal(diags.Err())
   768  	}
   769  	boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "boop"}
   770  	beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "beep"}
   771  
   772  	plan := &plans.Plan{
   773  		Changes: &plans.Changes{
   774  			Resources: []*plans.ResourceInstanceChangeSrc{
   775  				{
   776  					Addr:        boop.Instance(addrs.IntKey(0)).Absolute(vpc),
   777  					PrevRunAddr: boop.Instance(addrs.IntKey(0)).Absolute(vpc),
   778  					ChangeSrc:   plans.ChangeSrc{Action: plans.NoOp, Importing: &plans.ImportingSrc{ID: "DECD6D77"}},
   779  				},
   780  				{
   781  					Addr:        boop.Instance(addrs.IntKey(1)).Absolute(vpc),
   782  					PrevRunAddr: boop.Instance(addrs.IntKey(1)).Absolute(vpc),
   783  					ChangeSrc:   plans.ChangeSrc{Action: plans.Delete, Importing: &plans.ImportingSrc{ID: "DECD6D77"}},
   784  				},
   785  				{
   786  					Addr:        boop.Instance(addrs.IntKey(0)).Absolute(root),
   787  					PrevRunAddr: boop.Instance(addrs.IntKey(0)).Absolute(root),
   788  					ChangeSrc:   plans.ChangeSrc{Action: plans.CreateThenDelete, Importing: &plans.ImportingSrc{ID: "DECD6D77"}},
   789  				},
   790  				{
   791  					Addr:        beep.Instance(addrs.NoKey).Absolute(root),
   792  					PrevRunAddr: beep.Instance(addrs.NoKey).Absolute(root),
   793  					ChangeSrc:   plans.ChangeSrc{Action: plans.Update, Importing: &plans.ImportingSrc{ID: "DECD6D77"}},
   794  				},
   795  			},
   796  		},
   797  	}
   798  	v.Plan(plan, testSchemas())
   799  
   800  	want := []map[string]interface{}{
   801  		// Simple import
   802  		{
   803  			"@level":   "info",
   804  			"@message": "module.vpc.test_resource.boop[0]: Plan to import",
   805  			"@module":  "tofu.ui",
   806  			"type":     "planned_change",
   807  			"change": map[string]interface{}{
   808  				"action": "import",
   809  				"resource": map[string]interface{}{
   810  					"addr":             `module.vpc.test_resource.boop[0]`,
   811  					"implied_provider": "test",
   812  					"module":           "module.vpc",
   813  					"resource":         `test_resource.boop[0]`,
   814  					"resource_key":     float64(0),
   815  					"resource_name":    "boop",
   816  					"resource_type":    "test_resource",
   817  				},
   818  				"importing": map[string]interface{}{
   819  					"id": "DECD6D77",
   820  				},
   821  			},
   822  		},
   823  		// Delete after importing
   824  		{
   825  			"@level":   "info",
   826  			"@message": "module.vpc.test_resource.boop[1]: Plan to delete",
   827  			"@module":  "tofu.ui",
   828  			"type":     "planned_change",
   829  			"change": map[string]interface{}{
   830  				"action": "delete",
   831  				"resource": map[string]interface{}{
   832  					"addr":             `module.vpc.test_resource.boop[1]`,
   833  					"implied_provider": "test",
   834  					"module":           "module.vpc",
   835  					"resource":         `test_resource.boop[1]`,
   836  					"resource_key":     float64(1),
   837  					"resource_name":    "boop",
   838  					"resource_type":    "test_resource",
   839  				},
   840  				"importing": map[string]interface{}{
   841  					"id": "DECD6D77",
   842  				},
   843  			},
   844  		},
   845  		// Create-then-delete after importing.
   846  		{
   847  			"@level":   "info",
   848  			"@message": "test_resource.boop[0]: Plan to replace",
   849  			"@module":  "tofu.ui",
   850  			"type":     "planned_change",
   851  			"change": map[string]interface{}{
   852  				"action": "replace",
   853  				"resource": map[string]interface{}{
   854  					"addr":             `test_resource.boop[0]`,
   855  					"implied_provider": "test",
   856  					"module":           "",
   857  					"resource":         `test_resource.boop[0]`,
   858  					"resource_key":     float64(0),
   859  					"resource_name":    "boop",
   860  					"resource_type":    "test_resource",
   861  				},
   862  				"importing": map[string]interface{}{
   863  					"id": "DECD6D77",
   864  				},
   865  			},
   866  		},
   867  		// Update after importing
   868  		{
   869  			"@level":   "info",
   870  			"@message": "test_resource.beep: Plan to update",
   871  			"@module":  "tofu.ui",
   872  			"type":     "planned_change",
   873  			"change": map[string]interface{}{
   874  				"action": "update",
   875  				"resource": map[string]interface{}{
   876  					"addr":             `test_resource.beep`,
   877  					"implied_provider": "test",
   878  					"module":           "",
   879  					"resource":         `test_resource.beep`,
   880  					"resource_key":     nil,
   881  					"resource_name":    "beep",
   882  					"resource_type":    "test_resource",
   883  				},
   884  				"importing": map[string]interface{}{
   885  					"id": "DECD6D77",
   886  				},
   887  			},
   888  		},
   889  		{
   890  			"@level":   "info",
   891  			"@message": "Plan: 4 to import, 1 to add, 1 to change, 2 to destroy.",
   892  			"@module":  "tofu.ui",
   893  			"type":     "change_summary",
   894  			"changes": map[string]interface{}{
   895  				"operation": "plan",
   896  				"add":       float64(1),
   897  				"import":    float64(4),
   898  				"change":    float64(1),
   899  				"remove":    float64(2),
   900  			},
   901  		},
   902  	}
   903  
   904  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   905  }
   906  
   907  func TestOperationJSON_planDriftWithMove(t *testing.T) {
   908  	streams, done := terminal.StreamsForTesting(t)
   909  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
   910  
   911  	root := addrs.RootModuleInstance
   912  	boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "boop"}
   913  	beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "beep"}
   914  	blep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "blep"}
   915  	honk := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "honk"}
   916  
   917  	plan := &plans.Plan{
   918  		UIMode: plans.NormalMode,
   919  		Changes: &plans.Changes{
   920  			Resources: []*plans.ResourceInstanceChangeSrc{
   921  				{
   922  					Addr:        honk.Instance(addrs.StringKey("bonk")).Absolute(root),
   923  					PrevRunAddr: honk.Instance(addrs.IntKey(0)).Absolute(root),
   924  					ChangeSrc:   plans.ChangeSrc{Action: plans.NoOp},
   925  				},
   926  			},
   927  		},
   928  		DriftedResources: []*plans.ResourceInstanceChangeSrc{
   929  			{
   930  				Addr:        beep.Instance(addrs.NoKey).Absolute(root),
   931  				PrevRunAddr: beep.Instance(addrs.NoKey).Absolute(root),
   932  				ChangeSrc:   plans.ChangeSrc{Action: plans.Delete},
   933  			},
   934  			{
   935  				Addr:        boop.Instance(addrs.NoKey).Absolute(root),
   936  				PrevRunAddr: blep.Instance(addrs.NoKey).Absolute(root),
   937  				ChangeSrc:   plans.ChangeSrc{Action: plans.Update},
   938  			},
   939  			// Move-only resource drift should not be present in normal mode plans
   940  			{
   941  				Addr:        honk.Instance(addrs.StringKey("bonk")).Absolute(root),
   942  				PrevRunAddr: honk.Instance(addrs.IntKey(0)).Absolute(root),
   943  				ChangeSrc:   plans.ChangeSrc{Action: plans.NoOp},
   944  			},
   945  		},
   946  	}
   947  	v.Plan(plan, testSchemas())
   948  
   949  	want := []map[string]interface{}{
   950  		// Drift detected: delete
   951  		{
   952  			"@level":   "info",
   953  			"@message": "test_resource.beep: Drift detected (delete)",
   954  			"@module":  "tofu.ui",
   955  			"type":     "resource_drift",
   956  			"change": map[string]interface{}{
   957  				"action": "delete",
   958  				"resource": map[string]interface{}{
   959  					"addr":             "test_resource.beep",
   960  					"implied_provider": "test",
   961  					"module":           "",
   962  					"resource":         "test_resource.beep",
   963  					"resource_key":     nil,
   964  					"resource_name":    "beep",
   965  					"resource_type":    "test_resource",
   966  				},
   967  			},
   968  		},
   969  		// Drift detected: update with move
   970  		{
   971  			"@level":   "info",
   972  			"@message": "test_resource.boop: Drift detected (update)",
   973  			"@module":  "tofu.ui",
   974  			"type":     "resource_drift",
   975  			"change": map[string]interface{}{
   976  				"action": "update",
   977  				"resource": map[string]interface{}{
   978  					"addr":             "test_resource.boop",
   979  					"implied_provider": "test",
   980  					"module":           "",
   981  					"resource":         "test_resource.boop",
   982  					"resource_key":     nil,
   983  					"resource_name":    "boop",
   984  					"resource_type":    "test_resource",
   985  				},
   986  				"previous_resource": map[string]interface{}{
   987  					"addr":             "test_resource.blep",
   988  					"implied_provider": "test",
   989  					"module":           "",
   990  					"resource":         "test_resource.blep",
   991  					"resource_key":     nil,
   992  					"resource_name":    "blep",
   993  					"resource_type":    "test_resource",
   994  				},
   995  			},
   996  		},
   997  		// Move-only change
   998  		{
   999  			"@level":   "info",
  1000  			"@message": `test_resource.honk["bonk"]: Plan to move`,
  1001  			"@module":  "tofu.ui",
  1002  			"type":     "planned_change",
  1003  			"change": map[string]interface{}{
  1004  				"action": "move",
  1005  				"resource": map[string]interface{}{
  1006  					"addr":             `test_resource.honk["bonk"]`,
  1007  					"implied_provider": "test",
  1008  					"module":           "",
  1009  					"resource":         `test_resource.honk["bonk"]`,
  1010  					"resource_key":     "bonk",
  1011  					"resource_name":    "honk",
  1012  					"resource_type":    "test_resource",
  1013  				},
  1014  				"previous_resource": map[string]interface{}{
  1015  					"addr":             `test_resource.honk[0]`,
  1016  					"implied_provider": "test",
  1017  					"module":           "",
  1018  					"resource":         `test_resource.honk[0]`,
  1019  					"resource_key":     float64(0),
  1020  					"resource_name":    "honk",
  1021  					"resource_type":    "test_resource",
  1022  				},
  1023  			},
  1024  		},
  1025  		// No changes
  1026  		{
  1027  			"@level":   "info",
  1028  			"@message": "Plan: 0 to add, 0 to change, 0 to destroy.",
  1029  			"@module":  "tofu.ui",
  1030  			"type":     "change_summary",
  1031  			"changes": map[string]interface{}{
  1032  				"operation": "plan",
  1033  				"add":       float64(0),
  1034  				"import":    float64(0),
  1035  				"change":    float64(0),
  1036  				"remove":    float64(0),
  1037  			},
  1038  		},
  1039  	}
  1040  
  1041  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
  1042  }
  1043  
  1044  func TestOperationJSON_planDriftWithMoveRefreshOnly(t *testing.T) {
  1045  	streams, done := terminal.StreamsForTesting(t)
  1046  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
  1047  
  1048  	root := addrs.RootModuleInstance
  1049  	boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "boop"}
  1050  	beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "beep"}
  1051  	blep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "blep"}
  1052  	honk := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "honk"}
  1053  
  1054  	plan := &plans.Plan{
  1055  		UIMode: plans.RefreshOnlyMode,
  1056  		Changes: &plans.Changes{
  1057  			Resources: []*plans.ResourceInstanceChangeSrc{},
  1058  		},
  1059  		DriftedResources: []*plans.ResourceInstanceChangeSrc{
  1060  			{
  1061  				Addr:        beep.Instance(addrs.NoKey).Absolute(root),
  1062  				PrevRunAddr: beep.Instance(addrs.NoKey).Absolute(root),
  1063  				ChangeSrc:   plans.ChangeSrc{Action: plans.Delete},
  1064  			},
  1065  			{
  1066  				Addr:        boop.Instance(addrs.NoKey).Absolute(root),
  1067  				PrevRunAddr: blep.Instance(addrs.NoKey).Absolute(root),
  1068  				ChangeSrc:   plans.ChangeSrc{Action: plans.Update},
  1069  			},
  1070  			// Move-only resource drift should be present in refresh-only plans
  1071  			{
  1072  				Addr:        honk.Instance(addrs.StringKey("bonk")).Absolute(root),
  1073  				PrevRunAddr: honk.Instance(addrs.IntKey(0)).Absolute(root),
  1074  				ChangeSrc:   plans.ChangeSrc{Action: plans.NoOp},
  1075  			},
  1076  		},
  1077  	}
  1078  	v.Plan(plan, testSchemas())
  1079  
  1080  	want := []map[string]interface{}{
  1081  		// Drift detected: delete
  1082  		{
  1083  			"@level":   "info",
  1084  			"@message": "test_resource.beep: Drift detected (delete)",
  1085  			"@module":  "tofu.ui",
  1086  			"type":     "resource_drift",
  1087  			"change": map[string]interface{}{
  1088  				"action": "delete",
  1089  				"resource": map[string]interface{}{
  1090  					"addr":             "test_resource.beep",
  1091  					"implied_provider": "test",
  1092  					"module":           "",
  1093  					"resource":         "test_resource.beep",
  1094  					"resource_key":     nil,
  1095  					"resource_name":    "beep",
  1096  					"resource_type":    "test_resource",
  1097  				},
  1098  			},
  1099  		},
  1100  		// Drift detected: update
  1101  		{
  1102  			"@level":   "info",
  1103  			"@message": "test_resource.boop: Drift detected (update)",
  1104  			"@module":  "tofu.ui",
  1105  			"type":     "resource_drift",
  1106  			"change": map[string]interface{}{
  1107  				"action": "update",
  1108  				"resource": map[string]interface{}{
  1109  					"addr":             "test_resource.boop",
  1110  					"implied_provider": "test",
  1111  					"module":           "",
  1112  					"resource":         "test_resource.boop",
  1113  					"resource_key":     nil,
  1114  					"resource_name":    "boop",
  1115  					"resource_type":    "test_resource",
  1116  				},
  1117  				"previous_resource": map[string]interface{}{
  1118  					"addr":             "test_resource.blep",
  1119  					"implied_provider": "test",
  1120  					"module":           "",
  1121  					"resource":         "test_resource.blep",
  1122  					"resource_key":     nil,
  1123  					"resource_name":    "blep",
  1124  					"resource_type":    "test_resource",
  1125  				},
  1126  			},
  1127  		},
  1128  		// Drift detected: Move-only change
  1129  		{
  1130  			"@level":   "info",
  1131  			"@message": `test_resource.honk["bonk"]: Drift detected (move)`,
  1132  			"@module":  "tofu.ui",
  1133  			"type":     "resource_drift",
  1134  			"change": map[string]interface{}{
  1135  				"action": "move",
  1136  				"resource": map[string]interface{}{
  1137  					"addr":             `test_resource.honk["bonk"]`,
  1138  					"implied_provider": "test",
  1139  					"module":           "",
  1140  					"resource":         `test_resource.honk["bonk"]`,
  1141  					"resource_key":     "bonk",
  1142  					"resource_name":    "honk",
  1143  					"resource_type":    "test_resource",
  1144  				},
  1145  				"previous_resource": map[string]interface{}{
  1146  					"addr":             `test_resource.honk[0]`,
  1147  					"implied_provider": "test",
  1148  					"module":           "",
  1149  					"resource":         `test_resource.honk[0]`,
  1150  					"resource_key":     float64(0),
  1151  					"resource_name":    "honk",
  1152  					"resource_type":    "test_resource",
  1153  				},
  1154  			},
  1155  		},
  1156  		// No changes
  1157  		{
  1158  			"@level":   "info",
  1159  			"@message": "Plan: 0 to add, 0 to change, 0 to destroy.",
  1160  			"@module":  "tofu.ui",
  1161  			"type":     "change_summary",
  1162  			"changes": map[string]interface{}{
  1163  				"operation": "plan",
  1164  				"add":       float64(0),
  1165  				"import":    float64(0),
  1166  				"change":    float64(0),
  1167  				"remove":    float64(0),
  1168  			},
  1169  		},
  1170  	}
  1171  
  1172  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
  1173  }
  1174  
  1175  func TestOperationJSON_planOutputChanges(t *testing.T) {
  1176  	streams, done := terminal.StreamsForTesting(t)
  1177  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
  1178  
  1179  	root := addrs.RootModuleInstance
  1180  
  1181  	plan := &plans.Plan{
  1182  		Changes: &plans.Changes{
  1183  			Resources: []*plans.ResourceInstanceChangeSrc{},
  1184  			Outputs: []*plans.OutputChangeSrc{
  1185  				{
  1186  					Addr: root.OutputValue("boop"),
  1187  					ChangeSrc: plans.ChangeSrc{
  1188  						Action: plans.NoOp,
  1189  					},
  1190  				},
  1191  				{
  1192  					Addr: root.OutputValue("beep"),
  1193  					ChangeSrc: plans.ChangeSrc{
  1194  						Action: plans.Create,
  1195  					},
  1196  				},
  1197  				{
  1198  					Addr: root.OutputValue("bonk"),
  1199  					ChangeSrc: plans.ChangeSrc{
  1200  						Action: plans.Delete,
  1201  					},
  1202  				},
  1203  				{
  1204  					Addr: root.OutputValue("honk"),
  1205  					ChangeSrc: plans.ChangeSrc{
  1206  						Action: plans.Update,
  1207  					},
  1208  					Sensitive: true,
  1209  				},
  1210  			},
  1211  		},
  1212  	}
  1213  	v.Plan(plan, testSchemas())
  1214  
  1215  	want := []map[string]interface{}{
  1216  		// No resource changes
  1217  		{
  1218  			"@level":   "info",
  1219  			"@message": "Plan: 0 to add, 0 to change, 0 to destroy.",
  1220  			"@module":  "tofu.ui",
  1221  			"type":     "change_summary",
  1222  			"changes": map[string]interface{}{
  1223  				"operation": "plan",
  1224  				"add":       float64(0),
  1225  				"import":    float64(0),
  1226  				"change":    float64(0),
  1227  				"remove":    float64(0),
  1228  			},
  1229  		},
  1230  		// Output changes
  1231  		{
  1232  			"@level":   "info",
  1233  			"@message": "Outputs: 4",
  1234  			"@module":  "tofu.ui",
  1235  			"type":     "outputs",
  1236  			"outputs": map[string]interface{}{
  1237  				"boop": map[string]interface{}{
  1238  					"action":    "noop",
  1239  					"sensitive": false,
  1240  				},
  1241  				"beep": map[string]interface{}{
  1242  					"action":    "create",
  1243  					"sensitive": false,
  1244  				},
  1245  				"bonk": map[string]interface{}{
  1246  					"action":    "delete",
  1247  					"sensitive": false,
  1248  				},
  1249  				"honk": map[string]interface{}{
  1250  					"action":    "update",
  1251  					"sensitive": true,
  1252  				},
  1253  			},
  1254  		},
  1255  	}
  1256  
  1257  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
  1258  }
  1259  
  1260  func TestOperationJSON_plannedChange(t *testing.T) {
  1261  	streams, done := terminal.StreamsForTesting(t)
  1262  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
  1263  
  1264  	root := addrs.RootModuleInstance
  1265  	boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "boop"}
  1266  	derp := addrs.Resource{Mode: addrs.DataResourceMode, Type: "test_source", Name: "derp"}
  1267  
  1268  	// Replace requested by user
  1269  	v.PlannedChange(&plans.ResourceInstanceChangeSrc{
  1270  		Addr:         boop.Instance(addrs.IntKey(0)).Absolute(root),
  1271  		PrevRunAddr:  boop.Instance(addrs.IntKey(0)).Absolute(root),
  1272  		ChangeSrc:    plans.ChangeSrc{Action: plans.DeleteThenCreate},
  1273  		ActionReason: plans.ResourceInstanceReplaceByRequest,
  1274  	})
  1275  
  1276  	// Simple create
  1277  	v.PlannedChange(&plans.ResourceInstanceChangeSrc{
  1278  		Addr:        boop.Instance(addrs.IntKey(1)).Absolute(root),
  1279  		PrevRunAddr: boop.Instance(addrs.IntKey(1)).Absolute(root),
  1280  		ChangeSrc:   plans.ChangeSrc{Action: plans.Create},
  1281  	})
  1282  
  1283  	// Data source deletion
  1284  	v.PlannedChange(&plans.ResourceInstanceChangeSrc{
  1285  		Addr:        derp.Instance(addrs.NoKey).Absolute(root),
  1286  		PrevRunAddr: derp.Instance(addrs.NoKey).Absolute(root),
  1287  		ChangeSrc:   plans.ChangeSrc{Action: plans.Delete},
  1288  	})
  1289  
  1290  	// Expect only two messages, as the data source deletion should be a no-op
  1291  	want := []map[string]interface{}{
  1292  		{
  1293  			"@level":   "info",
  1294  			"@message": "test_instance.boop[0]: Plan to replace",
  1295  			"@module":  "tofu.ui",
  1296  			"type":     "planned_change",
  1297  			"change": map[string]interface{}{
  1298  				"action": "replace",
  1299  				"reason": "requested",
  1300  				"resource": map[string]interface{}{
  1301  					"addr":             `test_instance.boop[0]`,
  1302  					"implied_provider": "test",
  1303  					"module":           "",
  1304  					"resource":         `test_instance.boop[0]`,
  1305  					"resource_key":     float64(0),
  1306  					"resource_name":    "boop",
  1307  					"resource_type":    "test_instance",
  1308  				},
  1309  			},
  1310  		},
  1311  		{
  1312  			"@level":   "info",
  1313  			"@message": "test_instance.boop[1]: Plan to create",
  1314  			"@module":  "tofu.ui",
  1315  			"type":     "planned_change",
  1316  			"change": map[string]interface{}{
  1317  				"action": "create",
  1318  				"resource": map[string]interface{}{
  1319  					"addr":             `test_instance.boop[1]`,
  1320  					"implied_provider": "test",
  1321  					"module":           "",
  1322  					"resource":         `test_instance.boop[1]`,
  1323  					"resource_key":     float64(1),
  1324  					"resource_name":    "boop",
  1325  					"resource_type":    "test_instance",
  1326  				},
  1327  			},
  1328  		},
  1329  	}
  1330  
  1331  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
  1332  }