github.com/opentofu/opentofu@v1.7.1/internal/command/views/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 "fmt" 11 12 "github.com/mitchellh/colorstring" 13 14 "github.com/opentofu/opentofu/internal/command/arguments" 15 "github.com/opentofu/opentofu/internal/command/format" 16 "github.com/opentofu/opentofu/internal/command/jsonformat" 17 "github.com/opentofu/opentofu/internal/command/jsonplan" 18 "github.com/opentofu/opentofu/internal/command/jsonprovider" 19 "github.com/opentofu/opentofu/internal/command/jsonstate" 20 "github.com/opentofu/opentofu/internal/command/views/json" 21 "github.com/opentofu/opentofu/internal/configs" 22 "github.com/opentofu/opentofu/internal/encryption" 23 "github.com/opentofu/opentofu/internal/moduletest" 24 "github.com/opentofu/opentofu/internal/plans" 25 "github.com/opentofu/opentofu/internal/states" 26 "github.com/opentofu/opentofu/internal/states/statefile" 27 "github.com/opentofu/opentofu/internal/states/statemgr" 28 "github.com/opentofu/opentofu/internal/tfdiags" 29 "github.com/opentofu/opentofu/internal/tofu" 30 ) 31 32 // Test renders outputs for test executions. 33 type Test interface { 34 // Abstract should print an early summary of the tests that will be 35 // executed. This will be called before the tests have been executed so 36 // the status for everything within suite will be test.Pending. 37 // 38 // This should be used to state what is going to be tested. 39 Abstract(suite *moduletest.Suite) 40 41 // Conclusion should print out a summary of the tests including their 42 // completed status. 43 Conclusion(suite *moduletest.Suite) 44 45 // File prints out the summary for an entire test file. 46 File(file *moduletest.File) 47 48 // Run prints out the summary for a single test run block. 49 Run(run *moduletest.Run, file *moduletest.File) 50 51 // DestroySummary prints out the summary of the destroy step of each test 52 // file. If everything goes well, this should be empty. 53 DestroySummary(diags tfdiags.Diagnostics, run *moduletest.Run, file *moduletest.File, state *states.State) 54 55 // Diagnostics prints out the provided diagnostics. 56 Diagnostics(run *moduletest.Run, file *moduletest.File, diags tfdiags.Diagnostics) 57 58 // Interrupted prints out a message stating that an interrupt has been 59 // received and testing will stop. 60 Interrupted() 61 62 // FatalInterrupt prints out a message stating that a hard interrupt has 63 // been received and testing will stop and cleanup will be skipped. 64 FatalInterrupt() 65 66 // FatalInterruptSummary prints out the resources that were held in state 67 // and were being created at the time the FatalInterrupt was received. 68 // 69 // This will typically be called in place of DestroySummary, as there is no 70 // guarantee that this function will be called during a FatalInterrupt. In 71 // addition, this function prints additional details about the current 72 // operation alongside the current state as the state will be missing newly 73 // created resources that also need to be handled manually. 74 FatalInterruptSummary(run *moduletest.Run, file *moduletest.File, states map[*moduletest.Run]*states.State, created []*plans.ResourceInstanceChangeSrc) 75 } 76 77 func NewTest(vt arguments.ViewType, view *View) Test { 78 switch vt { 79 case arguments.ViewJSON: 80 return &TestJSON{ 81 view: NewJSONView(view), 82 } 83 case arguments.ViewHuman: 84 return &TestHuman{ 85 view: view, 86 } 87 default: 88 panic(fmt.Sprintf("unknown view type %v", vt)) 89 } 90 } 91 92 type TestHuman struct { 93 view *View 94 } 95 96 var _ Test = (*TestHuman)(nil) 97 98 func (t *TestHuman) Abstract(_ *moduletest.Suite) { 99 // Do nothing, we don't print an abstract for the human view. 100 } 101 102 func (t *TestHuman) Conclusion(suite *moduletest.Suite) { 103 t.view.streams.Println() 104 105 counts := make(map[moduletest.Status]int) 106 for _, file := range suite.Files { 107 for _, run := range file.Runs { 108 count := counts[run.Status] 109 counts[run.Status] = count + 1 110 } 111 } 112 113 if suite.Status <= moduletest.Skip { 114 // Then no tests. 115 t.view.streams.Print("Executed 0 tests") 116 if counts[moduletest.Skip] > 0 { 117 t.view.streams.Printf(", %d skipped.\n", counts[moduletest.Skip]) 118 } else { 119 t.view.streams.Println(".") 120 } 121 return 122 } 123 124 if suite.Status == moduletest.Pass { 125 t.view.streams.Print(t.view.colorize.Color("[green]Success![reset]")) 126 } else { 127 t.view.streams.Print(t.view.colorize.Color("[red]Failure![reset]")) 128 } 129 130 t.view.streams.Printf(" %d passed, %d failed", counts[moduletest.Pass], counts[moduletest.Fail]+counts[moduletest.Error]) 131 if counts[moduletest.Skip] > 0 { 132 t.view.streams.Printf(", %d skipped.\n", counts[moduletest.Skip]) 133 } else { 134 t.view.streams.Println(".") 135 } 136 } 137 138 func (t *TestHuman) File(file *moduletest.File) { 139 t.view.streams.Printf("%s... %s\n", file.Name, colorizeTestStatus(file.Status, t.view.colorize)) 140 t.Diagnostics(nil, file, file.Diagnostics) 141 } 142 143 func (t *TestHuman) Run(run *moduletest.Run, file *moduletest.File) { 144 t.view.streams.Printf(" run %q... %s\n", run.Name, colorizeTestStatus(run.Status, t.view.colorize)) 145 146 if run.Verbose != nil { 147 // We're going to be more verbose about what we print, here's the plan 148 // or the state depending on the type of run we did. 149 150 schemas := &tofu.Schemas{ 151 Providers: run.Verbose.Providers, 152 Provisioners: run.Verbose.Provisioners, 153 } 154 155 renderer := jsonformat.Renderer{ 156 Streams: t.view.streams, 157 Colorize: t.view.colorize, 158 RunningInAutomation: t.view.runningInAutomation, 159 } 160 161 if run.Config.Command == configs.ApplyTestCommand { 162 // Then we'll print the state. 163 root, outputs, err := jsonstate.MarshalForRenderer(statefile.New(run.Verbose.State, file.Name, uint64(run.Index)), schemas) 164 if err != nil { 165 run.Diagnostics = run.Diagnostics.Append(tfdiags.Sourceless( 166 tfdiags.Warning, 167 "Failed to render test state", 168 fmt.Sprintf("OpenTofu could not marshal the state for display: %v", err))) 169 } else { 170 state := jsonformat.State{ 171 StateFormatVersion: jsonstate.FormatVersion, 172 ProviderFormatVersion: jsonprovider.FormatVersion, 173 RootModule: root, 174 RootModuleOutputs: outputs, 175 ProviderSchemas: jsonprovider.MarshalForRenderer(schemas), 176 } 177 178 renderer.RenderHumanState(state) 179 } 180 } else { 181 // We'll print the plan. 182 outputs, changed, drift, attrs, err := jsonplan.MarshalForRenderer(run.Verbose.Plan, schemas) 183 if err != nil { 184 run.Diagnostics = run.Diagnostics.Append(tfdiags.Sourceless( 185 tfdiags.Warning, 186 "Failed to render test plan", 187 fmt.Sprintf("OpenTofu could not marshal the plan for display: %v", err))) 188 } else { 189 plan := jsonformat.Plan{ 190 PlanFormatVersion: jsonplan.FormatVersion, 191 ProviderFormatVersion: jsonprovider.FormatVersion, 192 OutputChanges: outputs, 193 ResourceChanges: changed, 194 ResourceDrift: drift, 195 ProviderSchemas: jsonprovider.MarshalForRenderer(schemas), 196 RelevantAttributes: attrs, 197 } 198 199 var opts []plans.Quality 200 if !run.Verbose.Plan.CanApply() { 201 opts = append(opts, plans.NoChanges) 202 } 203 if run.Verbose.Plan.Errored { 204 opts = append(opts, plans.Errored) 205 } 206 207 renderer.RenderHumanPlan(plan, run.Verbose.Plan.UIMode, opts...) 208 } 209 } 210 } 211 212 // Finally we'll print out a summary of the diagnostics from the run. 213 t.Diagnostics(run, file, run.Diagnostics) 214 } 215 216 func (t *TestHuman) DestroySummary(diags tfdiags.Diagnostics, run *moduletest.Run, file *moduletest.File, state *states.State) { 217 identifier := file.Name 218 if run != nil { 219 identifier = fmt.Sprintf("%s/%s", identifier, run.Name) 220 } 221 222 if diags.HasErrors() { 223 t.view.streams.Eprint(format.WordWrap(fmt.Sprintf("OpenTofu encountered an error destroying resources created while executing %s.\n", identifier), t.view.errorColumns())) 224 } 225 t.Diagnostics(run, file, diags) 226 227 if state.HasManagedResourceInstanceObjects() { 228 t.view.streams.Eprint(format.WordWrap(fmt.Sprintf("\nOpenTofu left the following resources in state after executing %s, these left-over resources can be viewed by reading the statefile written to disk(errored_test.tfstate) and they need to be cleaned up manually:\n", identifier), t.view.errorColumns())) 229 for _, resource := range state.AllResourceInstanceObjectAddrs() { 230 if resource.DeposedKey != states.NotDeposed { 231 t.view.streams.Eprintf(" - %s (%s)\n", resource.Instance, resource.DeposedKey) 232 continue 233 } 234 t.view.streams.Eprintf(" - %s\n", resource.Instance) 235 } 236 } 237 } 238 239 func (t *TestHuman) Diagnostics(_ *moduletest.Run, _ *moduletest.File, diags tfdiags.Diagnostics) { 240 t.view.Diagnostics(diags) 241 } 242 243 func (t *TestHuman) Interrupted() { 244 t.view.streams.Eprintln(format.WordWrap(interrupted, t.view.errorColumns())) 245 } 246 247 func (t *TestHuman) FatalInterrupt() { 248 t.view.streams.Eprintln(format.WordWrap(fatalInterrupt, t.view.errorColumns())) 249 } 250 251 func (t *TestHuman) FatalInterruptSummary(run *moduletest.Run, file *moduletest.File, existingStates map[*moduletest.Run]*states.State, created []*plans.ResourceInstanceChangeSrc) { 252 t.view.streams.Eprint(format.WordWrap(fmt.Sprintf("\nOpenTofu was interrupted while executing %s, and may not have performed the expected cleanup operations.\n", file.Name), t.view.errorColumns())) 253 254 // Print out the main state first, this is the state that isn't associated 255 // with a run block. 256 if state, exists := existingStates[nil]; exists && !state.Empty() { 257 t.view.streams.Eprint(format.WordWrap("\nOpenTofu has already created the following resources from the module under test:\n", t.view.errorColumns())) 258 for _, resource := range state.AllResourceInstanceObjectAddrs() { 259 if resource.DeposedKey != states.NotDeposed { 260 t.view.streams.Eprintf(" - %s (%s)\n", resource.Instance, resource.DeposedKey) 261 continue 262 } 263 t.view.streams.Eprintf(" - %s\n", resource.Instance) 264 } 265 } 266 267 // Then print out the other states in order. 268 for _, run := range file.Runs { 269 state, exists := existingStates[run] 270 if !exists || state.Empty() { 271 continue 272 } 273 274 t.view.streams.Eprint(format.WordWrap(fmt.Sprintf("\nOpenTofu has already created the following resources for %q from %q:\n", run.Name, run.Config.Module.Source), t.view.errorColumns())) 275 for _, resource := range state.AllResourceInstanceObjectAddrs() { 276 if resource.DeposedKey != states.NotDeposed { 277 t.view.streams.Eprintf(" - %s (%s)\n", resource.Instance, resource.DeposedKey) 278 continue 279 } 280 t.view.streams.Eprintf(" - %s\n", resource.Instance) 281 } 282 } 283 284 if len(created) == 0 { 285 // No planned changes, so we won't print anything. 286 return 287 } 288 289 var resources []string 290 for _, change := range created { 291 resources = append(resources, change.Addr.String()) 292 } 293 294 if len(resources) > 0 { 295 module := "the module under test" 296 if run.Config.ConfigUnderTest != nil { 297 module = fmt.Sprintf("%q", run.Config.Module.Source.String()) 298 } 299 300 t.view.streams.Eprint(format.WordWrap(fmt.Sprintf("\nOpenTofu was in the process of creating the following resources for %q from %s, and they may not have been destroyed:\n", run.Name, module), t.view.errorColumns())) 301 for _, resource := range resources { 302 t.view.streams.Eprintf(" - %s\n", resource) 303 } 304 } 305 } 306 307 type TestJSON struct { 308 view *JSONView 309 } 310 311 var _ Test = (*TestJSON)(nil) 312 313 func (t *TestJSON) Abstract(suite *moduletest.Suite) { 314 var fileCount, runCount int 315 316 abstract := json.TestSuiteAbstract{} 317 for name, file := range suite.Files { 318 fileCount++ 319 var runs []string 320 for _, run := range file.Runs { 321 runCount++ 322 runs = append(runs, run.Name) 323 } 324 abstract[name] = runs 325 } 326 327 files := "files" 328 runs := "run blocks" 329 330 if fileCount == 1 { 331 files = "file" 332 } 333 334 if runCount == 1 { 335 runs = "run block" 336 } 337 338 t.view.log.Info( 339 fmt.Sprintf("Found %d %s and %d %s", fileCount, files, runCount, runs), 340 "type", json.MessageTestAbstract, 341 json.MessageTestAbstract, abstract) 342 } 343 344 func (t *TestJSON) Conclusion(suite *moduletest.Suite) { 345 summary := json.TestSuiteSummary{ 346 Status: json.ToTestStatus(suite.Status), 347 } 348 for _, file := range suite.Files { 349 for _, run := range file.Runs { 350 switch run.Status { 351 case moduletest.Skip: 352 summary.Skipped++ 353 case moduletest.Pass: 354 summary.Passed++ 355 case moduletest.Error: 356 summary.Errored++ 357 case moduletest.Fail: 358 summary.Failed++ 359 } 360 } 361 } 362 363 var message bytes.Buffer 364 if suite.Status <= moduletest.Skip { 365 // Then no tests. 366 message.WriteString("Executed 0 tests") 367 if summary.Skipped > 0 { 368 message.WriteString(fmt.Sprintf(", %d skipped.", summary.Skipped)) 369 } else { 370 message.WriteString(".") 371 } 372 } else { 373 if suite.Status == moduletest.Pass { 374 message.WriteString("Success!") 375 } else { 376 message.WriteString("Failure!") 377 } 378 379 message.WriteString(fmt.Sprintf(" %d passed, %d failed", summary.Passed, summary.Failed+summary.Errored)) 380 if summary.Skipped > 0 { 381 message.WriteString(fmt.Sprintf(", %d skipped.", summary.Skipped)) 382 } else { 383 message.WriteString(".") 384 } 385 } 386 387 t.view.log.Info( 388 message.String(), 389 "type", json.MessageTestSummary, 390 json.MessageTestSummary, summary) 391 } 392 393 func (t *TestJSON) File(file *moduletest.File) { 394 t.view.log.Info( 395 fmt.Sprintf("%s... %s", file.Name, testStatus(file.Status)), 396 "type", json.MessageTestFile, 397 json.MessageTestFile, json.TestFileStatus{file.Name, json.ToTestStatus(file.Status)}, 398 "@testfile", file.Name) 399 t.Diagnostics(nil, file, file.Diagnostics) 400 } 401 402 func (t *TestJSON) Run(run *moduletest.Run, file *moduletest.File) { 403 t.view.log.Info( 404 fmt.Sprintf(" %q... %s", run.Name, testStatus(run.Status)), 405 "type", json.MessageTestRun, 406 json.MessageTestRun, json.TestRunStatus{file.Name, run.Name, json.ToTestStatus(run.Status)}, 407 "@testfile", file.Name, 408 "@testrun", run.Name) 409 410 if run.Verbose != nil { 411 412 schemas := &tofu.Schemas{ 413 Providers: run.Verbose.Providers, 414 Provisioners: run.Verbose.Provisioners, 415 } 416 417 if run.Config.Command == configs.ApplyTestCommand { 418 state, err := jsonstate.MarshalForLog(statefile.New(run.Verbose.State, file.Name, uint64(run.Index)), schemas) 419 if err != nil { 420 run.Diagnostics = run.Diagnostics.Append(tfdiags.Sourceless( 421 tfdiags.Warning, 422 "Failed to render test state", 423 fmt.Sprintf("OpenTofu could not marshal the state for display: %v", err))) 424 } else { 425 t.view.log.Info( 426 "-verbose flag enabled, printing state", 427 "type", json.MessageTestState, 428 json.MessageTestState, state, 429 "@testfile", file.Name, 430 "@testrun", run.Name) 431 } 432 } else { 433 plan, err := jsonplan.MarshalForLog(run.Verbose.Config, run.Verbose.Plan, nil, schemas) 434 if err != nil { 435 run.Diagnostics = run.Diagnostics.Append(tfdiags.Sourceless( 436 tfdiags.Warning, 437 "Failed to render test plan", 438 fmt.Sprintf("OpenTofu could not marshal the plan for display: %v", err))) 439 } else { 440 t.view.log.Info( 441 "-verbose flag enabled, printing plan", 442 "type", json.MessageTestPlan, 443 json.MessageTestPlan, plan, 444 "@testfile", file.Name, 445 "@testrun", run.Name) 446 } 447 } 448 } 449 450 t.Diagnostics(run, file, run.Diagnostics) 451 } 452 453 func (t *TestJSON) DestroySummary(diags tfdiags.Diagnostics, run *moduletest.Run, file *moduletest.File, state *states.State) { 454 if state.HasManagedResourceInstanceObjects() { 455 cleanup := json.TestFileCleanup{} 456 for _, resource := range state.AllResourceInstanceObjectAddrs() { 457 cleanup.FailedResources = append(cleanup.FailedResources, json.TestFailedResource{ 458 Instance: resource.Instance.String(), 459 DeposedKey: resource.DeposedKey.String(), 460 }) 461 } 462 463 if run != nil { 464 t.view.log.Error( 465 fmt.Sprintf("OpenTofu left some resources in state after executing %s/%s, these left-over resources can be viewed by reading the statefile written to disk(errored_test.tfstate) and they need to be cleaned up manually:", file.Name, run.Name), 466 "type", json.MessageTestCleanup, 467 json.MessageTestCleanup, cleanup, 468 "@testfile", file.Name, 469 "@testrun", run.Name) 470 } else { 471 t.view.log.Error( 472 fmt.Sprintf("OpenTofu left some resources in state after executing %s, these left-over resources can be viewed by reading the statefile written to disk(errored_test.tfstate) and they need to be cleaned up manually:", file.Name), 473 "type", json.MessageTestCleanup, 474 json.MessageTestCleanup, cleanup, 475 "@testfile", file.Name) 476 } 477 } 478 t.Diagnostics(run, file, diags) 479 } 480 481 func (t *TestJSON) Diagnostics(run *moduletest.Run, file *moduletest.File, diags tfdiags.Diagnostics) { 482 var metadata []interface{} 483 if file != nil { 484 metadata = append(metadata, "@testfile", file.Name) 485 } 486 if run != nil { 487 metadata = append(metadata, "@testrun", run.Name) 488 } 489 t.view.Diagnostics(diags, metadata...) 490 } 491 492 func (t *TestJSON) Interrupted() { 493 t.view.Log(interrupted) 494 } 495 496 func (t *TestJSON) FatalInterrupt() { 497 t.view.Log(fatalInterrupt) 498 } 499 500 func (t *TestJSON) FatalInterruptSummary(run *moduletest.Run, file *moduletest.File, existingStates map[*moduletest.Run]*states.State, created []*plans.ResourceInstanceChangeSrc) { 501 502 message := json.TestFatalInterrupt{ 503 States: make(map[string][]json.TestFailedResource), 504 } 505 506 for run, state := range existingStates { 507 if state.Empty() { 508 continue 509 } 510 511 var resources []json.TestFailedResource 512 for _, resource := range state.AllResourceInstanceObjectAddrs() { 513 resources = append(resources, json.TestFailedResource{ 514 Instance: resource.Instance.String(), 515 DeposedKey: resource.DeposedKey.String(), 516 }) 517 } 518 519 if run == nil { 520 message.State = resources 521 } else { 522 message.States[run.Name] = resources 523 } 524 } 525 526 if len(created) > 0 { 527 for _, change := range created { 528 message.Planned = append(message.Planned, change.Addr.String()) 529 } 530 } 531 532 if len(message.States) == 0 && len(message.State) == 0 && len(message.Planned) == 0 { 533 // Then we don't have any information to share with the user. 534 return 535 } 536 537 t.view.log.Error( 538 "OpenTofu was interrupted during test execution, and may not have performed the expected cleanup operations.", 539 "type", json.MessageTestInterrupt, 540 json.MessageTestInterrupt, message, 541 "@testfile", file.Name) 542 } 543 544 func colorizeTestStatus(status moduletest.Status, color *colorstring.Colorize) string { 545 switch status { 546 case moduletest.Error, moduletest.Fail: 547 return color.Color("[red]fail[reset]") 548 case moduletest.Pass: 549 return color.Color("[green]pass[reset]") 550 case moduletest.Skip: 551 return color.Color("[light_gray]skip[reset]") 552 case moduletest.Pending: 553 return color.Color("[light_gray]pending[reset]") 554 default: 555 panic("unrecognized status: " + status.String()) 556 } 557 } 558 559 func testStatus(status moduletest.Status) string { 560 switch status { 561 case moduletest.Error, moduletest.Fail: 562 return "fail" 563 case moduletest.Pass: 564 return "pass" 565 case moduletest.Skip: 566 return "skip" 567 case moduletest.Pending: 568 return "pending" 569 default: 570 panic("unrecognized status: " + status.String()) 571 } 572 } 573 574 // SaveErroredTestStateFile is a helper function to invoked in DestorySummary 575 // to store the state to errored_test.tfstate and handle associated diagnostics and errors with this operation 576 func SaveErroredTestStateFile(state *states.State, run *moduletest.Run, file *moduletest.File, view Test) { 577 var diags tfdiags.Diagnostics 578 localFileSystem := statemgr.NewFilesystem("errored_test.tfstate", encryption.StateEncryptionDisabled()) 579 stateFile := statemgr.NewStateFile() 580 stateFile.State = state 581 582 //creating an operation to invoke EmergencyDumpState() 583 var op Operation 584 switch v := view.(type) { 585 case *TestHuman: 586 op = NewOperation(arguments.ViewHuman, false, v.view) 587 v.view.streams.Eprint(format.WordWrap("\nWriting state to file: errored_test.tfstate\n", v.view.errorColumns())) 588 case *TestJSON: 589 op = &OperationJSON{ 590 view: v.view, 591 } 592 v.view.log.Info("Writing state to file: errored_test.tfstate") 593 default: 594 } 595 596 writeErr := localFileSystem.WriteStateForMigration(stateFile, true) 597 if writeErr != nil { 598 // if the write operation to errored_test.tfstate executed by WriteStateForMigration fails, as a final attempt to 599 // prevent leaving the user with no state file at all, the JSON state is printed onto the terminal by EmergencyDumpState() 600 601 if dumpErr := op.EmergencyDumpState(stateFile, encryption.StateEncryptionDisabled()); dumpErr != nil { 602 diags = diags.Append(tfdiags.Sourceless( 603 tfdiags.Error, 604 "Failed to serialize state", 605 fmt.Sprintf(stateWriteFatalErrorFmt, dumpErr), 606 )) 607 } 608 diags = diags.Append(tfdiags.Sourceless( 609 tfdiags.Error, 610 "Failed to persist state", 611 stateWriteConsoleFallbackError, 612 )) 613 } 614 view.Diagnostics(run, file, diags) 615 } 616 617 const stateWriteFatalErrorFmt = `Failed to save state after an errored test run. 618 619 Error serializing state: %s 620 621 A catastrophic error has prevented OpenTofu from persisting the state during an errored test run. 622 623 This is a serious bug in OpenTofu and should be reported. 624 ` 625 626 const stateWriteConsoleFallbackError = `The errors shown above prevented OpenTofu from writing the state to 627 the errored_test.tfstate. As a fallback, the raw state data is printed above as a JSON object. 628 629 To retry writing this state, copy the state data (from the first { to the last } inclusive) and save it into a local file named "errored_test.tfstate". 630 `