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