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