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