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 }