github.com/rstandt/terraform@v0.12.32-0.20230710220336-b1063613405c/backend/remote/backend_plan.go (about)

     1  package remote
     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  	version "github.com/hashicorp/go-version"
    19  	"github.com/hashicorp/terraform/backend"
    20  	"github.com/hashicorp/terraform/tfdiags"
    21  )
    22  
    23  func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) {
    24  	log.Printf("[INFO] backend/remote: starting Plan operation")
    25  
    26  	var diags tfdiags.Diagnostics
    27  
    28  	if !w.Permissions.CanQueueRun {
    29  		diags = diags.Append(tfdiags.Sourceless(
    30  			tfdiags.Error,
    31  			"Insufficient rights to generate a plan",
    32  			"The provided credentials have insufficient rights to generate a plan. In order "+
    33  				"to generate plans, at least plan permissions on the workspace are required.",
    34  		))
    35  		return nil, diags.Err()
    36  	}
    37  
    38  	if op.Parallelism != defaultParallelism {
    39  		diags = diags.Append(tfdiags.Sourceless(
    40  			tfdiags.Error,
    41  			"Custom parallelism values are currently not supported",
    42  			`The "remote" backend does not support setting a custom parallelism `+
    43  				`value at this time.`,
    44  		))
    45  	}
    46  
    47  	if op.PlanFile != nil {
    48  		diags = diags.Append(tfdiags.Sourceless(
    49  			tfdiags.Error,
    50  			"Displaying a saved plan is currently not supported",
    51  			`The "remote" backend currently requires configuration to be present and `+
    52  				`does not accept an existing saved plan as an argument at this time.`,
    53  		))
    54  	}
    55  
    56  	if op.PlanOutPath != "" {
    57  		diags = diags.Append(tfdiags.Sourceless(
    58  			tfdiags.Error,
    59  			"Saving a generated plan is currently not supported",
    60  			`The "remote" backend does not support saving the generated execution `+
    61  				`plan locally at this time.`,
    62  		))
    63  	}
    64  
    65  	if !op.PlanRefresh {
    66  		diags = diags.Append(tfdiags.Sourceless(
    67  			tfdiags.Error,
    68  			"Planning without refresh is currently not supported",
    69  			`Currently the "remote" backend will always do an in-memory refresh of `+
    70  				`the Terraform state prior to generating the plan.`,
    71  		))
    72  	}
    73  
    74  	if b.hasExplicitVariableValues(op) {
    75  		diags = diags.Append(tfdiags.Sourceless(
    76  			tfdiags.Error,
    77  			"Run variables are currently not supported",
    78  			fmt.Sprintf(
    79  				"The \"remote\" backend does not support setting run variables at this time. "+
    80  					"Currently the only to way to pass variables to the remote backend is by "+
    81  					"creating a '*.auto.tfvars' variables file. This file will automatically "+
    82  					"be loaded by the \"remote\" backend when the workspace is configured to use "+
    83  					"Terraform v0.10.0 or later.\n\nAdditionally you can also set variables on "+
    84  					"the workspace in the web UI:\nhttps://%s/app/%s/%s/variables",
    85  				b.hostname, b.organization, op.Workspace,
    86  			),
    87  		))
    88  	}
    89  
    90  	if !op.HasConfig() && !op.Destroy {
    91  		diags = diags.Append(tfdiags.Sourceless(
    92  			tfdiags.Error,
    93  			"No configuration files found",
    94  			`Plan requires configuration to be present. Planning without a configuration `+
    95  				`would mark everything for destruction, which is normally not what is desired. `+
    96  				`If you would like to destroy everything, please run plan with the "-destroy" `+
    97  				`flag or create a single empty configuration file. Otherwise, please create `+
    98  				`a Terraform configuration file in the path being executed and try again.`,
    99  		))
   100  	}
   101  
   102  	if len(op.Targets) != 0 {
   103  		// For API versions prior to 2.3, RemoteAPIVersion will return an empty string,
   104  		// so if there's an error when parsing the RemoteAPIVersion, it's handled as
   105  		// equivalent to an API version < 2.3.
   106  		currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion())
   107  		desiredAPIVersion, _ := version.NewVersion("2.3")
   108  
   109  		if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) {
   110  			diags = diags.Append(tfdiags.Sourceless(
   111  				tfdiags.Error,
   112  				"Resource targeting is not supported",
   113  				fmt.Sprintf(
   114  					`The host %s does not support the -target option for `+
   115  						`remote plans.`,
   116  					b.hostname,
   117  				),
   118  			))
   119  		}
   120  	}
   121  
   122  	// Return if there are any errors.
   123  	if diags.HasErrors() {
   124  		return nil, diags.Err()
   125  	}
   126  
   127  	return b.plan(stopCtx, cancelCtx, op, w)
   128  }
   129  
   130  func (b *Remote) plan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) {
   131  	if b.CLI != nil {
   132  		header := planDefaultHeader
   133  		if op.Type == backend.OperationTypeApply {
   134  			header = applyDefaultHeader
   135  		}
   136  		b.CLI.Output(b.Colorize().Color(strings.TrimSpace(header) + "\n"))
   137  	}
   138  
   139  	configOptions := tfe.ConfigurationVersionCreateOptions{
   140  		AutoQueueRuns: tfe.Bool(false),
   141  		Speculative:   tfe.Bool(op.Type == backend.OperationTypePlan),
   142  	}
   143  
   144  	cv, err := b.client.ConfigurationVersions.Create(stopCtx, w.ID, configOptions)
   145  	if err != nil {
   146  		return nil, generalError("Failed to create configuration version", err)
   147  	}
   148  
   149  	var configDir string
   150  	if op.ConfigDir != "" {
   151  		// De-normalize the configuration directory path.
   152  		configDir, err = filepath.Abs(op.ConfigDir)
   153  		if err != nil {
   154  			return nil, generalError(
   155  				"Failed to get absolute path of the configuration directory: %v", err)
   156  		}
   157  
   158  		// Make sure to take the working directory into account by removing
   159  		// the working directory from the current path. This will result in
   160  		// a path that points to the expected root of the workspace.
   161  		configDir = filepath.Clean(strings.TrimSuffix(
   162  			filepath.Clean(configDir),
   163  			filepath.Clean(w.WorkingDirectory),
   164  		))
   165  
   166  		// If the workspace has a subdirectory as its working directory then
   167  		// our configDir will be some parent directory of the current working
   168  		// directory. Users are likely to find that surprising, so we'll
   169  		// produce an explicit message about it to be transparent about what
   170  		// we are doing and why.
   171  		if w.WorkingDirectory != "" && filepath.Base(configDir) != w.WorkingDirectory {
   172  			if b.CLI != nil {
   173  				b.CLI.Output(fmt.Sprintf(strings.TrimSpace(`
   174  The remote workspace is configured to work with configuration at
   175  %s relative to the target repository.
   176  
   177  Terraform will upload the contents of the following directory,
   178  excluding files or directories as defined by a .terraformignore file
   179  at %s/.terraformignore (if it is present),
   180  in order to capture the filesystem context the remote workspace expects:
   181      %s
   182  `), w.WorkingDirectory, configDir, configDir) + "\n")
   183  			}
   184  		}
   185  
   186  	} else {
   187  		// We did a check earlier to make sure we either have a config dir,
   188  		// or the plan is run with -destroy. So this else clause will only
   189  		// be executed when we are destroying and doesn't need the config.
   190  		configDir, err = ioutil.TempDir("", "tf")
   191  		if err != nil {
   192  			return nil, generalError("Failed to create temporary directory", err)
   193  		}
   194  		defer os.RemoveAll(configDir)
   195  
   196  		// Make sure the configured working directory exists.
   197  		err = os.MkdirAll(filepath.Join(configDir, w.WorkingDirectory), 0700)
   198  		if err != nil {
   199  			return nil, generalError(
   200  				"Failed to create temporary working directory", err)
   201  		}
   202  	}
   203  
   204  	err = b.client.ConfigurationVersions.Upload(stopCtx, cv.UploadURL, configDir)
   205  	if err != nil {
   206  		return nil, generalError("Failed to upload configuration files", err)
   207  	}
   208  
   209  	uploaded := false
   210  	for i := 0; i < 60 && !uploaded; i++ {
   211  		select {
   212  		case <-stopCtx.Done():
   213  			return nil, context.Canceled
   214  		case <-cancelCtx.Done():
   215  			return nil, context.Canceled
   216  		case <-time.After(500 * time.Millisecond):
   217  			cv, err = b.client.ConfigurationVersions.Read(stopCtx, cv.ID)
   218  			if err != nil {
   219  				return nil, generalError("Failed to retrieve configuration version", err)
   220  			}
   221  
   222  			if cv.Status == tfe.ConfigurationUploaded {
   223  				uploaded = true
   224  			}
   225  		}
   226  	}
   227  
   228  	if !uploaded {
   229  		return nil, generalError(
   230  			"Failed to upload configuration files", errors.New("operation timed out"))
   231  	}
   232  
   233  	queueMessage := "Queued manually using Terraform"
   234  	if op.Targets != nil {
   235  		queueMessage = "Queued manually via Terraform using -target"
   236  	}
   237  
   238  	runOptions := tfe.RunCreateOptions{
   239  		IsDestroy:            tfe.Bool(op.Destroy),
   240  		Message:              tfe.String(queueMessage),
   241  		ConfigurationVersion: cv,
   242  		Workspace:            w,
   243  	}
   244  
   245  	if len(op.Targets) != 0 {
   246  		runOptions.TargetAddrs = make([]string, 0, len(op.Targets))
   247  		for _, addr := range op.Targets {
   248  			// The API client wants the normal string representation of a
   249  			// target address, which will ultimately get inserted into a
   250  			// -target option when Terraform CLI is launched in the
   251  			// Cloud/Enterprise execution environment.
   252  			runOptions.TargetAddrs = append(runOptions.TargetAddrs, addr.String())
   253  		}
   254  	}
   255  
   256  	r, err := b.client.Runs.Create(stopCtx, runOptions)
   257  	if err != nil {
   258  		return r, generalError("Failed to create run", err)
   259  	}
   260  
   261  	// When the lock timeout is set,
   262  	if op.StateLockTimeout > 0 {
   263  		go func() {
   264  			select {
   265  			case <-stopCtx.Done():
   266  				return
   267  			case <-cancelCtx.Done():
   268  				return
   269  			case <-time.After(op.StateLockTimeout):
   270  				// Retrieve the run to get its current status.
   271  				r, err := b.client.Runs.Read(cancelCtx, r.ID)
   272  				if err != nil {
   273  					log.Printf("[ERROR] error reading run: %v", err)
   274  					return
   275  				}
   276  
   277  				if r.Status == tfe.RunPending && r.Actions.IsCancelable {
   278  					if b.CLI != nil {
   279  						b.CLI.Output(b.Colorize().Color(strings.TrimSpace(lockTimeoutErr)))
   280  					}
   281  
   282  					// We abuse the auto aprove flag to indicate that we do not
   283  					// want to ask if the remote operation should be canceled.
   284  					op.AutoApprove = true
   285  
   286  					p, err := os.FindProcess(os.Getpid())
   287  					if err != nil {
   288  						log.Printf("[ERROR] error searching process ID: %v", err)
   289  						return
   290  					}
   291  					p.Signal(syscall.SIGINT)
   292  				}
   293  			}
   294  		}()
   295  	}
   296  
   297  	if b.CLI != nil {
   298  		b.CLI.Output(b.Colorize().Color(strings.TrimSpace(fmt.Sprintf(
   299  			runHeader, b.hostname, b.organization, op.Workspace, r.ID)) + "\n"))
   300  	}
   301  
   302  	r, err = b.waitForRun(stopCtx, cancelCtx, op, "plan", r, w)
   303  	if err != nil {
   304  		return r, err
   305  	}
   306  
   307  	logs, err := b.client.Plans.Logs(stopCtx, r.Plan.ID)
   308  	if err != nil {
   309  		return r, generalError("Failed to retrieve logs", err)
   310  	}
   311  	reader := bufio.NewReaderSize(logs, 64*1024)
   312  
   313  	if b.CLI != nil {
   314  		for next := true; next; {
   315  			var l, line []byte
   316  
   317  			for isPrefix := true; isPrefix; {
   318  				l, isPrefix, err = reader.ReadLine()
   319  				if err != nil {
   320  					if err != io.EOF {
   321  						return r, generalError("Failed to read logs", err)
   322  					}
   323  					next = false
   324  				}
   325  				line = append(line, l...)
   326  			}
   327  
   328  			if next || len(line) > 0 {
   329  				b.CLI.Output(b.Colorize().Color(string(line)))
   330  			}
   331  		}
   332  	}
   333  
   334  	// Retrieve the run to get its current status.
   335  	r, err = b.client.Runs.Read(stopCtx, r.ID)
   336  	if err != nil {
   337  		return r, generalError("Failed to retrieve run", err)
   338  	}
   339  
   340  	// Return if the run is canceled or errored. We return without
   341  	// an error, even if the run errored, as the error is already
   342  	// displayed by the output of the remote run.
   343  	if r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored {
   344  		return r, nil
   345  	}
   346  
   347  	// Show any cost estimation output.
   348  	if r.CostEstimate != nil {
   349  		err = b.costEstimate(stopCtx, cancelCtx, op, r)
   350  		if err != nil {
   351  			return r, err
   352  		}
   353  	}
   354  
   355  	// Check any configured sentinel policies.
   356  	if len(r.PolicyChecks) > 0 {
   357  		err = b.checkPolicy(stopCtx, cancelCtx, op, r)
   358  		if err != nil {
   359  			return r, err
   360  		}
   361  	}
   362  
   363  	return r, nil
   364  }
   365  
   366  const planDefaultHeader = `
   367  [reset][yellow]Running plan in the remote backend. Output will stream here. Pressing Ctrl-C
   368  will stop streaming the logs, but will not stop the plan running remotely.[reset]
   369  
   370  Preparing the remote plan...
   371  `
   372  
   373  const runHeader = `
   374  [reset][yellow]To view this run in a browser, visit:
   375  https://%s/app/%s/%s/runs/%s[reset]
   376  `
   377  
   378  // The newline in this error is to make it look good in the CLI!
   379  const lockTimeoutErr = `
   380  [reset][red]Lock timeout exceeded, sending interrupt to cancel the remote operation.
   381  [reset]
   382  `