github.com/opentofu/opentofu@v1.7.1/internal/cloud/backend_plan.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 cloud
     7  
     8  import (
     9  	"bufio"
    10  	"context"
    11  	"encoding/json"
    12  	"errors"
    13  	"fmt"
    14  	"io"
    15  	"log"
    16  	"os"
    17  	"path/filepath"
    18  	"strconv"
    19  	"strings"
    20  	"syscall"
    21  	"time"
    22  
    23  	tfe "github.com/hashicorp/go-tfe"
    24  	version "github.com/hashicorp/go-version"
    25  
    26  	"github.com/opentofu/opentofu/internal/backend"
    27  	"github.com/opentofu/opentofu/internal/cloud/cloudplan"
    28  	"github.com/opentofu/opentofu/internal/command/jsonformat"
    29  	"github.com/opentofu/opentofu/internal/configs"
    30  	"github.com/opentofu/opentofu/internal/genconfig"
    31  	"github.com/opentofu/opentofu/internal/plans"
    32  	"github.com/opentofu/opentofu/internal/tfdiags"
    33  )
    34  
    35  var planConfigurationVersionsPollInterval = 500 * time.Millisecond
    36  
    37  func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) {
    38  	log.Printf("[INFO] cloud: starting Plan operation")
    39  
    40  	var diags tfdiags.Diagnostics
    41  
    42  	if !w.Permissions.CanQueueRun {
    43  		diags = diags.Append(tfdiags.Sourceless(
    44  			tfdiags.Error,
    45  			"Insufficient rights to generate a plan",
    46  			"The provided credentials have insufficient rights to generate a plan. In order "+
    47  				"to generate plans, at least plan permissions on the workspace are required.",
    48  		))
    49  		return nil, diags.Err()
    50  	}
    51  
    52  	if b.ContextOpts != nil && b.ContextOpts.Parallelism != defaultParallelism {
    53  		diags = diags.Append(tfdiags.Sourceless(
    54  			tfdiags.Error,
    55  			"Custom parallelism values are currently not supported",
    56  			`Cloud backend does not support setting a custom parallelism `+
    57  				`value at this time.`,
    58  		))
    59  	}
    60  
    61  	if op.PlanFile != nil {
    62  		diags = diags.Append(tfdiags.Sourceless(
    63  			tfdiags.Error,
    64  			"Displaying a saved plan is currently not supported",
    65  			`Cloud backend currently requires configuration to be present and `+
    66  				`does not accept an existing saved plan as an argument at this time.`,
    67  		))
    68  	}
    69  
    70  	if !op.HasConfig() && op.PlanMode != plans.DestroyMode {
    71  		diags = diags.Append(tfdiags.Sourceless(
    72  			tfdiags.Error,
    73  			"No configuration files found",
    74  			`Plan requires configuration to be present. Planning without a configuration `+
    75  				`would mark everything for destruction, which is normally not what is desired. `+
    76  				`If you would like to destroy everything, please run plan with the "-destroy" `+
    77  				`flag or create a single empty configuration file. Otherwise, please create `+
    78  				`a OpenTofu configuration file in the path being executed and try again.`,
    79  		))
    80  	}
    81  
    82  	if len(op.GenerateConfigOut) > 0 {
    83  		diags = diags.Append(genconfig.ValidateTargetFile(op.GenerateConfigOut))
    84  	}
    85  
    86  	// Return if there are any errors.
    87  	if diags.HasErrors() {
    88  		return nil, diags.Err()
    89  	}
    90  
    91  	// If the run errored, exit before checking whether to save a plan file
    92  	run, err := b.plan(stopCtx, cancelCtx, op, w)
    93  	if err != nil {
    94  		return nil, err
    95  	}
    96  
    97  	// Save plan file if -out <FILE> was specified
    98  	if op.PlanOutPath != "" {
    99  		bookmark := cloudplan.NewSavedPlanBookmark(run.ID, b.hostname)
   100  		err = bookmark.Save(op.PlanOutPath)
   101  		if err != nil {
   102  			return nil, err
   103  		}
   104  	}
   105  
   106  	// Everything succeded, so display next steps
   107  	op.View.PlanNextStep(op.PlanOutPath, op.GenerateConfigOut)
   108  
   109  	return run, nil
   110  }
   111  
   112  func (b *Cloud) plan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) {
   113  	if b.CLI != nil {
   114  		header := planDefaultHeader
   115  		if op.Type == backend.OperationTypeApply || op.Type == backend.OperationTypeRefresh {
   116  			header = applyDefaultHeader
   117  		}
   118  		b.CLI.Output(b.Colorize().Color(strings.TrimSpace(header) + "\n"))
   119  	}
   120  
   121  	// Plan-only means they ran tofu plan without -out.
   122  	provisional := op.PlanOutPath != ""
   123  	planOnly := op.Type == backend.OperationTypePlan && !provisional
   124  
   125  	configOptions := tfe.ConfigurationVersionCreateOptions{
   126  		AutoQueueRuns: tfe.Bool(false),
   127  		Speculative:   tfe.Bool(planOnly),
   128  		Provisional:   tfe.Bool(provisional),
   129  	}
   130  
   131  	cv, err := b.client.ConfigurationVersions.Create(stopCtx, w.ID, configOptions)
   132  	if err != nil {
   133  		return nil, generalError("Failed to create configuration version", err)
   134  	}
   135  
   136  	var configDir string
   137  	if op.ConfigDir != "" {
   138  		// De-normalize the configuration directory path.
   139  		configDir, err = filepath.Abs(op.ConfigDir)
   140  		if err != nil {
   141  			return nil, generalError(
   142  				"Failed to get absolute path of the configuration directory: %v", err)
   143  		}
   144  
   145  		// Make sure to take the working directory into account by removing
   146  		// the working directory from the current path. This will result in
   147  		// a path that points to the expected root of the workspace.
   148  		configDir = filepath.Clean(strings.TrimSuffix(
   149  			filepath.Clean(configDir),
   150  			filepath.Clean(w.WorkingDirectory),
   151  		))
   152  
   153  		// If the workspace has a subdirectory as its working directory then
   154  		// our configDir will be some parent directory of the current working
   155  		// directory. Users are likely to find that surprising, so we'll
   156  		// produce an explicit message about it to be transparent about what
   157  		// we are doing and why.
   158  		if w.WorkingDirectory != "" && filepath.Base(configDir) != w.WorkingDirectory {
   159  			if b.CLI != nil {
   160  				b.CLI.Output(fmt.Sprintf(strings.TrimSpace(`
   161  The remote workspace is configured to work with configuration at
   162  %s relative to the target repository.
   163  
   164  OpenTofu will upload the contents of the following directory,
   165  excluding files or directories as defined by a .terraformignore file
   166  at %s/.terraformignore (if it is present),
   167  in order to capture the filesystem context the remote workspace expects:
   168      %s
   169  `), w.WorkingDirectory, configDir, configDir) + "\n")
   170  			}
   171  		}
   172  
   173  	} else {
   174  		// We did a check earlier to make sure we either have a config dir,
   175  		// or the plan is run with -destroy. So this else clause will only
   176  		// be executed when we are destroying and doesn't need the config.
   177  		configDir, err = os.MkdirTemp("", "tf")
   178  		if err != nil {
   179  			return nil, generalError("Failed to create temporary directory", err)
   180  		}
   181  		defer os.RemoveAll(configDir)
   182  
   183  		// Make sure the configured working directory exists.
   184  		err = os.MkdirAll(filepath.Join(configDir, w.WorkingDirectory), 0700)
   185  		if err != nil {
   186  			return nil, generalError(
   187  				"Failed to create temporary working directory", err)
   188  		}
   189  	}
   190  
   191  	err = b.client.ConfigurationVersions.Upload(stopCtx, cv.UploadURL, configDir)
   192  	if err != nil {
   193  		return nil, generalError("Failed to upload configuration files", err)
   194  	}
   195  
   196  	uploaded := false
   197  	for i := 0; i < 60 && !uploaded; i++ {
   198  		select {
   199  		case <-stopCtx.Done():
   200  			return nil, context.Canceled
   201  		case <-cancelCtx.Done():
   202  			return nil, context.Canceled
   203  		case <-time.After(planConfigurationVersionsPollInterval):
   204  			cv, err = b.client.ConfigurationVersions.Read(stopCtx, cv.ID)
   205  			if err != nil {
   206  				return nil, generalError("Failed to retrieve configuration version", err)
   207  			}
   208  
   209  			if cv.Status == tfe.ConfigurationUploaded {
   210  				uploaded = true
   211  			}
   212  		}
   213  	}
   214  
   215  	if !uploaded {
   216  		return nil, generalError(
   217  			"Failed to upload configuration files", errors.New("operation timed out"))
   218  	}
   219  
   220  	runOptions := tfe.RunCreateOptions{
   221  		ConfigurationVersion: cv,
   222  		Refresh:              tfe.Bool(op.PlanRefresh),
   223  		Workspace:            w,
   224  		AutoApply:            tfe.Bool(op.AutoApprove),
   225  		SavePlan:             tfe.Bool(op.PlanOutPath != ""),
   226  	}
   227  
   228  	switch op.PlanMode {
   229  	case plans.NormalMode:
   230  		// okay, but we don't need to do anything special for this
   231  	case plans.RefreshOnlyMode:
   232  		runOptions.RefreshOnly = tfe.Bool(true)
   233  	case plans.DestroyMode:
   234  		runOptions.IsDestroy = tfe.Bool(true)
   235  	default:
   236  		// Shouldn't get here because we should update this for each new
   237  		// plan mode we add, mapping it to the corresponding RunCreateOptions
   238  		// field.
   239  		return nil, generalError(
   240  			"Invalid plan mode",
   241  			fmt.Errorf("Cloud backend doesn't support %s", op.PlanMode),
   242  		)
   243  	}
   244  
   245  	if len(op.Targets) != 0 {
   246  		runOptions.TargetAddrs = make([]string, 0, len(op.Targets))
   247  		for _, addr := range op.Targets {
   248  			runOptions.TargetAddrs = append(runOptions.TargetAddrs, addr.String())
   249  		}
   250  	}
   251  
   252  	if len(op.ForceReplace) != 0 {
   253  		runOptions.ReplaceAddrs = make([]string, 0, len(op.ForceReplace))
   254  		for _, addr := range op.ForceReplace {
   255  			runOptions.ReplaceAddrs = append(runOptions.ReplaceAddrs, addr.String())
   256  		}
   257  	}
   258  
   259  	config, _, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir)
   260  	if configDiags.HasErrors() {
   261  		return nil, fmt.Errorf("error loading config with snapshot: %w", configDiags.Errs()[0])
   262  	}
   263  
   264  	variables, varDiags := ParseCloudRunVariables(op.Variables, config.Module.Variables)
   265  
   266  	if varDiags.HasErrors() {
   267  		return nil, varDiags.Err()
   268  	}
   269  
   270  	runVariables := make([]*tfe.RunVariable, 0, len(variables))
   271  	for name, value := range variables {
   272  		runVariables = append(runVariables, &tfe.RunVariable{
   273  			Key:   name,
   274  			Value: value,
   275  		})
   276  	}
   277  	runOptions.Variables = runVariables
   278  
   279  	if len(op.GenerateConfigOut) > 0 {
   280  		runOptions.AllowConfigGeneration = tfe.Bool(true)
   281  	}
   282  
   283  	r, err := b.client.Runs.Create(stopCtx, runOptions)
   284  	if err != nil {
   285  		return r, generalError("Failed to create run", err)
   286  	}
   287  
   288  	// When the lock timeout is set, if the run is still pending and
   289  	// cancellable after that period, we attempt to cancel it.
   290  	if lockTimeout := op.StateLocker.Timeout(); lockTimeout > 0 {
   291  		go func() {
   292  			select {
   293  			case <-stopCtx.Done():
   294  				return
   295  			case <-cancelCtx.Done():
   296  				return
   297  			case <-time.After(lockTimeout):
   298  				// Retrieve the run to get its current status.
   299  				r, err := b.client.Runs.Read(cancelCtx, r.ID)
   300  				if err != nil {
   301  					log.Printf("[ERROR] error reading run: %v", err)
   302  					return
   303  				}
   304  
   305  				if r.Status == tfe.RunPending && r.Actions.IsCancelable {
   306  					if b.CLI != nil {
   307  						b.CLI.Output(b.Colorize().Color(strings.TrimSpace(lockTimeoutErr)))
   308  					}
   309  
   310  					// We abuse the auto aprove flag to indicate that we do not
   311  					// want to ask if the remote operation should be canceled.
   312  					op.AutoApprove = true
   313  
   314  					p, err := os.FindProcess(os.Getpid())
   315  					if err != nil {
   316  						log.Printf("[ERROR] error searching process ID: %v", err)
   317  						return
   318  					}
   319  					p.Signal(syscall.SIGINT)
   320  				}
   321  			}
   322  		}()
   323  	}
   324  
   325  	if b.CLI != nil {
   326  		b.CLI.Output(b.Colorize().Color(strings.TrimSpace(fmt.Sprintf(
   327  			runHeader, b.hostname, b.organization, op.Workspace, r.ID)) + "\n"))
   328  	}
   329  
   330  	// Render any warnings that were raised during run creation
   331  	if err := b.renderRunWarnings(stopCtx, b.client, r.ID); err != nil {
   332  		return r, err
   333  	}
   334  
   335  	// Retrieve the run to get task stages.
   336  	// Task Stages are calculated upfront so we only need to call this once for the run.
   337  	taskStages, err := b.runTaskStages(stopCtx, b.client, r.ID)
   338  	if err != nil {
   339  		return r, err
   340  	}
   341  
   342  	if stage, ok := taskStages[tfe.PrePlan]; ok {
   343  		if err := b.waitTaskStage(stopCtx, cancelCtx, op, r, stage.ID, "Pre-plan Tasks"); err != nil {
   344  			return r, err
   345  		}
   346  	}
   347  
   348  	r, err = b.waitForRun(stopCtx, cancelCtx, op, "plan", r, w)
   349  	if err != nil {
   350  		return r, err
   351  	}
   352  
   353  	err = b.renderPlanLogs(stopCtx, op, r)
   354  	if err != nil {
   355  		return r, err
   356  	}
   357  
   358  	// Retrieve the run to get its current status.
   359  	r, err = b.client.Runs.Read(stopCtx, r.ID)
   360  	if err != nil {
   361  		return r, generalError("Failed to retrieve run", err)
   362  	}
   363  
   364  	// If the run is canceled or errored, we still continue to the
   365  	// cost-estimation and policy check phases to ensure we render any
   366  	// results available. In the case of a hard-failed policy check, the
   367  	// status of the run will be "errored", but there is still policy
   368  	// information which should be shown.
   369  
   370  	if stage, ok := taskStages[tfe.PostPlan]; ok {
   371  		if err := b.waitTaskStage(stopCtx, cancelCtx, op, r, stage.ID, "Post-plan Tasks"); err != nil {
   372  			return r, err
   373  		}
   374  	}
   375  
   376  	// Show any cost estimation output.
   377  	if r.CostEstimate != nil {
   378  		err = b.costEstimate(stopCtx, cancelCtx, op, r)
   379  		if err != nil {
   380  			return r, err
   381  		}
   382  	}
   383  
   384  	// Check any configured sentinel policies.
   385  	if len(r.PolicyChecks) > 0 {
   386  		err = b.checkPolicy(stopCtx, cancelCtx, op, r)
   387  		if err != nil {
   388  			return r, err
   389  		}
   390  	}
   391  
   392  	return r, nil
   393  }
   394  
   395  // AssertImportCompatible errors if the user is attempting to use configuration-
   396  // driven import and the version of the agent or API is too low to support it.
   397  func (b *Cloud) AssertImportCompatible(config *configs.Config) error {
   398  	// Check TFC_RUN_ID is populated, indicating we are running in a remote TFC
   399  	// execution environment.
   400  	if len(config.Module.Import) > 0 && os.Getenv("TFC_RUN_ID") != "" {
   401  		// First, check the remote API version is high enough.
   402  		currentAPIVersion, err := version.NewVersion(b.client.RemoteAPIVersion())
   403  		if err != nil {
   404  			return fmt.Errorf("Error parsing remote API version. To proceed, please remove any import blocks from your config. Please report the following error to the OpenTofu team: %w", err)
   405  		}
   406  		desiredAPIVersion, _ := version.NewVersion("2.6")
   407  		if currentAPIVersion.LessThan(desiredAPIVersion) {
   408  			return fmt.Errorf("Import blocks are not supported in this version of the cloud backend. Please remove any import blocks from your config or upgrade the cloud backend.")
   409  		}
   410  
   411  		// Second, check the agent version is high enough.
   412  		agentEnv, isSet := os.LookupEnv("TFC_AGENT_VERSION")
   413  		if !isSet {
   414  			return fmt.Errorf("Error reading TFC agent version. To proceed, please remove any import blocks from your config. Please report the following error to the OpenTofu team: TFC_AGENT_VERSION not present.")
   415  		}
   416  		currentAgentVersion, err := version.NewVersion(agentEnv)
   417  		if err != nil {
   418  			return fmt.Errorf("Error parsing TFC agent version. To proceed, please remove any import blocks from your config. Please report the following error to the OpenTofu team: %w", err)
   419  		}
   420  		desiredAgentVersion, _ := version.NewVersion("1.10")
   421  		if currentAgentVersion.LessThan(desiredAgentVersion) {
   422  			return fmt.Errorf("Import blocks are not supported in this version of the cloud backend Agent. You are using agent version %s, but this feature requires version %s. Please remove any import blocks from your config or upgrade your agent.", currentAgentVersion, desiredAgentVersion)
   423  		}
   424  	}
   425  	return nil
   426  }
   427  
   428  // renderPlanLogs reads the streamed plan JSON logs and calls the JSON Plan renderer (jsonformat.RenderPlan) to
   429  // render the plan output. The plan output is fetched from the redacted output endpoint.
   430  func (b *Cloud) renderPlanLogs(ctx context.Context, op *backend.Operation, run *tfe.Run) error {
   431  	logs, err := b.client.Plans.Logs(ctx, run.Plan.ID)
   432  	if err != nil {
   433  		return err
   434  	}
   435  
   436  	if b.CLI != nil {
   437  		reader := bufio.NewReaderSize(logs, 64*1024)
   438  
   439  		for next := true; next; {
   440  			var l, line []byte
   441  			var err error
   442  
   443  			for isPrefix := true; isPrefix; {
   444  				l, isPrefix, err = reader.ReadLine()
   445  				if err != nil {
   446  					if err != io.EOF {
   447  						return generalError("Failed to read logs", err)
   448  					}
   449  					next = false
   450  				}
   451  
   452  				line = append(line, l...)
   453  			}
   454  
   455  			if next || len(line) > 0 {
   456  				log := &jsonformat.JSONLog{}
   457  				if err := json.Unmarshal(line, log); err != nil {
   458  					// If we can not parse the line as JSON, we will simply
   459  					// print the line. This maintains backwards compatibility for
   460  					// users who do not wish to enable structured output in their
   461  					// workspace.
   462  					b.CLI.Output(string(line))
   463  					continue
   464  				}
   465  
   466  				// We will ignore plan output, change summary or outputs logs
   467  				// during the plan phase.
   468  				if log.Type == jsonformat.LogOutputs ||
   469  					log.Type == jsonformat.LogChangeSummary ||
   470  					log.Type == jsonformat.LogPlannedChange {
   471  					continue
   472  				}
   473  
   474  				if b.renderer != nil {
   475  					// Otherwise, we will print the log
   476  					err := b.renderer.RenderLog(log)
   477  					if err != nil {
   478  						return err
   479  					}
   480  				}
   481  			}
   482  		}
   483  	}
   484  
   485  	// Get the run's current status and include the workspace and plan. We will check if
   486  	// the run has errored, if structured output is enabled, and if the plan
   487  	run, err = b.client.Runs.ReadWithOptions(ctx, run.ID, &tfe.RunReadOptions{
   488  		Include: []tfe.RunIncludeOpt{tfe.RunWorkspace, tfe.RunPlan},
   489  	})
   490  	if err != nil {
   491  		return err
   492  	}
   493  
   494  	// If the run was errored, canceled, or discarded we will not resume the rest
   495  	// of this logic and attempt to render the plan, except in certain special circumstances
   496  	// where the plan errored but successfully generated configuration during an
   497  	// import operation. In that case, we need to keep going so we can load the JSON plan
   498  	// and use it to write the generated config to the specified output file.
   499  	shouldGenerateConfig := shouldGenerateConfig(op.GenerateConfigOut, run)
   500  	shouldRenderPlan := shouldRenderPlan(run)
   501  	if !shouldRenderPlan && !shouldGenerateConfig {
   502  		// We won't return an error here since we need to resume the logic that
   503  		// follows after rendering the logs (run tasks, cost estimation, etc.)
   504  		return nil
   505  	}
   506  
   507  	// Fetch the redacted JSON plan if we need it for either rendering the plan
   508  	// or writing out generated configuration.
   509  	var redactedPlan *jsonformat.Plan
   510  	renderSRO, err := b.shouldRenderStructuredRunOutput(run)
   511  	if err != nil {
   512  		return err
   513  	}
   514  	if renderSRO || shouldGenerateConfig {
   515  		jsonBytes, err := readRedactedPlan(ctx, b.client.BaseURL(), b.token, run.Plan.ID)
   516  		if err != nil {
   517  			return generalError("Failed to read JSON plan", err)
   518  		}
   519  		redactedPlan, err = decodeRedactedPlan(jsonBytes)
   520  		if err != nil {
   521  			return generalError("Failed to decode JSON plan", err)
   522  		}
   523  	}
   524  
   525  	// Write any generated config before rendering the plan, so we can stop in case of errors
   526  	if shouldGenerateConfig {
   527  		diags := maybeWriteGeneratedConfig(redactedPlan, op.GenerateConfigOut)
   528  		if diags.HasErrors() {
   529  			return diags.Err()
   530  		}
   531  	}
   532  
   533  	// Only generate the human readable output from the plan if structured run output is
   534  	// enabled. Otherwise we risk duplicate plan output since plan output may also be
   535  	// shown in the streamed logs.
   536  	if shouldRenderPlan && renderSRO {
   537  		b.renderer.RenderHumanPlan(*redactedPlan, op.PlanMode)
   538  	}
   539  
   540  	return nil
   541  }
   542  
   543  // maybeWriteGeneratedConfig attempts to write any generated configuration from the JSON plan
   544  // to the specified output file, if generated configuration exists and the correct flag was
   545  // passed to the plan command.
   546  func maybeWriteGeneratedConfig(plan *jsonformat.Plan, out string) (diags tfdiags.Diagnostics) {
   547  	if genconfig.ShouldWriteConfig(out) {
   548  		diags := genconfig.ValidateTargetFile(out)
   549  		if diags.HasErrors() {
   550  			return diags
   551  		}
   552  
   553  		var writer io.Writer
   554  		for _, c := range plan.ResourceChanges {
   555  			change := genconfig.Change{
   556  				Addr:            c.Address,
   557  				GeneratedConfig: c.Change.GeneratedConfig,
   558  			}
   559  			if c.Change.Importing != nil {
   560  				change.ImportID = c.Change.Importing.ID
   561  			}
   562  
   563  			var moreDiags tfdiags.Diagnostics
   564  			writer, _, moreDiags = change.MaybeWriteConfig(writer, out)
   565  			if moreDiags.HasErrors() {
   566  				return diags.Append(moreDiags)
   567  			}
   568  		}
   569  	}
   570  
   571  	return diags
   572  }
   573  
   574  // shouldRenderStructuredRunOutput ensures the remote workspace has structured
   575  // run output enabled and, if using Terraform Enterprise, ensures it is a release
   576  // that supports enabling SRO for CLI-driven runs. The plan output will have
   577  // already been rendered when the logs were read if this wasn't the case.
   578  func (b *Cloud) shouldRenderStructuredRunOutput(run *tfe.Run) (bool, error) {
   579  	if b.renderer == nil || !run.Workspace.StructuredRunOutputEnabled {
   580  		return false, nil
   581  	}
   582  
   583  	// If the cloud backend is configured against TFC, we only require that
   584  	// the workspace has structured run output enabled.
   585  	if b.client.IsCloud() && run.Workspace.StructuredRunOutputEnabled {
   586  		return true, nil
   587  	}
   588  
   589  	// If the cloud backend is configured against TFE, ensure the release version
   590  	// supports enabling SRO for CLI runs.
   591  	if b.client.IsEnterprise() {
   592  		tfeVersion := b.client.RemoteTFEVersion()
   593  		if tfeVersion != "" {
   594  			v := strings.Split(tfeVersion[1:], "-")
   595  			releaseDate, err := strconv.Atoi(v[0])
   596  			if err != nil {
   597  				return false, err
   598  			}
   599  
   600  			// Any release older than 202302-1 will not support enabling SRO for
   601  			// CLI-driven runs
   602  			if releaseDate < 202302 {
   603  				return false, nil
   604  			} else if run.Workspace.StructuredRunOutputEnabled {
   605  				return true, nil
   606  			}
   607  		}
   608  	}
   609  
   610  	// Version of TFE is unknowable
   611  	return false, nil
   612  }
   613  
   614  func shouldRenderPlan(run *tfe.Run) bool {
   615  	return !(run.Status == tfe.RunErrored || run.Status == tfe.RunCanceled ||
   616  		run.Status == tfe.RunDiscarded)
   617  }
   618  
   619  func shouldGenerateConfig(out string, run *tfe.Run) bool {
   620  	return (run.Plan.Status == tfe.PlanErrored || run.Plan.Status == tfe.PlanFinished) &&
   621  		run.Plan.GeneratedConfiguration && len(out) > 0
   622  }
   623  
   624  const planDefaultHeader = `
   625  [reset][yellow]Running plan in cloud backend. Output will stream here. Pressing Ctrl-C
   626  will stop streaming the logs, but will not stop the plan running remotely.[reset]
   627  
   628  Preparing the remote plan...
   629  `
   630  
   631  const runHeader = `
   632  [reset][yellow]To view this run in a browser, visit:
   633  https://%s/app/%s/%s/runs/%s[reset]
   634  `
   635  
   636  // The newline in this error is to make it look good in the CLI!
   637  const lockTimeoutErr = `
   638  [reset][red]Lock timeout exceeded, sending interrupt to cancel the remote operation.
   639  [reset]
   640  `