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  }