| // Copyright (c) HashiCorp, Inc. |
| // SPDX-License-Identifier: BUSL-1.1 |
| |
| package command |
| |
| import ( |
| "context" |
| "path/filepath" |
| "strings" |
| "time" |
| |
| "github.com/hashicorp/terraform/internal/backend/local" |
| "github.com/hashicorp/terraform/internal/cloud" |
| "github.com/hashicorp/terraform/internal/command/arguments" |
| "github.com/hashicorp/terraform/internal/command/jsonformat" |
| "github.com/hashicorp/terraform/internal/command/junit" |
| "github.com/hashicorp/terraform/internal/command/views" |
| "github.com/hashicorp/terraform/internal/logging" |
| "github.com/hashicorp/terraform/internal/moduletest" |
| "github.com/hashicorp/terraform/internal/tfdiags" |
| ) |
| |
| type TestCommand struct { |
| Meta |
| } |
| |
| func (c *TestCommand) Help() string { |
| helpText := ` |
| Usage: terraform [global options] test [options] |
| |
| Executes automated integration tests against the current Terraform |
| configuration. |
| |
| Terraform will search for .tftest.hcl files within the current configuration |
| and testing directories. Terraform will then execute the testing run blocks |
| within any testing files in order, and verify conditional checks and |
| assertions against the created infrastructure. |
| |
| This command creates real infrastructure and will attempt to clean up the |
| testing infrastructure on completion. Monitor the output carefully to ensure |
| this cleanup process is successful. |
| |
| Options: |
| |
| -cloud-run=source If specified, Terraform will execute this test run |
| remotely using HCP Terraform or Terraform Enterprise. |
| You must specify the source of a module registered in |
| a private module registry as the argument to this flag. |
| This allows Terraform to associate the cloud run with |
| the correct HCP Terraform or Terraform Enterprise module |
| and organization. |
| |
| -filter=testfile If specified, Terraform will only execute the test files |
| specified by this flag. You can use this option multiple |
| times to execute more than one test file. |
| |
| -json If specified, machine readable output will be printed in |
| JSON format |
| |
| -junit-xml=path Saves a test report in JUnit XML format to the specified |
| file. This is currently incompatible with remote test |
| execution using the the -cloud-run option. The file path |
| must be relative or absolute. |
| |
| -no-color If specified, output won't contain any color. |
| |
| -parallelism=n Limit the number of concurrent operations within the |
| plan/apply operation of a test run. Defaults to 10. |
| |
| -test-directory=path Set the Terraform test directory, defaults to "tests". |
| |
| -var 'foo=bar' Set a value for one of the input variables in the root |
| module of the configuration. Use this option more than |
| once to set more than one variable. |
| |
| -var-file=filename Load variable values from the given file, in addition |
| to the default files terraform.tfvars and *.auto.tfvars. |
| Use this option more than once to include more than one |
| variables file. |
| |
| -verbose Print the plan or state for each test run block as it |
| executes. |
| ` |
| return strings.TrimSpace(helpText) |
| } |
| |
| func (c *TestCommand) Synopsis() string { |
| return "Execute integration tests for Terraform modules" |
| } |
| |
| func (c *TestCommand) Run(rawArgs []string) int { |
| var diags tfdiags.Diagnostics |
| |
| common, rawArgs := arguments.ParseView(rawArgs) |
| c.View.Configure(common) |
| |
| // Since we build the colorizer for the cloud runner outside the views |
| // package we need to propagate our no-color setting manually. Once the |
| // cloud package is fully migrated over to the new streams IO we should be |
| // able to remove this. |
| c.Meta.color = !common.NoColor |
| c.Meta.Color = c.Meta.color |
| |
| args, diags := arguments.ParseTest(rawArgs) |
| if diags.HasErrors() { |
| c.View.Diagnostics(diags) |
| c.View.HelpPrompt("test") |
| return 1 |
| } |
| c.Meta.parallelism = args.OperationParallelism |
| |
| view := views.NewTest(args.ViewType, c.View) |
| |
| // The specified testing directory must be a relative path, and it must |
| // point to a directory that is a descendant of the configuration directory. |
| if !filepath.IsLocal(args.TestDirectory) { |
| diags = diags.Append(tfdiags.Sourceless( |
| tfdiags.Error, |
| "Invalid testing directory", |
| "The testing directory must be a relative path pointing to a directory local to the configuration directory.")) |
| |
| view.Diagnostics(nil, nil, diags) |
| return 1 |
| } |
| |
| config, configDiags := c.loadConfigWithTests(".", args.TestDirectory) |
| diags = diags.Append(configDiags) |
| if configDiags.HasErrors() { |
| view.Diagnostics(nil, nil, diags) |
| return 1 |
| } |
| |
| // Users can also specify variables via the command line, so we'll parse |
| // all that here. |
| var items []arguments.FlagNameValue |
| for _, variable := range args.Vars.All() { |
| items = append(items, arguments.FlagNameValue{ |
| Name: variable.Name, |
| Value: variable.Value, |
| }) |
| } |
| c.variableArgs = arguments.FlagNameValueSlice{Items: &items} |
| |
| // Collect variables for "terraform test" |
| testVariables, variableDiags := c.collectVariableValuesForTests(args.TestDirectory) |
| diags = diags.Append(variableDiags) |
| |
| variables, variableDiags := c.collectVariableValues() |
| diags = diags.Append(variableDiags) |
| if variableDiags.HasErrors() { |
| view.Diagnostics(nil, nil, diags) |
| return 1 |
| } |
| |
| opts, err := c.contextOpts() |
| if err != nil { |
| diags = diags.Append(err) |
| view.Diagnostics(nil, nil, diags) |
| return 1 |
| } |
| |
| // Print out all the diagnostics we have from the setup. These will just be |
| // warnings, and we want them out of the way before we start the actual |
| // testing. |
| view.Diagnostics(nil, nil, diags) |
| |
| // We have two levels of interrupt here. A 'stop' and a 'cancel'. A 'stop' |
| // is a soft request to stop. We'll finish the current test, do the tidy up, |
| // but then skip all remaining tests and run blocks. A 'cancel' is a hard |
| // request to stop now. We'll cancel the current operation immediately |
| // even if it's a delete operation, and we won't clean up any infrastructure |
| // if we're halfway through a test. We'll print details explaining what was |
| // stopped so the user can do their best to recover from it. |
| |
| runningCtx, done := context.WithCancel(context.Background()) |
| stopCtx, stop := context.WithCancel(runningCtx) |
| cancelCtx, cancel := context.WithCancel(context.Background()) |
| |
| var runner moduletest.TestSuiteRunner |
| if len(args.CloudRunSource) > 0 { |
| |
| var renderer *jsonformat.Renderer |
| if args.ViewType == arguments.ViewHuman { |
| // We only set the renderer if we want Human-readable output. |
| // Otherwise, we just let the runner echo whatever data it receives |
| // back from the agent anyway. |
| renderer = &jsonformat.Renderer{ |
| Streams: c.Streams, |
| Colorize: c.Colorize(), |
| RunningInAutomation: c.RunningInAutomation, |
| } |
| } |
| |
| runner = &cloud.TestSuiteRunner{ |
| ConfigDirectory: ".", // Always loading from the current directory. |
| TestingDirectory: args.TestDirectory, |
| Config: config, |
| Services: c.Services, |
| Source: args.CloudRunSource, |
| GlobalVariables: variables, |
| Stopped: false, |
| Cancelled: false, |
| StoppedCtx: stopCtx, |
| CancelledCtx: cancelCtx, |
| Verbose: args.Verbose, |
| OperationParallelism: args.OperationParallelism, |
| Filters: args.Filter, |
| Renderer: renderer, |
| View: view, |
| Streams: c.Streams, |
| } |
| } else { |
| localRunner := &local.TestSuiteRunner{ |
| Config: config, |
| // The GlobalVariables are loaded from the |
| // main configuration directory |
| // The GlobalTestVariables are loaded from the |
| // test directory |
| GlobalVariables: variables, |
| GlobalTestVariables: testVariables, |
| TestingDirectory: args.TestDirectory, |
| Opts: opts, |
| View: view, |
| Stopped: false, |
| Cancelled: false, |
| StoppedCtx: stopCtx, |
| CancelledCtx: cancelCtx, |
| Filter: args.Filter, |
| Verbose: args.Verbose, |
| } |
| |
| // JUnit output is only compatible with local test execution |
| if args.JUnitXMLFile != "" { |
| // Make sure TestCommand's calls loadConfigWithTests before this code, so configLoader is not nil |
| localRunner.JUnit = junit.NewTestJUnitXMLFile(args.JUnitXMLFile, c.configLoader, localRunner) |
| } |
| |
| runner = localRunner |
| } |
| |
| var testDiags tfdiags.Diagnostics |
| var status moduletest.Status |
| |
| go func() { |
| defer logging.PanicHandler() |
| defer done() |
| defer stop() |
| defer cancel() |
| |
| status, testDiags = runner.Test() |
| }() |
| |
| // Wait for the operation to complete, or for an interrupt to occur. |
| select { |
| case <-c.ShutdownCh: |
| // Nice request to be cancelled. |
| |
| view.Interrupted() |
| runner.Stop() |
| stop() |
| |
| select { |
| case <-c.ShutdownCh: |
| // The user pressed it again, now we have to get it to stop as |
| // fast as possible. |
| |
| view.FatalInterrupt() |
| runner.Cancel() |
| cancel() |
| |
| waitTime := 5 * time.Second |
| if len(args.CloudRunSource) > 0 { |
| // We wait longer for cloud runs because the agent should force |
| // kill the remote job after 5 seconds (as defined above). |
| // |
| // This can take longer as the remote agent doesn't receive the |
| // interrupt immediately. So for cloud runs, we'll wait a minute |
| // which should give the remote process enough to receive the |
| // signal, process it, and exit. |
| // |
| // If after a minute, the job still hasn't finished then we |
| // assume something else has gone wrong and we'll just have to |
| // live with the consequences. |
| waitTime = time.Minute |
| } |
| |
| // We'll wait 5 seconds for this operation to finish now, regardless |
| // of whether it finishes successfully or not. |
| select { |
| case <-runningCtx.Done(): |
| case <-time.After(waitTime): |
| } |
| |
| case <-runningCtx.Done(): |
| // The application finished nicely after the request was stopped. |
| } |
| case <-runningCtx.Done(): |
| // tests finished normally with no interrupts. |
| } |
| |
| view.Diagnostics(nil, nil, testDiags) |
| |
| if status != moduletest.Pass { |
| return 1 |
| } |
| return 0 |
| } |