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