github.com/iaas-resource-provision/iaas-rpc@v1.0.7-0.20211021023331-ed21f798c408/internal/command/views/operation_test.go (about)

     1  package views
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"strings"
     7  	"testing"
     8  
     9  	"github.com/iaas-resource-provision/iaas-rpc/internal/addrs"
    10  	"github.com/iaas-resource-provision/iaas-rpc/internal/command/arguments"
    11  	"github.com/iaas-resource-provision/iaas-rpc/internal/plans"
    12  	"github.com/iaas-resource-provision/iaas-rpc/internal/states"
    13  	"github.com/iaas-resource-provision/iaas-rpc/internal/states/statefile"
    14  	"github.com/iaas-resource-provision/iaas-rpc/internal/terminal"
    15  	"github.com/iaas-resource-provision/iaas-rpc/internal/terraform"
    16  )
    17  
    18  func TestOperation_stopping(t *testing.T) {
    19  	streams, done := terminal.StreamsForTesting(t)
    20  	v := NewOperation(arguments.ViewHuman, false, NewView(streams))
    21  
    22  	v.Stopping()
    23  
    24  	if got, want := done(t).Stdout(), "Stopping operation...\n"; got != want {
    25  		t.Errorf("wrong result\ngot:  %q\nwant: %q", got, want)
    26  	}
    27  }
    28  
    29  func TestOperation_cancelled(t *testing.T) {
    30  	testCases := map[string]struct {
    31  		planMode plans.Mode
    32  		want     string
    33  	}{
    34  		"apply": {
    35  			planMode: plans.NormalMode,
    36  			want:     "Apply cancelled.\n",
    37  		},
    38  		"destroy": {
    39  			planMode: plans.DestroyMode,
    40  			want:     "Destroy cancelled.\n",
    41  		},
    42  	}
    43  	for name, tc := range testCases {
    44  		t.Run(name, func(t *testing.T) {
    45  			streams, done := terminal.StreamsForTesting(t)
    46  			v := NewOperation(arguments.ViewHuman, false, NewView(streams))
    47  
    48  			v.Cancelled(tc.planMode)
    49  
    50  			if got, want := done(t).Stdout(), tc.want; got != want {
    51  				t.Errorf("wrong result\ngot:  %q\nwant: %q", got, want)
    52  			}
    53  		})
    54  	}
    55  }
    56  
    57  func TestOperation_emergencyDumpState(t *testing.T) {
    58  	streams, done := terminal.StreamsForTesting(t)
    59  	v := NewOperation(arguments.ViewHuman, false, NewView(streams))
    60  
    61  	stateFile := statefile.New(nil, "foo", 1)
    62  
    63  	err := v.EmergencyDumpState(stateFile)
    64  	if err != nil {
    65  		t.Fatalf("unexpected error dumping state: %s", err)
    66  	}
    67  
    68  	// Check that the result (on stderr) looks like JSON state
    69  	raw := done(t).Stderr()
    70  	var state map[string]interface{}
    71  	if err := json.Unmarshal([]byte(raw), &state); err != nil {
    72  		t.Fatalf("unexpected error parsing dumped state: %s\nraw:\n%s", err, raw)
    73  	}
    74  }
    75  
    76  func TestOperation_planNoChanges(t *testing.T) {
    77  
    78  	tests := map[string]struct {
    79  		plan     func(schemas *terraform.Schemas) *plans.Plan
    80  		wantText string
    81  	}{
    82  		"nothing at all in normal mode": {
    83  			func(schemas *terraform.Schemas) *plans.Plan {
    84  				return &plans.Plan{
    85  					UIMode:       plans.NormalMode,
    86  					Changes:      plans.NewChanges(),
    87  					PrevRunState: states.NewState(),
    88  					PriorState:   states.NewState(),
    89  				}
    90  			},
    91  			"no differences, so no changes are needed.",
    92  		},
    93  		"nothing at all in refresh-only mode": {
    94  			func(schemas *terraform.Schemas) *plans.Plan {
    95  				return &plans.Plan{
    96  					UIMode:       plans.RefreshOnlyMode,
    97  					Changes:      plans.NewChanges(),
    98  					PrevRunState: states.NewState(),
    99  					PriorState:   states.NewState(),
   100  				}
   101  			},
   102  			"Terraform has checked that the real remote objects still match",
   103  		},
   104  		"nothing at all in destroy mode": {
   105  			func(schemas *terraform.Schemas) *plans.Plan {
   106  				return &plans.Plan{
   107  					UIMode:       plans.DestroyMode,
   108  					Changes:      plans.NewChanges(),
   109  					PrevRunState: states.NewState(),
   110  					PriorState:   states.NewState(),
   111  				}
   112  			},
   113  			"No objects need to be destroyed.",
   114  		},
   115  		"no drift to display with only deposed instances": {
   116  			// changes in deposed instances will cause a change in state, but
   117  			// have nothing to display to the user
   118  			func(schemas *terraform.Schemas) *plans.Plan {
   119  				return &plans.Plan{
   120  					UIMode:  plans.NormalMode,
   121  					Changes: plans.NewChanges(),
   122  					PrevRunState: states.BuildState(func(state *states.SyncState) {
   123  						state.SetResourceInstanceDeposed(
   124  							addrs.Resource{
   125  								Mode: addrs.ManagedResourceMode,
   126  								Type: "test_resource",
   127  								Name: "somewhere",
   128  							}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
   129  							states.NewDeposedKey(),
   130  							&states.ResourceInstanceObjectSrc{
   131  								Status:    states.ObjectReady,
   132  								AttrsJSON: []byte(`{"foo": "ok", "bars":[]}`),
   133  							},
   134  							addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   135  						)
   136  					}),
   137  					PriorState: states.NewState(),
   138  				}
   139  			},
   140  			"no differences, so no changes are needed.",
   141  		},
   142  		"drift detected in normal mode": {
   143  			func(schemas *terraform.Schemas) *plans.Plan {
   144  				return &plans.Plan{
   145  					UIMode:  plans.NormalMode,
   146  					Changes: plans.NewChanges(),
   147  					PrevRunState: states.BuildState(func(state *states.SyncState) {
   148  						state.SetResourceInstanceCurrent(
   149  							addrs.Resource{
   150  								Mode: addrs.ManagedResourceMode,
   151  								Type: "test_resource",
   152  								Name: "somewhere",
   153  							}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
   154  							&states.ResourceInstanceObjectSrc{
   155  								Status:    states.ObjectReady,
   156  								AttrsJSON: []byte(`{}`),
   157  							},
   158  							addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   159  						)
   160  					}),
   161  					PriorState: states.NewState(),
   162  				}
   163  			},
   164  			"to update the Terraform state to match, create and apply a refresh-only plan",
   165  		},
   166  		"drift detected with deposed": {
   167  			func(schemas *terraform.Schemas) *plans.Plan {
   168  				return &plans.Plan{
   169  					UIMode:  plans.NormalMode,
   170  					Changes: plans.NewChanges(),
   171  					PrevRunState: states.BuildState(func(state *states.SyncState) {
   172  						state.SetResourceInstanceCurrent(
   173  							addrs.Resource{
   174  								Mode: addrs.ManagedResourceMode,
   175  								Type: "test_resource",
   176  								Name: "changes",
   177  							}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
   178  							&states.ResourceInstanceObjectSrc{
   179  								Status:    states.ObjectReady,
   180  								AttrsJSON: []byte(`{"foo":"b"}`),
   181  							},
   182  							addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   183  						)
   184  						state.SetResourceInstanceDeposed(
   185  							addrs.Resource{
   186  								Mode: addrs.ManagedResourceMode,
   187  								Type: "test_resource",
   188  								Name: "broken",
   189  							}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
   190  							states.NewDeposedKey(),
   191  							&states.ResourceInstanceObjectSrc{
   192  								Status:    states.ObjectReady,
   193  								AttrsJSON: []byte(`{"foo":"c"}`),
   194  							},
   195  							addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   196  						)
   197  					}),
   198  					PriorState: states.BuildState(func(state *states.SyncState) {
   199  						state.SetResourceInstanceCurrent(
   200  							addrs.Resource{
   201  								Mode: addrs.ManagedResourceMode,
   202  								Type: "test_resource",
   203  								Name: "changed",
   204  							}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
   205  							&states.ResourceInstanceObjectSrc{
   206  								Status:    states.ObjectReady,
   207  								AttrsJSON: []byte(`{"foo":"b"}`),
   208  							},
   209  							addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   210  						)
   211  						state.SetResourceInstanceDeposed(
   212  							addrs.Resource{
   213  								Mode: addrs.ManagedResourceMode,
   214  								Type: "test_resource",
   215  								Name: "broken",
   216  							}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
   217  							states.NewDeposedKey(),
   218  							&states.ResourceInstanceObjectSrc{
   219  								Status:    states.ObjectReady,
   220  								AttrsJSON: []byte(`{"foo":"d"}`),
   221  							},
   222  							addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   223  						)
   224  					}),
   225  				}
   226  			},
   227  			"to update the Terraform state to match, create and apply a refresh-only plan",
   228  		},
   229  		"drift detected in refresh-only mode": {
   230  			func(schemas *terraform.Schemas) *plans.Plan {
   231  				return &plans.Plan{
   232  					UIMode:  plans.RefreshOnlyMode,
   233  					Changes: plans.NewChanges(),
   234  					PrevRunState: states.BuildState(func(state *states.SyncState) {
   235  						state.SetResourceInstanceCurrent(
   236  							addrs.Resource{
   237  								Mode: addrs.ManagedResourceMode,
   238  								Type: "test_resource",
   239  								Name: "somewhere",
   240  							}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
   241  							&states.ResourceInstanceObjectSrc{
   242  								Status:    states.ObjectReady,
   243  								AttrsJSON: []byte(`{}`),
   244  							},
   245  							addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   246  						)
   247  					}),
   248  					PriorState: states.NewState(),
   249  				}
   250  			},
   251  			"If you were expecting these changes then you can apply this plan",
   252  		},
   253  		"drift detected in destroy mode": {
   254  			func(schemas *terraform.Schemas) *plans.Plan {
   255  				return &plans.Plan{
   256  					UIMode:  plans.DestroyMode,
   257  					Changes: plans.NewChanges(),
   258  					PrevRunState: states.BuildState(func(state *states.SyncState) {
   259  						state.SetResourceInstanceCurrent(
   260  							addrs.Resource{
   261  								Mode: addrs.ManagedResourceMode,
   262  								Type: "test_resource",
   263  								Name: "somewhere",
   264  							}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
   265  							&states.ResourceInstanceObjectSrc{
   266  								Status:    states.ObjectReady,
   267  								AttrsJSON: []byte(`{}`),
   268  							},
   269  							addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   270  						)
   271  					}),
   272  					PriorState: states.NewState(),
   273  				}
   274  			},
   275  			"No objects need to be destroyed.",
   276  		},
   277  	}
   278  
   279  	schemas := testSchemas()
   280  	for name, test := range tests {
   281  		t.Run(name, func(t *testing.T) {
   282  			streams, done := terminal.StreamsForTesting(t)
   283  			v := NewOperation(arguments.ViewHuman, false, NewView(streams))
   284  			plan := test.plan(schemas)
   285  			v.Plan(plan, schemas)
   286  			got := done(t).Stdout()
   287  			if want := test.wantText; want != "" && !strings.Contains(got, want) {
   288  				t.Errorf("missing expected message\ngot:\n%s\n\nwant substring: %s", got, want)
   289  			}
   290  		})
   291  	}
   292  }
   293  
   294  func TestOperation_plan(t *testing.T) {
   295  	streams, done := terminal.StreamsForTesting(t)
   296  	v := NewOperation(arguments.ViewHuman, true, NewView(streams))
   297  
   298  	plan := testPlan(t)
   299  	schemas := testSchemas()
   300  	v.Plan(plan, schemas)
   301  
   302  	want := `
   303  RPC (Resource Provision Center) used the selected providers to generate the following execution
   304  plan. Resource actions are indicated with the following symbols:
   305    + create
   306  
   307  RPC (Resource Provision Center)  will perform the following actions:
   308  
   309    # test_resource.foo will be created
   310    + resource "test_resource" "foo" {
   311        + foo = "bar"
   312        + id  = (known after apply)
   313      }
   314  
   315  Plan: 1 to add, 0 to change, 0 to destroy.
   316  `
   317  
   318  	if got := done(t).Stdout(); got != want {
   319  		t.Errorf("unexpected output\ngot:\n%s\nwant:\n%s", got, want)
   320  	}
   321  }
   322  
   323  func TestOperation_planNextStep(t *testing.T) {
   324  	testCases := map[string]struct {
   325  		path string
   326  		want string
   327  	}{
   328  		"no state path": {
   329  			path: "",
   330  			want: "You didn't use the -out option",
   331  		},
   332  		"state path": {
   333  			path: "good plan.tfplan",
   334  			want: `terraform apply "good plan.tfplan"`,
   335  		},
   336  	}
   337  	for name, tc := range testCases {
   338  		t.Run(name, func(t *testing.T) {
   339  			streams, done := terminal.StreamsForTesting(t)
   340  			v := NewOperation(arguments.ViewHuman, false, NewView(streams))
   341  
   342  			v.PlanNextStep(tc.path)
   343  
   344  			if got := done(t).Stdout(); !strings.Contains(got, tc.want) {
   345  				t.Errorf("wrong result\ngot:  %q\nwant: %q", got, tc.want)
   346  			}
   347  		})
   348  	}
   349  }
   350  
   351  // The in-automation state is on the view itself, so testing it separately is
   352  // clearer.
   353  func TestOperation_planNextStepInAutomation(t *testing.T) {
   354  	streams, done := terminal.StreamsForTesting(t)
   355  	v := NewOperation(arguments.ViewHuman, true, NewView(streams))
   356  
   357  	v.PlanNextStep("")
   358  
   359  	if got := done(t).Stdout(); got != "" {
   360  		t.Errorf("unexpected output\ngot: %q", got)
   361  	}
   362  }
   363  
   364  // Test all the trivial OperationJSON methods together. Y'know, for brevity.
   365  // This test is not a realistic stream of messages.
   366  func TestOperationJSON_logs(t *testing.T) {
   367  	streams, done := terminal.StreamsForTesting(t)
   368  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
   369  
   370  	v.Cancelled(plans.NormalMode)
   371  	v.Cancelled(plans.DestroyMode)
   372  	v.Stopping()
   373  	v.Interrupted()
   374  	v.FatalInterrupt()
   375  
   376  	want := []map[string]interface{}{
   377  		{
   378  			"@level":   "info",
   379  			"@message": "Apply cancelled",
   380  			"@module":  "terraform.ui",
   381  			"type":     "log",
   382  		},
   383  		{
   384  			"@level":   "info",
   385  			"@message": "Destroy cancelled",
   386  			"@module":  "terraform.ui",
   387  			"type":     "log",
   388  		},
   389  		{
   390  			"@level":   "info",
   391  			"@message": "Stopping operation...",
   392  			"@module":  "terraform.ui",
   393  			"type":     "log",
   394  		},
   395  		{
   396  			"@level":   "info",
   397  			"@message": interrupted,
   398  			"@module":  "terraform.ui",
   399  			"type":     "log",
   400  		},
   401  		{
   402  			"@level":   "info",
   403  			"@message": fatalInterrupt,
   404  			"@module":  "terraform.ui",
   405  			"type":     "log",
   406  		},
   407  	}
   408  
   409  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   410  }
   411  
   412  // This is a fairly circular test, but it's such a rarely executed code path
   413  // that I think it's probably still worth having. We're not testing against
   414  // a fixed state JSON output because this test ought not fail just because
   415  // we upgrade state format in the future.
   416  func TestOperationJSON_emergencyDumpState(t *testing.T) {
   417  	streams, done := terminal.StreamsForTesting(t)
   418  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
   419  
   420  	stateFile := statefile.New(nil, "foo", 1)
   421  	stateBuf := new(bytes.Buffer)
   422  	err := statefile.Write(stateFile, stateBuf)
   423  	if err != nil {
   424  		t.Fatal(err)
   425  	}
   426  	var stateJSON map[string]interface{}
   427  	err = json.Unmarshal(stateBuf.Bytes(), &stateJSON)
   428  	if err != nil {
   429  		t.Fatal(err)
   430  	}
   431  
   432  	err = v.EmergencyDumpState(stateFile)
   433  	if err != nil {
   434  		t.Fatalf("unexpected error dumping state: %s", err)
   435  	}
   436  
   437  	want := []map[string]interface{}{
   438  		{
   439  			"@level":   "info",
   440  			"@message": "Emergency state dump",
   441  			"@module":  "terraform.ui",
   442  			"type":     "log",
   443  			"state":    stateJSON,
   444  		},
   445  	}
   446  
   447  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   448  }
   449  
   450  func TestOperationJSON_planNoChanges(t *testing.T) {
   451  	streams, done := terminal.StreamsForTesting(t)
   452  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
   453  
   454  	plan := &plans.Plan{
   455  		Changes: plans.NewChanges(),
   456  	}
   457  	v.Plan(plan, nil)
   458  
   459  	want := []map[string]interface{}{
   460  		{
   461  			"@level":   "info",
   462  			"@message": "Plan: 0 to add, 0 to change, 0 to destroy.",
   463  			"@module":  "terraform.ui",
   464  			"type":     "change_summary",
   465  			"changes": map[string]interface{}{
   466  				"operation": "plan",
   467  				"add":       float64(0),
   468  				"change":    float64(0),
   469  				"remove":    float64(0),
   470  			},
   471  		},
   472  	}
   473  
   474  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   475  }
   476  
   477  func TestOperationJSON_plan(t *testing.T) {
   478  	streams, done := terminal.StreamsForTesting(t)
   479  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
   480  
   481  	root := addrs.RootModuleInstance
   482  	vpc, diags := addrs.ParseModuleInstanceStr("module.vpc")
   483  	if len(diags) > 0 {
   484  		t.Fatal(diags.Err())
   485  	}
   486  	boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "boop"}
   487  	beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "beep"}
   488  	derp := addrs.Resource{Mode: addrs.DataResourceMode, Type: "test_source", Name: "derp"}
   489  
   490  	plan := &plans.Plan{
   491  		Changes: &plans.Changes{
   492  			Resources: []*plans.ResourceInstanceChangeSrc{
   493  				{
   494  					Addr:      boop.Instance(addrs.IntKey(0)).Absolute(root),
   495  					ChangeSrc: plans.ChangeSrc{Action: plans.CreateThenDelete},
   496  				},
   497  				{
   498  					Addr:      boop.Instance(addrs.IntKey(1)).Absolute(root),
   499  					ChangeSrc: plans.ChangeSrc{Action: plans.Create},
   500  				},
   501  				{
   502  					Addr:      boop.Instance(addrs.IntKey(0)).Absolute(vpc),
   503  					ChangeSrc: plans.ChangeSrc{Action: plans.Delete},
   504  				},
   505  				{
   506  					Addr:      beep.Instance(addrs.NoKey).Absolute(root),
   507  					ChangeSrc: plans.ChangeSrc{Action: plans.DeleteThenCreate},
   508  				},
   509  				{
   510  					Addr:      beep.Instance(addrs.NoKey).Absolute(vpc),
   511  					ChangeSrc: plans.ChangeSrc{Action: plans.Update},
   512  				},
   513  				// Data source deletion should not show up in the logs
   514  				{
   515  					Addr:      derp.Instance(addrs.NoKey).Absolute(root),
   516  					ChangeSrc: plans.ChangeSrc{Action: plans.Delete},
   517  				},
   518  			},
   519  		},
   520  	}
   521  	v.Plan(plan, testSchemas())
   522  
   523  	want := []map[string]interface{}{
   524  		// Create-then-delete should result in replace
   525  		{
   526  			"@level":   "info",
   527  			"@message": "test_resource.boop[0]: Plan to replace",
   528  			"@module":  "terraform.ui",
   529  			"type":     "planned_change",
   530  			"change": map[string]interface{}{
   531  				"action": "replace",
   532  				"resource": map[string]interface{}{
   533  					"addr":             `test_resource.boop[0]`,
   534  					"implied_provider": "test",
   535  					"module":           "",
   536  					"resource":         `test_resource.boop[0]`,
   537  					"resource_key":     float64(0),
   538  					"resource_name":    "boop",
   539  					"resource_type":    "test_resource",
   540  				},
   541  			},
   542  		},
   543  		// Simple create
   544  		{
   545  			"@level":   "info",
   546  			"@message": "test_resource.boop[1]: Plan to create",
   547  			"@module":  "terraform.ui",
   548  			"type":     "planned_change",
   549  			"change": map[string]interface{}{
   550  				"action": "create",
   551  				"resource": map[string]interface{}{
   552  					"addr":             `test_resource.boop[1]`,
   553  					"implied_provider": "test",
   554  					"module":           "",
   555  					"resource":         `test_resource.boop[1]`,
   556  					"resource_key":     float64(1),
   557  					"resource_name":    "boop",
   558  					"resource_type":    "test_resource",
   559  				},
   560  			},
   561  		},
   562  		// Simple delete
   563  		{
   564  			"@level":   "info",
   565  			"@message": "module.vpc.test_resource.boop[0]: Plan to delete",
   566  			"@module":  "terraform.ui",
   567  			"type":     "planned_change",
   568  			"change": map[string]interface{}{
   569  				"action": "delete",
   570  				"resource": map[string]interface{}{
   571  					"addr":             `module.vpc.test_resource.boop[0]`,
   572  					"implied_provider": "test",
   573  					"module":           "module.vpc",
   574  					"resource":         `test_resource.boop[0]`,
   575  					"resource_key":     float64(0),
   576  					"resource_name":    "boop",
   577  					"resource_type":    "test_resource",
   578  				},
   579  			},
   580  		},
   581  		// Delete-then-create is also a replace
   582  		{
   583  			"@level":   "info",
   584  			"@message": "test_resource.beep: Plan to replace",
   585  			"@module":  "terraform.ui",
   586  			"type":     "planned_change",
   587  			"change": map[string]interface{}{
   588  				"action": "replace",
   589  				"resource": map[string]interface{}{
   590  					"addr":             `test_resource.beep`,
   591  					"implied_provider": "test",
   592  					"module":           "",
   593  					"resource":         `test_resource.beep`,
   594  					"resource_key":     nil,
   595  					"resource_name":    "beep",
   596  					"resource_type":    "test_resource",
   597  				},
   598  			},
   599  		},
   600  		// Simple update
   601  		{
   602  			"@level":   "info",
   603  			"@message": "module.vpc.test_resource.beep: Plan to update",
   604  			"@module":  "terraform.ui",
   605  			"type":     "planned_change",
   606  			"change": map[string]interface{}{
   607  				"action": "update",
   608  				"resource": map[string]interface{}{
   609  					"addr":             `module.vpc.test_resource.beep`,
   610  					"implied_provider": "test",
   611  					"module":           "module.vpc",
   612  					"resource":         `test_resource.beep`,
   613  					"resource_key":     nil,
   614  					"resource_name":    "beep",
   615  					"resource_type":    "test_resource",
   616  				},
   617  			},
   618  		},
   619  		// These counts are 3 add/1 change/3 destroy because the replace
   620  		// changes result in both add and destroy counts.
   621  		{
   622  			"@level":   "info",
   623  			"@message": "Plan: 3 to add, 1 to change, 3 to destroy.",
   624  			"@module":  "terraform.ui",
   625  			"type":     "change_summary",
   626  			"changes": map[string]interface{}{
   627  				"operation": "plan",
   628  				"add":       float64(3),
   629  				"change":    float64(1),
   630  				"remove":    float64(3),
   631  			},
   632  		},
   633  	}
   634  
   635  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   636  }
   637  
   638  func TestOperationJSON_planDrift(t *testing.T) {
   639  	streams, done := terminal.StreamsForTesting(t)
   640  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
   641  
   642  	root := addrs.RootModuleInstance
   643  	boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "boop"}
   644  	beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "beep"}
   645  	derp := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "derp"}
   646  
   647  	plan := &plans.Plan{
   648  		Changes: &plans.Changes{
   649  			Resources: []*plans.ResourceInstanceChangeSrc{},
   650  		},
   651  		PrevRunState: states.BuildState(func(state *states.SyncState) {
   652  			// Update
   653  			state.SetResourceInstanceCurrent(
   654  				boop.Instance(addrs.NoKey).Absolute(root),
   655  				&states.ResourceInstanceObjectSrc{
   656  					Status:    states.ObjectReady,
   657  					AttrsJSON: []byte(`{"foo":"bar"}`),
   658  				},
   659  				root.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   660  			)
   661  			// Delete
   662  			state.SetResourceInstanceCurrent(
   663  				beep.Instance(addrs.NoKey).Absolute(root),
   664  				&states.ResourceInstanceObjectSrc{
   665  					Status:    states.ObjectReady,
   666  					AttrsJSON: []byte(`{"foo":"boop"}`),
   667  				},
   668  				root.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   669  			)
   670  			// No-op
   671  			state.SetResourceInstanceCurrent(
   672  				derp.Instance(addrs.NoKey).Absolute(root),
   673  				&states.ResourceInstanceObjectSrc{
   674  					Status:    states.ObjectReady,
   675  					AttrsJSON: []byte(`{"foo":"boop"}`),
   676  				},
   677  				root.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   678  			)
   679  		}),
   680  		PriorState: states.BuildState(func(state *states.SyncState) {
   681  			// Update
   682  			state.SetResourceInstanceCurrent(
   683  				boop.Instance(addrs.NoKey).Absolute(root),
   684  				&states.ResourceInstanceObjectSrc{
   685  					Status:    states.ObjectReady,
   686  					AttrsJSON: []byte(`{"foo":"baz"}`),
   687  				},
   688  				root.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   689  			)
   690  			// Delete
   691  			state.SetResourceInstanceCurrent(
   692  				beep.Instance(addrs.NoKey).Absolute(root),
   693  				nil,
   694  				root.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   695  			)
   696  			// No-op
   697  			state.SetResourceInstanceCurrent(
   698  				derp.Instance(addrs.NoKey).Absolute(root),
   699  				&states.ResourceInstanceObjectSrc{
   700  					Status:    states.ObjectReady,
   701  					AttrsJSON: []byte(`{"foo":"boop"}`),
   702  				},
   703  				root.ProviderConfigDefault(addrs.NewDefaultProvider("test")),
   704  			)
   705  		}),
   706  	}
   707  	v.Plan(plan, testSchemas())
   708  
   709  	want := []map[string]interface{}{
   710  		// Drift detected: delete
   711  		{
   712  			"@level":   "info",
   713  			"@message": "test_resource.beep: Drift detected (delete)",
   714  			"@module":  "terraform.ui",
   715  			"type":     "resource_drift",
   716  			"change": map[string]interface{}{
   717  				"action": "delete",
   718  				"resource": map[string]interface{}{
   719  					"addr":             "test_resource.beep",
   720  					"implied_provider": "test",
   721  					"module":           "",
   722  					"resource":         "test_resource.beep",
   723  					"resource_key":     nil,
   724  					"resource_name":    "beep",
   725  					"resource_type":    "test_resource",
   726  				},
   727  			},
   728  		},
   729  		// Drift detected: update
   730  		{
   731  			"@level":   "info",
   732  			"@message": "test_resource.boop: Drift detected (update)",
   733  			"@module":  "terraform.ui",
   734  			"type":     "resource_drift",
   735  			"change": map[string]interface{}{
   736  				"action": "update",
   737  				"resource": map[string]interface{}{
   738  					"addr":             "test_resource.boop",
   739  					"implied_provider": "test",
   740  					"module":           "",
   741  					"resource":         "test_resource.boop",
   742  					"resource_key":     nil,
   743  					"resource_name":    "boop",
   744  					"resource_type":    "test_resource",
   745  				},
   746  			},
   747  		},
   748  		// No changes
   749  		{
   750  			"@level":   "info",
   751  			"@message": "Plan: 0 to add, 0 to change, 0 to destroy.",
   752  			"@module":  "terraform.ui",
   753  			"type":     "change_summary",
   754  			"changes": map[string]interface{}{
   755  				"operation": "plan",
   756  				"add":       float64(0),
   757  				"change":    float64(0),
   758  				"remove":    float64(0),
   759  			},
   760  		},
   761  	}
   762  
   763  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   764  }
   765  
   766  func TestOperationJSON_planOutputChanges(t *testing.T) {
   767  	streams, done := terminal.StreamsForTesting(t)
   768  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
   769  
   770  	root := addrs.RootModuleInstance
   771  
   772  	plan := &plans.Plan{
   773  		Changes: &plans.Changes{
   774  			Resources: []*plans.ResourceInstanceChangeSrc{},
   775  			Outputs: []*plans.OutputChangeSrc{
   776  				{
   777  					Addr: root.OutputValue("boop"),
   778  					ChangeSrc: plans.ChangeSrc{
   779  						Action: plans.NoOp,
   780  					},
   781  				},
   782  				{
   783  					Addr: root.OutputValue("beep"),
   784  					ChangeSrc: plans.ChangeSrc{
   785  						Action: plans.Create,
   786  					},
   787  				},
   788  				{
   789  					Addr: root.OutputValue("bonk"),
   790  					ChangeSrc: plans.ChangeSrc{
   791  						Action: plans.Delete,
   792  					},
   793  				},
   794  				{
   795  					Addr: root.OutputValue("honk"),
   796  					ChangeSrc: plans.ChangeSrc{
   797  						Action: plans.Update,
   798  					},
   799  					Sensitive: true,
   800  				},
   801  			},
   802  		},
   803  	}
   804  	v.Plan(plan, testSchemas())
   805  
   806  	want := []map[string]interface{}{
   807  		// No resource changes
   808  		{
   809  			"@level":   "info",
   810  			"@message": "Plan: 0 to add, 0 to change, 0 to destroy.",
   811  			"@module":  "terraform.ui",
   812  			"type":     "change_summary",
   813  			"changes": map[string]interface{}{
   814  				"operation": "plan",
   815  				"add":       float64(0),
   816  				"change":    float64(0),
   817  				"remove":    float64(0),
   818  			},
   819  		},
   820  		// Output changes
   821  		{
   822  			"@level":   "info",
   823  			"@message": "Outputs: 4",
   824  			"@module":  "terraform.ui",
   825  			"type":     "outputs",
   826  			"outputs": map[string]interface{}{
   827  				"boop": map[string]interface{}{
   828  					"action":    "noop",
   829  					"sensitive": false,
   830  				},
   831  				"beep": map[string]interface{}{
   832  					"action":    "create",
   833  					"sensitive": false,
   834  				},
   835  				"bonk": map[string]interface{}{
   836  					"action":    "delete",
   837  					"sensitive": false,
   838  				},
   839  				"honk": map[string]interface{}{
   840  					"action":    "update",
   841  					"sensitive": true,
   842  				},
   843  			},
   844  		},
   845  	}
   846  
   847  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   848  }
   849  
   850  func TestOperationJSON_plannedChange(t *testing.T) {
   851  	streams, done := terminal.StreamsForTesting(t)
   852  	v := &OperationJSON{view: NewJSONView(NewView(streams))}
   853  
   854  	root := addrs.RootModuleInstance
   855  	boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "boop"}
   856  	derp := addrs.Resource{Mode: addrs.DataResourceMode, Type: "test_source", Name: "derp"}
   857  
   858  	// Replace requested by user
   859  	v.PlannedChange(&plans.ResourceInstanceChangeSrc{
   860  		Addr:         boop.Instance(addrs.IntKey(0)).Absolute(root),
   861  		ChangeSrc:    plans.ChangeSrc{Action: plans.DeleteThenCreate},
   862  		ActionReason: plans.ResourceInstanceReplaceByRequest,
   863  	})
   864  
   865  	// Simple create
   866  	v.PlannedChange(&plans.ResourceInstanceChangeSrc{
   867  		Addr:      boop.Instance(addrs.IntKey(1)).Absolute(root),
   868  		ChangeSrc: plans.ChangeSrc{Action: plans.Create},
   869  	})
   870  
   871  	// Data source deletion
   872  	v.PlannedChange(&plans.ResourceInstanceChangeSrc{
   873  		Addr:      derp.Instance(addrs.NoKey).Absolute(root),
   874  		ChangeSrc: plans.ChangeSrc{Action: plans.Delete},
   875  	})
   876  
   877  	// Expect only two messages, as the data source deletion should be a no-op
   878  	want := []map[string]interface{}{
   879  		{
   880  			"@level":   "info",
   881  			"@message": "test_instance.boop[0]: Plan to replace",
   882  			"@module":  "terraform.ui",
   883  			"type":     "planned_change",
   884  			"change": map[string]interface{}{
   885  				"action": "replace",
   886  				"reason": "requested",
   887  				"resource": map[string]interface{}{
   888  					"addr":             `test_instance.boop[0]`,
   889  					"implied_provider": "test",
   890  					"module":           "",
   891  					"resource":         `test_instance.boop[0]`,
   892  					"resource_key":     float64(0),
   893  					"resource_name":    "boop",
   894  					"resource_type":    "test_instance",
   895  				},
   896  			},
   897  		},
   898  		{
   899  			"@level":   "info",
   900  			"@message": "test_instance.boop[1]: Plan to create",
   901  			"@module":  "terraform.ui",
   902  			"type":     "planned_change",
   903  			"change": map[string]interface{}{
   904  				"action": "create",
   905  				"resource": map[string]interface{}{
   906  					"addr":             `test_instance.boop[1]`,
   907  					"implied_provider": "test",
   908  					"module":           "",
   909  					"resource":         `test_instance.boop[1]`,
   910  					"resource_key":     float64(1),
   911  					"resource_name":    "boop",
   912  					"resource_type":    "test_instance",
   913  				},
   914  			},
   915  		},
   916  	}
   917  
   918  	testJSONViewOutputEquals(t, done(t).Stdout(), want)
   919  }