github.com/cycloidio/terraform@v1.1.10-0.20220513142504-76d5c768dc63/cloud/backend_plan.go (about)

     1  package cloud
     2  
     3  import (
     4  	"bufio"
     5  	"context"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"io/ioutil"
    10  	"log"
    11  	"os"
    12  	"path/filepath"
    13  	"strings"
    14  	"syscall"
    15  	"time"
    16  
    17  	tfe "github.com/hashicorp/go-tfe"
    18  	"github.com/cycloidio/terraform/backend"
    19  	"github.com/cycloidio/terraform/plans"
    20  	"github.com/cycloidio/terraform/tfdiags"
    21  )
    22  
    23  var planConfigurationVersionsPollInterval = 500 * time.Millisecond
    24  
    25  func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) {
    26  	log.Printf("[INFO] cloud: starting Plan operation")
    27  
    28  	var diags tfdiags.Diagnostics
    29  
    30  	if !w.Permissions.CanQueueRun {
    31  		diags = diags.Append(tfdiags.Sourceless(
    32  			tfdiags.Error,
    33  			"Insufficient rights to generate a plan",
    34  			"The provided credentials have insufficient rights to generate a plan. In order "+
    35  				"to generate plans, at least plan permissions on the workspace are required.",
    36  		))
    37  		return nil, diags.Err()
    38  	}
    39  
    40  	if b.ContextOpts != nil && b.ContextOpts.Parallelism != defaultParallelism {
    41  		diags = diags.Append(tfdiags.Sourceless(
    42  			tfdiags.Error,
    43  			"Custom parallelism values are currently not supported",
    44  			`Terraform Cloud does not support setting a custom parallelism `+
    45  				`value at this time.`,
    46  		))
    47  	}
    48  
    49  	if op.PlanFile != nil {
    50  		diags = diags.Append(tfdiags.Sourceless(
    51  			tfdiags.Error,
    52  			"Displaying a saved plan is currently not supported",
    53  			`Terraform Cloud currently requires configuration to be present and `+
    54  				`does not accept an existing saved plan as an argument at this time.`,
    55  		))
    56  	}
    57  
    58  	if op.PlanOutPath != "" {
    59  		diags = diags.Append(tfdiags.Sourceless(
    60  			tfdiags.Error,
    61  			"Saving a generated plan is currently not supported",
    62  			`Terraform Cloud does not support saving the generated execution `+
    63  				`plan locally at this time.`,
    64  		))
    65  	}
    66  
    67  	if !op.HasConfig() && op.PlanMode != plans.DestroyMode {
    68  		diags = diags.Append(tfdiags.Sourceless(
    69  			tfdiags.Error,
    70  			"No configuration files found",
    71  			`Plan requires configuration to be present. Planning without a configuration `+
    72  				`would mark everything for destruction, which is normally not what is desired. `+
    73  				`If you would like to destroy everything, please run plan with the "-destroy" `+
    74  				`flag or create a single empty configuration file. Otherwise, please create `+
    75  				`a Terraform configuration file in the path being executed and try again.`,
    76  		))
    77  	}
    78  
    79  	// Return if there are any errors.
    80  	if diags.HasErrors() {
    81  		return nil, diags.Err()
    82  	}
    83  
    84  	return b.plan(stopCtx, cancelCtx, op, w)
    85  }
    86  
    87  func (b *Cloud) plan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) {
    88  	if b.CLI != nil {
    89  		header := planDefaultHeader
    90  		if op.Type == backend.OperationTypeApply || op.Type == backend.OperationTypeRefresh {
    91  			header = applyDefaultHeader
    92  		}
    93  		b.CLI.Output(b.Colorize().Color(strings.TrimSpace(header) + "\n"))
    94  	}
    95  
    96  	configOptions := tfe.ConfigurationVersionCreateOptions{
    97  		AutoQueueRuns: tfe.Bool(false),
    98  		Speculative:   tfe.Bool(op.Type == backend.OperationTypePlan),
    99  	}
   100  
   101  	cv, err := b.client.ConfigurationVersions.Create(stopCtx, w.ID, configOptions)
   102  	if err != nil {
   103  		return nil, generalError("Failed to create configuration version", err)
   104  	}
   105  
   106  	var configDir string
   107  	if op.ConfigDir != "" {
   108  		// De-normalize the configuration directory path.
   109  		configDir, err = filepath.Abs(op.ConfigDir)
   110  		if err != nil {
   111  			return nil, generalError(
   112  				"Failed to get absolute path of the configuration directory: %v", err)
   113  		}
   114  
   115  		// Make sure to take the working directory into account by removing
   116  		// the working directory from the current path. This will result in
   117  		// a path that points to the expected root of the workspace.
   118  		configDir = filepath.Clean(strings.TrimSuffix(
   119  			filepath.Clean(configDir),
   120  			filepath.Clean(w.WorkingDirectory),
   121  		))
   122  
   123  		// If the workspace has a subdirectory as its working directory then
   124  		// our configDir will be some parent directory of the current working
   125  		// directory. Users are likely to find that surprising, so we'll
   126  		// produce an explicit message about it to be transparent about what
   127  		// we are doing and why.
   128  		if w.WorkingDirectory != "" && filepath.Base(configDir) != w.WorkingDirectory {
   129  			if b.CLI != nil {
   130  				b.CLI.Output(fmt.Sprintf(strings.TrimSpace(`
   131  The remote workspace is configured to work with configuration at
   132  %s relative to the target repository.
   133  
   134  Terraform will upload the contents of the following directory,
   135  excluding files or directories as defined by a .terraformignore file
   136  at %s/.terraformignore (if it is present),
   137  in order to capture the filesystem context the remote workspace expects:
   138      %s
   139  `), w.WorkingDirectory, configDir, configDir) + "\n")
   140  			}
   141  		}
   142  
   143  	} else {
   144  		// We did a check earlier to make sure we either have a config dir,
   145  		// or the plan is run with -destroy. So this else clause will only
   146  		// be executed when we are destroying and doesn't need the config.
   147  		configDir, err = ioutil.TempDir("", "tf")
   148  		if err != nil {
   149  			return nil, generalError("Failed to create temporary directory", err)
   150  		}
   151  		defer os.RemoveAll(configDir)
   152  
   153  		// Make sure the configured working directory exists.
   154  		err = os.MkdirAll(filepath.Join(configDir, w.WorkingDirectory), 0700)
   155  		if err != nil {
   156  			return nil, generalError(
   157  				"Failed to create temporary working directory", err)
   158  		}
   159  	}
   160  
   161  	err = b.client.ConfigurationVersions.Upload(stopCtx, cv.UploadURL, configDir)
   162  	if err != nil {
   163  		return nil, generalError("Failed to upload configuration files", err)
   164  	}
   165  
   166  	uploaded := false
   167  	for i := 0; i < 60 && !uploaded; i++ {
   168  		select {
   169  		case <-stopCtx.Done():
   170  			return nil, context.Canceled
   171  		case <-cancelCtx.Done():
   172  			return nil, context.Canceled
   173  		case <-time.After(planConfigurationVersionsPollInterval):
   174  			cv, err = b.client.ConfigurationVersions.Read(stopCtx, cv.ID)
   175  			if err != nil {
   176  				return nil, generalError("Failed to retrieve configuration version", err)
   177  			}
   178  
   179  			if cv.Status == tfe.ConfigurationUploaded {
   180  				uploaded = true
   181  			}
   182  		}
   183  	}
   184  
   185  	if !uploaded {
   186  		return nil, generalError(
   187  			"Failed to upload configuration files", errors.New("operation timed out"))
   188  	}
   189  
   190  	runOptions := tfe.RunCreateOptions{
   191  		ConfigurationVersion: cv,
   192  		Refresh:              tfe.Bool(op.PlanRefresh),
   193  		Workspace:            w,
   194  		AutoApply:            tfe.Bool(op.AutoApprove),
   195  	}
   196  
   197  	switch op.PlanMode {
   198  	case plans.NormalMode:
   199  		// okay, but we don't need to do anything special for this
   200  	case plans.RefreshOnlyMode:
   201  		runOptions.RefreshOnly = tfe.Bool(true)
   202  	case plans.DestroyMode:
   203  		runOptions.IsDestroy = tfe.Bool(true)
   204  	default:
   205  		// Shouldn't get here because we should update this for each new
   206  		// plan mode we add, mapping it to the corresponding RunCreateOptions
   207  		// field.
   208  		return nil, generalError(
   209  			"Invalid plan mode",
   210  			fmt.Errorf("Terraform Cloud doesn't support %s", op.PlanMode),
   211  		)
   212  	}
   213  
   214  	if len(op.Targets) != 0 {
   215  		runOptions.TargetAddrs = make([]string, 0, len(op.Targets))
   216  		for _, addr := range op.Targets {
   217  			runOptions.TargetAddrs = append(runOptions.TargetAddrs, addr.String())
   218  		}
   219  	}
   220  
   221  	if len(op.ForceReplace) != 0 {
   222  		runOptions.ReplaceAddrs = make([]string, 0, len(op.ForceReplace))
   223  		for _, addr := range op.ForceReplace {
   224  			runOptions.ReplaceAddrs = append(runOptions.ReplaceAddrs, addr.String())
   225  		}
   226  	}
   227  
   228  	config, _, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir)
   229  	if configDiags.HasErrors() {
   230  		return nil, fmt.Errorf("error loading config with snapshot: %w", configDiags.Errs()[0])
   231  	}
   232  	variables, varDiags := ParseCloudRunVariables(op.Variables, config.Module.Variables)
   233  
   234  	if varDiags.HasErrors() {
   235  		return nil, varDiags.Err()
   236  	}
   237  
   238  	runVariables := make([]*tfe.RunVariable, len(variables))
   239  	for name, value := range variables {
   240  		runVariables = append(runVariables, &tfe.RunVariable{
   241  			Key:   name,
   242  			Value: value,
   243  		})
   244  	}
   245  	runOptions.Variables = runVariables
   246  
   247  	r, err := b.client.Runs.Create(stopCtx, runOptions)
   248  	if err != nil {
   249  		return r, generalError("Failed to create run", err)
   250  	}
   251  
   252  	// When the lock timeout is set, if the run is still pending and
   253  	// cancellable after that period, we attempt to cancel it.
   254  	if lockTimeout := op.StateLocker.Timeout(); lockTimeout > 0 {
   255  		go func() {
   256  			select {
   257  			case <-stopCtx.Done():
   258  				return
   259  			case <-cancelCtx.Done():
   260  				return
   261  			case <-time.After(lockTimeout):
   262  				// Retrieve the run to get its current status.
   263  				r, err := b.client.Runs.Read(cancelCtx, r.ID)
   264  				if err != nil {
   265  					log.Printf("[ERROR] error reading run: %v", err)
   266  					return
   267  				}
   268  
   269  				if r.Status == tfe.RunPending && r.Actions.IsCancelable {
   270  					if b.CLI != nil {
   271  						b.CLI.Output(b.Colorize().Color(strings.TrimSpace(lockTimeoutErr)))
   272  					}
   273  
   274  					// We abuse the auto aprove flag to indicate that we do not
   275  					// want to ask if the remote operation should be canceled.
   276  					op.AutoApprove = true
   277  
   278  					p, err := os.FindProcess(os.Getpid())
   279  					if err != nil {
   280  						log.Printf("[ERROR] error searching process ID: %v", err)
   281  						return
   282  					}
   283  					p.Signal(syscall.SIGINT)
   284  				}
   285  			}
   286  		}()
   287  	}
   288  
   289  	if b.CLI != nil {
   290  		b.CLI.Output(b.Colorize().Color(strings.TrimSpace(fmt.Sprintf(
   291  			runHeader, b.hostname, b.organization, op.Workspace, r.ID)) + "\n"))
   292  	}
   293  
   294  	r, err = b.waitForRun(stopCtx, cancelCtx, op, "plan", r, w)
   295  	if err != nil {
   296  		return r, err
   297  	}
   298  
   299  	logs, err := b.client.Plans.Logs(stopCtx, r.Plan.ID)
   300  	if err != nil {
   301  		return r, generalError("Failed to retrieve logs", err)
   302  	}
   303  	reader := bufio.NewReaderSize(logs, 64*1024)
   304  
   305  	if b.CLI != nil {
   306  		for next := true; next; {
   307  			var l, line []byte
   308  
   309  			for isPrefix := true; isPrefix; {
   310  				l, isPrefix, err = reader.ReadLine()
   311  				if err != nil {
   312  					if err != io.EOF {
   313  						return r, generalError("Failed to read logs", err)
   314  					}
   315  					next = false
   316  				}
   317  				line = append(line, l...)
   318  			}
   319  
   320  			if next || len(line) > 0 {
   321  				b.CLI.Output(b.Colorize().Color(string(line)))
   322  			}
   323  		}
   324  	}
   325  
   326  	// Retrieve the run to get its current status.
   327  	runID := r.ID
   328  	r, err = b.client.Runs.ReadWithOptions(stopCtx, runID, &tfe.RunReadOptions{
   329  		Include: "task_stages",
   330  	})
   331  	if err != nil {
   332  		// This error would be expected for older versions of TFE that do not allow
   333  		// fetching task_stages.
   334  		if strings.HasSuffix(err.Error(), "Invalid include parameter") {
   335  			r, err = b.client.Runs.Read(stopCtx, runID)
   336  		}
   337  
   338  		if err != nil {
   339  			return r, generalError("Failed to retrieve run", err)
   340  		}
   341  	}
   342  
   343  	// If the run is canceled or errored, we still continue to the
   344  	// cost-estimation and policy check phases to ensure we render any
   345  	// results available. In the case of a hard-failed policy check, the
   346  	// status of the run will be "errored", but there is still policy
   347  	// information which should be shown.
   348  
   349  	// Await post-plan run tasks
   350  	integration := &IntegrationContext{
   351  		B:             b,
   352  		StopContext:   stopCtx,
   353  		CancelContext: cancelCtx,
   354  		Op:            op,
   355  		Run:           r,
   356  	}
   357  
   358  	if stageID := getTaskStageIDByName(r.TaskStages, tfe.PostPlan); stageID != nil {
   359  		err = b.runTasks(integration, integration.BeginOutput("Run Tasks (post-plan)"), *stageID)
   360  		if err != nil {
   361  			return r, err
   362  		}
   363  	}
   364  
   365  	// Show any cost estimation output.
   366  	if r.CostEstimate != nil {
   367  		err = b.costEstimate(stopCtx, cancelCtx, op, r)
   368  		if err != nil {
   369  			return r, err
   370  		}
   371  	}
   372  
   373  	// Check any configured sentinel policies.
   374  	if len(r.PolicyChecks) > 0 {
   375  		err = b.checkPolicy(stopCtx, cancelCtx, op, r)
   376  		if err != nil {
   377  			return r, err
   378  		}
   379  	}
   380  
   381  	return r, nil
   382  }
   383  
   384  func getTaskStageIDByName(stages []*tfe.TaskStage, stageName tfe.Stage) *string {
   385  	if len(stages) == 0 {
   386  		return nil
   387  	}
   388  
   389  	for _, stage := range stages {
   390  		if stage.Stage == stageName {
   391  			return &stage.ID
   392  		}
   393  	}
   394  	return nil
   395  }
   396  
   397  const planDefaultHeader = `
   398  [reset][yellow]Running plan in Terraform Cloud. Output will stream here. Pressing Ctrl-C
   399  will stop streaming the logs, but will not stop the plan running remotely.[reset]
   400  
   401  Preparing the remote plan...
   402  `
   403  
   404  const runHeader = `
   405  [reset][yellow]To view this run in a browser, visit:
   406  https://%s/app/%s/%s/runs/%s[reset]
   407  `
   408  
   409  // The newline in this error is to make it look good in the CLI!
   410  const lockTimeoutErr = `
   411  [reset][red]Lock timeout exceeded, sending interrupt to cancel the remote operation.
   412  [reset]
   413  `