| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package cloud | |
| import ( | |
| "bufio" | |
| "context" | |
| "encoding/json" | |
| "errors" | |
| "fmt" | |
| "io" | |
| "net/http" | |
| "net/url" | |
| "path/filepath" | |
| "strings" | |
| "time" | |
| "github.com/hashicorp/go-tfe" | |
| tfaddr "github.com/hashicorp/terraform-registry-address" | |
| svchost "github.com/hashicorp/terraform-svchost" | |
| "github.com/hashicorp/terraform-svchost/disco" | |
| "github.com/zclconf/go-cty/cty" | |
| "github.com/hashicorp/terraform/internal/command/arguments" | |
| "github.com/hashicorp/terraform/internal/command/format" | |
| "github.com/hashicorp/terraform/internal/command/jsonformat" | |
| "github.com/hashicorp/terraform/internal/command/views" | |
| "github.com/hashicorp/terraform/internal/configs" | |
| "github.com/hashicorp/terraform/internal/logging" | |
| "github.com/hashicorp/terraform/internal/moduletest" | |
| "github.com/hashicorp/terraform/internal/plans" | |
| "github.com/hashicorp/terraform/internal/terminal" | |
| "github.com/hashicorp/terraform/internal/tfdiags" | |
| tfversion "github.com/hashicorp/terraform/version" | |
| ) | |
| // TestSuiteRunner executes any tests found in the relevant directories in TFC. | |
| // | |
| // It uploads the configuration and uses go-tfe to execute a . | |
| // | |
| // We keep this separate from Cloud, as the tests don't execute with a | |
| // particular workspace in mind but instead with a specific module from a | |
| // private registry. Many things within Cloud assume the existence of a | |
| // workspace when initialising so it isn't pratical to share this for tests. | |
| type TestSuiteRunner struct { | |
| // ConfigDirectory and TestingDirectory are the paths to the directory | |
| // that contains our configuration and our testing files. | |
| ConfigDirectory string | |
| TestingDirectory string | |
| // Config is the actual loaded config. | |
| Config *configs.Config | |
| Services *disco.Disco | |
| // Source is the private registry module we should be sending the tests | |
| // to when they execute. | |
| Source string | |
| // GlobalVariables are the variables provided by the TF_VAR_* environment | |
| // variables and -var and -var-file flags. | |
| GlobalVariables map[string]arguments.UnparsedVariableValue | |
| // Stopped and Cancelled track whether the user requested the testing | |
| // process to be interrupted. Stopped is a nice graceful exit, we'll still | |
| // tidy up any state that was created and mark the tests with relevant | |
| // `skipped` status updates. Cancelled is a hard stop right now exit, we | |
| // won't attempt to clean up any state left hanging, and tests will just | |
| // be left showing `pending` as the status. We will still print out the | |
| // destroy summary diagnostics that tell the user what state has been left | |
| // behind and needs manual clean up. | |
| Stopped bool | |
| Cancelled bool | |
| // StoppedCtx and CancelledCtx allow in progress Terraform operations to | |
| // respond to external calls from the test command. | |
| StoppedCtx context.Context | |
| CancelledCtx context.Context | |
| // Verbose tells the runner to print out plan files during each test run. | |
| Verbose bool | |
| // OperationParallelism is the limit Terraform places on total parallel operations | |
| // during the plan or apply command within a single test run. | |
| OperationParallelism int | |
| // Filters restricts which test files will be executed. | |
| Filters []string | |
| // Renderer knows how to convert JSON logs retrieved from TFE back into | |
| // human-readable. | |
| // | |
| // If this is nil, the runner will print the raw logs directly to Streams. | |
| Renderer *jsonformat.Renderer | |
| // View and Streams provide alternate ways to output raw data to the | |
| // user. | |
| View views.Test | |
| Streams *terminal.Streams | |
| // appName is the name of the instance this test suite runner is configured | |
| // against. Can be "HCP Terraform" or "Terraform Enterprise" | |
| appName string | |
| // clientOverride allows tests to specify the client instead of letting the | |
| // system initialise one itself. | |
| clientOverride *tfe.Client | |
| } | |
| func (runner *TestSuiteRunner) Stop() { | |
| runner.Stopped = true | |
| } | |
| func (runner *TestSuiteRunner) IsStopped() bool { | |
| return runner.Stopped | |
| } | |
| func (runner *TestSuiteRunner) Cancel() { | |
| runner.Cancelled = true | |
| } | |
| func (runner *TestSuiteRunner) Test(_ bool) (moduletest.Status, tfdiags.Diagnostics) { | |
| var diags tfdiags.Diagnostics | |
| configDirectory, err := filepath.Abs(runner.ConfigDirectory) | |
| if err != nil { | |
| diags = diags.Append(fmt.Errorf("Failed to get absolute path of the configuration directory: %v", err)) | |
| return moduletest.Error, diags | |
| } | |
| variables, variableDiags := ParseCloudRunTestVariables(runner.GlobalVariables) | |
| diags = diags.Append(variableDiags) | |
| if variableDiags.HasErrors() { | |
| // Stop early if we couldn't parse the global variables. | |
| return moduletest.Error, diags | |
| } | |
| addr, err := tfaddr.ParseModuleSource(runner.Source) | |
| if err != nil { | |
| if parserError, ok := err.(*tfaddr.ParserError); ok { | |
| diags = diags.Append(tfdiags.AttributeValue( | |
| tfdiags.Error, | |
| parserError.Summary, | |
| parserError.Detail, | |
| cty.Path{cty.GetAttrStep{Name: "source"}})) | |
| } else { | |
| diags = diags.Append(err) | |
| } | |
| return moduletest.Error, diags | |
| } | |
| if addr.Package.Host == tfaddr.DefaultModuleRegistryHost { | |
| // Then they've reference something from the public registry. We can't | |
| // run tests against that in this way yet. | |
| diags = diags.Append(tfdiags.AttributeValue( | |
| tfdiags.Error, | |
| "Module source points to the public registry", | |
| "HCP Terraform and Terraform Enterprise can only execute tests for modules held within private registries.", | |
| cty.Path{cty.GetAttrStep{Name: "source"}})) | |
| return moduletest.Error, diags | |
| } | |
| id := tfe.RegistryModuleID{ | |
| Organization: addr.Package.Namespace, | |
| Name: addr.Package.Name, | |
| Provider: addr.Package.TargetSystem, | |
| Namespace: addr.Package.Namespace, | |
| RegistryName: tfe.PrivateRegistry, | |
| } | |
| client, module, clientDiags := runner.client(addr, id) | |
| diags = diags.Append(clientDiags) | |
| if clientDiags.HasErrors() { | |
| return moduletest.Error, diags | |
| } | |
| configurationVersion, err := client.ConfigurationVersions.CreateForRegistryModule(runner.StoppedCtx, id) | |
| if err != nil { | |
| diags = diags.Append(runner.generalError("Failed to create configuration version", err)) | |
| return moduletest.Error, diags | |
| } | |
| if runner.Stopped || runner.Cancelled { | |
| return moduletest.Error, diags | |
| } | |
| if err := client.ConfigurationVersions.Upload(runner.StoppedCtx, configurationVersion.UploadURL, configDirectory); err != nil { | |
| diags = diags.Append(runner.generalError("Failed to upload configuration version", err)) | |
| return moduletest.Error, diags | |
| } | |
| if runner.Stopped || runner.Cancelled { | |
| return moduletest.Error, diags | |
| } | |
| // From here, we'll pass any cancellation signals into the test run instead | |
| // of cancelling things locally. The reason for this is we want to make sure | |
| // the test run tidies up any state properly. This means, we'll send the | |
| // cancellation signals and then still wait for and process the logs. | |
| // | |
| // This also means that all calls to HCP Terraform will use context.Background() | |
| // instead of the stopped or cancelled context as we want them to finish and | |
| // the run to be cancelled by HCP Terraform properly. | |
| opts := tfe.TestRunCreateOptions{ | |
| Filters: runner.Filters, | |
| TestDirectory: tfe.String(runner.TestingDirectory), | |
| Verbose: tfe.Bool(runner.Verbose), | |
| Parallelism: tfe.Int(runner.OperationParallelism), | |
| Variables: func() []*tfe.RunVariable { | |
| runVariables := make([]*tfe.RunVariable, 0, len(variables)) | |
| for name, value := range variables { | |
| runVariables = append(runVariables, &tfe.RunVariable{ | |
| Key: name, | |
| Value: value, | |
| }) | |
| } | |
| return runVariables | |
| }(), | |
| ConfigurationVersion: configurationVersion, | |
| RegistryModule: module, | |
| } | |
| run, err := client.TestRuns.Create(context.Background(), opts) | |
| if err != nil { | |
| diags = diags.Append(runner.generalError("Failed to create test run", err)) | |
| return moduletest.Error, diags | |
| } | |
| runningCtx, done := context.WithCancel(context.Background()) | |
| go func() { | |
| defer logging.PanicHandler() | |
| defer done() | |
| // Let's wait for the test run to start separately, so we can provide | |
| // some nice updates while we wait. | |
| completed := false | |
| started := time.Now() | |
| updated := started | |
| for i := 0; !completed; i++ { | |
| run, err := client.TestRuns.Read(context.Background(), id, run.ID) | |
| if err != nil { | |
| diags = diags.Append(runner.generalError("Failed to retrieve test run", err)) | |
| return // exit early | |
| } | |
| if run.Status != tfe.TestRunQueued { | |
| // We block as long as the test run is still queued. | |
| completed = true | |
| continue // We can render the logs now. | |
| } | |
| current := time.Now() | |
| if i == 0 || current.Sub(updated).Seconds() > 30 { | |
| updated = current | |
| // TODO: Provide better updates based on queue status etc. | |
| // We could look through the queue to find out exactly where the | |
| // test run is and give a count down. Other stuff like that. | |
| // For now, we'll just print a simple status updated. | |
| runner.View.TFCStatusUpdate(run.Status, current.Sub(started)) | |
| } | |
| } | |
| // The test run has actually started now, so let's render the logs. | |
| logDiags := runner.renderLogs(client, run, id) | |
| diags = diags.Append(logDiags) | |
| }() | |
| // We're doing a couple of things in the wait function. Firstly, waiting | |
| // for the test run to actually finish. Secondly, listening for interrupt | |
| // signals and forwarding them onto TFC. | |
| waitDiags := runner.wait(runningCtx, client, run, id) | |
| diags = diags.Append(waitDiags) | |
| if diags.HasErrors() { | |
| return moduletest.Error, diags | |
| } | |
| // Refresh the run now we know it is finished. | |
| run, err = client.TestRuns.Read(context.Background(), id, run.ID) | |
| if err != nil { | |
| diags = diags.Append(runner.generalError("Failed to retrieve completed test run", err)) | |
| return moduletest.Error, diags | |
| } | |
| if run.Status != tfe.TestRunFinished { | |
| // The only reason we'd get here without the run being finished properly | |
| // is because the run errored outside the scope of the tests, or because | |
| // the run was cancelled. Either way, we can just mark it has having | |
| // errored for the purpose of our return code. | |
| return moduletest.Error, diags | |
| } | |
| // Otherwise the run has finished successfully, and we can look at the | |
| // actual status of the test instead of the run to figure out what status we | |
| // should return. | |
| switch run.TestStatus { | |
| case tfe.TestError: | |
| return moduletest.Error, diags | |
| case tfe.TestFail: | |
| return moduletest.Fail, diags | |
| case tfe.TestPass: | |
| return moduletest.Pass, diags | |
| case tfe.TestPending: | |
| return moduletest.Pending, diags | |
| case tfe.TestSkip: | |
| return moduletest.Skip, diags | |
| default: | |
| panic("found unrecognized test status: " + run.TestStatus) | |
| } | |
| } | |
| // discover the TFC/E API service URL | |
| func discoverTfeURL(hostname svchost.Hostname, services *disco.Disco) (*url.URL, error) { | |
| host, err := services.Discover(hostname) | |
| if err != nil { | |
| var serviceDiscoErr *disco.ErrServiceDiscoveryNetworkRequest | |
| switch { | |
| case errors.As(err, &serviceDiscoErr): | |
| err = fmt.Errorf("a network issue prevented cloud configuration; %w", err) | |
| return nil, err | |
| default: | |
| return nil, err | |
| } | |
| } | |
| return host.ServiceURL(tfeServiceID) | |
| } | |
| func (runner *TestSuiteRunner) client(addr tfaddr.Module, id tfe.RegistryModuleID) (*tfe.Client, *tfe.RegistryModule, tfdiags.Diagnostics) { | |
| var diags tfdiags.Diagnostics | |
| var client *tfe.Client | |
| if runner.clientOverride != nil { | |
| client = runner.clientOverride | |
| } else { | |
| service, err := discoverTfeURL(addr.Package.Host, runner.Services) | |
| if err != nil { | |
| diags = diags.Append(tfdiags.AttributeValue( | |
| tfdiags.Error, | |
| strings.ToUpper(err.Error()[:1])+err.Error()[1:], | |
| "", // no description is needed here, the error is clear | |
| cty.Path{cty.GetAttrStep{Name: "hostname"}}, | |
| )) | |
| return nil, nil, diags | |
| } | |
| token, err := CliConfigToken(addr.Package.Host, runner.Services) | |
| if err != nil { | |
| diags = diags.Append(tfdiags.AttributeValue( | |
| tfdiags.Error, | |
| strings.ToUpper(err.Error()[:1])+err.Error()[1:], | |
| "", // no description is needed here, the error is clear | |
| cty.Path{cty.GetAttrStep{Name: "hostname"}}, | |
| )) | |
| return nil, nil, diags | |
| } | |
| if token == "" { | |
| hostname := addr.Package.Host.ForDisplay() | |
| loginCommand := "terraform login" | |
| if hostname != defaultHostname { | |
| loginCommand = loginCommand + " " + hostname | |
| } | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Required token could not be found", | |
| fmt.Sprintf( | |
| "Run the following command to generate a token for %s:\n %s", | |
| hostname, | |
| loginCommand, | |
| ), | |
| )) | |
| return nil, nil, diags | |
| } | |
| cfg := &tfe.Config{ | |
| Address: service.String(), | |
| BasePath: service.Path, | |
| Token: token, | |
| Headers: make(http.Header), | |
| RetryLogHook: runner.View.TFCRetryHook, | |
| } | |
| // Set the version header to the current version. | |
| cfg.Headers.Set(tfversion.Header, tfversion.Version) | |
| cfg.Headers.Set(headerSourceKey, headerSourceValue) | |
| if client, err = tfe.NewClient(cfg); err != nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Failed to create the HCP Terraform or Terraform Enterprise client", | |
| fmt.Sprintf( | |
| `Encountered an unexpected error while creating the `+ | |
| `HCP Terraform or Terraform Enterprise client: %s.`, err, | |
| ), | |
| )) | |
| return nil, nil, diags | |
| } | |
| } | |
| module, err := client.RegistryModules.Read(runner.StoppedCtx, id) | |
| if err != nil { | |
| // Then the module doesn't exist, and we can't run tests against it. | |
| if err == tfe.ErrResourceNotFound { | |
| err = fmt.Errorf("module %q was not found.\n\nPlease ensure that the organization and hostname are correct and that your API token for %s is valid.", addr.ForDisplay(), addr.Package.Host.ForDisplay()) | |
| } | |
| diags = diags.Append(tfdiags.AttributeValue( | |
| tfdiags.Error, | |
| fmt.Sprintf("Failed to read module %q", addr.ForDisplay()), | |
| fmt.Sprintf("Encountered an unexpected error while the module: %s", err), | |
| cty.Path{cty.GetAttrStep{Name: "source"}})) | |
| return client, nil, diags | |
| } | |
| // Enable retries for server errors. | |
| client.RetryServerErrors(true) | |
| runner.appName = client.AppName() | |
| if isValidAppName(runner.appName) { | |
| runner.appName = "HCP Terraform" | |
| } | |
| // Aaaaand I'm done. | |
| return client, module, diags | |
| } | |
| func (runner *TestSuiteRunner) wait(ctx context.Context, client *tfe.Client, run *tfe.TestRun, moduleId tfe.RegistryModuleID) tfdiags.Diagnostics { | |
| var diags tfdiags.Diagnostics | |
| handleCancelled := func() { | |
| if err := client.TestRuns.Cancel(context.Background(), moduleId, run.ID); err != nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Could not cancel the test run", | |
| fmt.Sprintf("Terraform could not cancel the test run, you will have to navigate to the %s console and cancel the test run manually.\n\nThe error message received when cancelling the test run was %s", client.AppName(), err))) | |
| return | |
| } | |
| // At this point we've requested a force cancel, and we know that | |
| // Terraform locally is just going to quit after some amount of time so | |
| // we'll just wait for that to happen or for HCP Terraform to finish, whichever | |
| // happens first. | |
| <-ctx.Done() | |
| } | |
| handleStopped := func() { | |
| if err := client.TestRuns.Cancel(context.Background(), moduleId, run.ID); err != nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Could not stop the test run", | |
| fmt.Sprintf("Terraform could not stop the test run, you will have to navigate to the %s console and cancel the test run manually.\n\nThe error message received when stopping the test run was %s", client.AppName(), err))) | |
| return | |
| } | |
| // We've request a cancel, we're happy to just wait for HCP Terraform to cancel | |
| // the run appropriately. | |
| select { | |
| case <-runner.CancelledCtx.Done(): | |
| // We got more pushy, let's force cancel. | |
| handleCancelled() | |
| case <-ctx.Done(): | |
| // It finished normally after we request the cancel. Do nothing. | |
| } | |
| } | |
| select { | |
| case <-runner.StoppedCtx.Done(): | |
| // The StoppedCtx is passed in from the command package, which is | |
| // listening for interrupts from the user. After the first interrupt the | |
| // StoppedCtx is triggered. | |
| handleStopped() | |
| case <-ctx.Done(): | |
| // The remote run finished normally! Do nothing. | |
| } | |
| return diags | |
| } | |
| func (runner *TestSuiteRunner) renderLogs(client *tfe.Client, run *tfe.TestRun, moduleId tfe.RegistryModuleID) tfdiags.Diagnostics { | |
| var diags tfdiags.Diagnostics | |
| logs, err := client.TestRuns.Logs(context.Background(), moduleId, run.ID) | |
| if err != nil { | |
| diags = diags.Append(runner.generalError("Failed to retrieve logs", err)) | |
| return diags | |
| } | |
| reader := bufio.NewReaderSize(logs, 64*1024) | |
| for next := true; next; { | |
| var l, line []byte | |
| var err error | |
| for isPrefix := true; isPrefix; { | |
| l, isPrefix, err = reader.ReadLine() | |
| if err != nil { | |
| if err != io.EOF { | |
| diags = diags.Append(runner.generalError("Failed to read logs", err)) | |
| return diags | |
| } | |
| next = false | |
| } | |
| line = append(line, l...) | |
| } | |
| if next || len(line) > 0 { | |
| if runner.Renderer != nil { | |
| log := jsonformat.JSONLog{} | |
| if err := json.Unmarshal(line, &log); err != nil { | |
| runner.Streams.Println(string(line)) // Just print the raw line so the user can still try and interpret the information. | |
| continue | |
| } | |
| // Most of the log types can be rendered with just the | |
| // information they contain. We just pass these straight into | |
| // the renderer. Others, however, need additional context that | |
| // isn't available within the renderer so we process them first. | |
| switch log.Type { | |
| case jsonformat.LogTestInterrupt: | |
| interrupt := log.TestFatalInterrupt | |
| runner.Streams.Eprintln(format.WordWrap(log.Message, runner.Streams.Stderr.Columns())) | |
| if len(interrupt.State) > 0 { | |
| runner.Streams.Eprint(format.WordWrap("\nTerraform has already created the following resources from the module under test:\n", runner.Streams.Stderr.Columns())) | |
| for _, resource := range interrupt.State { | |
| if len(resource.DeposedKey) > 0 { | |
| runner.Streams.Eprintf(" - %s (%s)\n", resource.Instance, resource.DeposedKey) | |
| } else { | |
| runner.Streams.Eprintf(" - %s\n", resource.Instance) | |
| } | |
| } | |
| } | |
| if len(interrupt.States) > 0 { | |
| for run, resources := range interrupt.States { | |
| runner.Streams.Eprint(format.WordWrap(fmt.Sprintf("\nTerraform has already created the following resources for %q:\n", run), runner.Streams.Stderr.Columns())) | |
| for _, resource := range resources { | |
| if len(resource.DeposedKey) > 0 { | |
| runner.Streams.Eprintf(" - %s (%s)\n", resource.Instance, resource.DeposedKey) | |
| } else { | |
| runner.Streams.Eprintf(" - %s\n", resource.Instance) | |
| } | |
| } | |
| } | |
| } | |
| if len(interrupt.Planned) > 0 { | |
| module := "the module under test" | |
| for _, run := range runner.Config.Module.Tests[log.TestFile].Runs { | |
| if run.Name == log.TestRun && run.ConfigUnderTest != nil { | |
| module = fmt.Sprintf("%q", run.Module.Source.String()) | |
| } | |
| } | |
| runner.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", log.TestRun, module), runner.Streams.Stderr.Columns())) | |
| for _, resource := range interrupt.Planned { | |
| runner.Streams.Eprintf(" - %s\n", resource) | |
| } | |
| } | |
| case jsonformat.LogTestPlan: | |
| var uimode plans.Mode | |
| for _, run := range runner.Config.Module.Tests[log.TestFile].Runs { | |
| if run.Name == log.TestRun { | |
| switch run.Options.Mode { | |
| case configs.RefreshOnlyTestMode: | |
| uimode = plans.RefreshOnlyMode | |
| case configs.NormalTestMode: | |
| uimode = plans.NormalMode | |
| } | |
| // Don't keep searching the runs. | |
| break | |
| } | |
| } | |
| runner.Renderer.RenderHumanPlan(*log.TestPlan, uimode) | |
| case jsonformat.LogTestState: | |
| runner.Renderer.RenderHumanState(*log.TestState) | |
| default: | |
| // For all the rest we can just hand over to the renderer | |
| // to handle directly. | |
| if err := runner.Renderer.RenderLog(&log); err != nil { | |
| runner.Streams.Println(string(line)) // Just print the raw line so the can still try and interpret the information. | |
| continue | |
| } | |
| } | |
| } else { | |
| runner.Streams.Println(string(line)) // If the renderer is null, it means the user just wants to see the raw JSON outputs anyway. | |
| } | |
| } | |
| } | |
| return diags | |
| } | |
| func (runner *TestSuiteRunner) generalError(msg string, err error) error { | |
| var diags tfdiags.Diagnostics | |
| if urlErr, ok := err.(*url.Error); ok { | |
| err = urlErr.Err | |
| } | |
| switch err { | |
| case context.Canceled: | |
| return err | |
| case tfe.ErrResourceNotFound: | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| fmt.Sprintf("%s: %v", msg, err), | |
| fmt.Sprintf("For security, %s return '404 Not Found' responses for resources\n", runner.appName)+ | |
| "for resources that a user doesn't have access to, in addition to resources that\n"+ | |
| "do not exist. If the resource does exist, please check the permissions of the provided token.", | |
| )) | |
| return diags.Err() | |
| default: | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| fmt.Sprintf("%s: %v", msg, err), | |
| fmt.Sprintf(`%s returned an unexpected error. Sometimes `, runner.appName)+ | |
| `this is caused by network connection problems, in which case you could retry `+ | |
| `the command. If the issue persists please open a support ticket to get help `+ | |
| `resolving the problem.`, | |
| )) | |
| return diags.Err() | |
| } | |
| } |