github.com/hugorut/terraform@v1.1.3/src/cloud/backend.go (about)

     1  package cloud
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"log"
     7  	"net/http"
     8  	"net/url"
     9  	"os"
    10  	"sort"
    11  	"strings"
    12  	"sync"
    13  	"time"
    14  
    15  	tfe "github.com/hashicorp/go-tfe"
    16  	version "github.com/hashicorp/go-version"
    17  	svchost "github.com/hashicorp/terraform-svchost"
    18  	"github.com/hashicorp/terraform-svchost/disco"
    19  	"github.com/hugorut/terraform/src/backend"
    20  	"github.com/hugorut/terraform/src/configs/configschema"
    21  	"github.com/hugorut/terraform/src/plans"
    22  	"github.com/hugorut/terraform/src/states/remote"
    23  	"github.com/hugorut/terraform/src/states/statemgr"
    24  	"github.com/hugorut/terraform/src/terraform"
    25  	"github.com/hugorut/terraform/src/tfdiags"
    26  	tfversion "github.com/hugorut/terraform/version"
    27  	"github.com/mitchellh/cli"
    28  	"github.com/mitchellh/colorstring"
    29  	"github.com/zclconf/go-cty/cty"
    30  	"github.com/zclconf/go-cty/cty/gocty"
    31  
    32  	backendLocal "github.com/hugorut/terraform/src/backend/local"
    33  )
    34  
    35  const (
    36  	defaultHostname    = "app.terraform.io"
    37  	defaultParallelism = 10
    38  	tfeServiceID       = "tfe.v2"
    39  	headerSourceKey    = "X-Terraform-Integration"
    40  	headerSourceValue  = "cloud"
    41  )
    42  
    43  // Cloud is an implementation of EnhancedBackend in service of the Terraform Cloud/Enterprise
    44  // integration for Terraform CLI. This backend is not intended to be surfaced at the user level and
    45  // is instead an implementation detail of cloud.Cloud.
    46  type Cloud struct {
    47  	// CLI and Colorize control the CLI output. If CLI is nil then no CLI
    48  	// output will be done. If CLIColor is nil then no coloring will be done.
    49  	CLI      cli.Ui
    50  	CLIColor *colorstring.Colorize
    51  
    52  	// ContextOpts are the base context options to set when initializing a
    53  	// new Terraform context. Many of these will be overridden or merged by
    54  	// Operation. See Operation for more details.
    55  	ContextOpts *terraform.ContextOpts
    56  
    57  	// client is the Terraform Cloud/Enterprise API client.
    58  	client *tfe.Client
    59  
    60  	// lastRetry is set to the last time a request was retried.
    61  	lastRetry time.Time
    62  
    63  	// hostname of Terraform Cloud or Terraform Enterprise
    64  	hostname string
    65  
    66  	// organization is the organization that contains the target workspaces.
    67  	organization string
    68  
    69  	// WorkspaceMapping contains strategies for mapping CLI workspaces in the working directory
    70  	// to remote Terraform Cloud workspaces.
    71  	WorkspaceMapping WorkspaceMapping
    72  
    73  	// services is used for service discovery
    74  	services *disco.Disco
    75  
    76  	// local allows local operations, where Terraform Cloud serves as a state storage backend.
    77  	local backend.Enhanced
    78  
    79  	// forceLocal, if true, will force the use of the local backend.
    80  	forceLocal bool
    81  
    82  	// opLock locks operations
    83  	opLock sync.Mutex
    84  
    85  	// ignoreVersionConflict, if true, will disable the requirement that the
    86  	// local Terraform version matches the remote workspace's configured
    87  	// version. This will also cause VerifyWorkspaceTerraformVersion to return
    88  	// a warning diagnostic instead of an error.
    89  	ignoreVersionConflict bool
    90  
    91  	runningInAutomation bool
    92  }
    93  
    94  var _ backend.Backend = (*Cloud)(nil)
    95  var _ backend.Enhanced = (*Cloud)(nil)
    96  var _ backend.Local = (*Cloud)(nil)
    97  
    98  // New creates a new initialized cloud backend.
    99  func New(services *disco.Disco) *Cloud {
   100  	return &Cloud{
   101  		services: services,
   102  	}
   103  }
   104  
   105  // ConfigSchema implements backend.Enhanced.
   106  func (b *Cloud) ConfigSchema() *configschema.Block {
   107  	return &configschema.Block{
   108  		Attributes: map[string]*configschema.Attribute{
   109  			"hostname": {
   110  				Type:        cty.String,
   111  				Optional:    true,
   112  				Description: schemaDescriptionHostname,
   113  			},
   114  			"organization": {
   115  				Type:        cty.String,
   116  				Required:    true,
   117  				Description: schemaDescriptionOrganization,
   118  			},
   119  			"token": {
   120  				Type:        cty.String,
   121  				Optional:    true,
   122  				Description: schemaDescriptionToken,
   123  			},
   124  		},
   125  
   126  		BlockTypes: map[string]*configschema.NestedBlock{
   127  			"workspaces": {
   128  				Block: configschema.Block{
   129  					Attributes: map[string]*configschema.Attribute{
   130  						"name": {
   131  							Type:        cty.String,
   132  							Optional:    true,
   133  							Description: schemaDescriptionName,
   134  						},
   135  						"tags": {
   136  							Type:        cty.Set(cty.String),
   137  							Optional:    true,
   138  							Description: schemaDescriptionTags,
   139  						},
   140  					},
   141  				},
   142  				Nesting: configschema.NestingSingle,
   143  			},
   144  		},
   145  	}
   146  }
   147  
   148  // PrepareConfig implements backend.Backend.
   149  func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) {
   150  	var diags tfdiags.Diagnostics
   151  	if obj.IsNull() {
   152  		return obj, diags
   153  	}
   154  
   155  	if val := obj.GetAttr("organization"); val.IsNull() || val.AsString() == "" {
   156  		diags = diags.Append(invalidOrganizationConfigMissingValue)
   157  	}
   158  
   159  	WorkspaceMapping := WorkspaceMapping{}
   160  	if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() {
   161  		if val := workspaces.GetAttr("name"); !val.IsNull() {
   162  			WorkspaceMapping.Name = val.AsString()
   163  		}
   164  		if val := workspaces.GetAttr("tags"); !val.IsNull() {
   165  			err := gocty.FromCtyValue(val, &WorkspaceMapping.Tags)
   166  			if err != nil {
   167  				log.Panicf("An unxpected error occurred: %s", err)
   168  			}
   169  		}
   170  	}
   171  
   172  	switch WorkspaceMapping.Strategy() {
   173  	// Make sure have a workspace mapping strategy present
   174  	case WorkspaceNoneStrategy:
   175  		diags = diags.Append(invalidWorkspaceConfigMissingValues)
   176  	// Make sure that a workspace name is configured.
   177  	case WorkspaceInvalidStrategy:
   178  		diags = diags.Append(invalidWorkspaceConfigMisconfiguration)
   179  	}
   180  
   181  	return obj, diags
   182  }
   183  
   184  // Configure implements backend.Enhanced.
   185  func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics {
   186  	var diags tfdiags.Diagnostics
   187  	if obj.IsNull() {
   188  		return diags
   189  	}
   190  
   191  	diagErr := b.setConfigurationFields(obj)
   192  	if diagErr.HasErrors() {
   193  		return diagErr
   194  	}
   195  
   196  	// Discover the service URL to confirm that it provides the Terraform Cloud/Enterprise API
   197  	service, err := b.discover()
   198  
   199  	// Check for errors before we continue.
   200  	if err != nil {
   201  		diags = diags.Append(tfdiags.AttributeValue(
   202  			tfdiags.Error,
   203  			strings.ToUpper(err.Error()[:1])+err.Error()[1:],
   204  			"", // no description is needed here, the error is clear
   205  			cty.Path{cty.GetAttrStep{Name: "hostname"}},
   206  		))
   207  		return diags
   208  	}
   209  
   210  	// Retrieve the token for this host as configured in the credentials
   211  	// section of the CLI Config File.
   212  	token, err := b.token()
   213  	if err != nil {
   214  		diags = diags.Append(tfdiags.AttributeValue(
   215  			tfdiags.Error,
   216  			strings.ToUpper(err.Error()[:1])+err.Error()[1:],
   217  			"", // no description is needed here, the error is clear
   218  			cty.Path{cty.GetAttrStep{Name: "hostname"}},
   219  		))
   220  		return diags
   221  	}
   222  
   223  	// Get the token from the config if no token was configured for this
   224  	// host in credentials section of the CLI Config File.
   225  	if token == "" {
   226  		if val := obj.GetAttr("token"); !val.IsNull() {
   227  			token = val.AsString()
   228  		}
   229  	}
   230  
   231  	// Return an error if we still don't have a token at this point.
   232  	if token == "" {
   233  		loginCommand := "terraform login"
   234  		if b.hostname != defaultHostname {
   235  			loginCommand = loginCommand + " " + b.hostname
   236  		}
   237  		diags = diags.Append(tfdiags.Sourceless(
   238  			tfdiags.Error,
   239  			"Required token could not be found",
   240  			fmt.Sprintf(
   241  				"Run the following command to generate a token for %s:\n    %s",
   242  				b.hostname,
   243  				loginCommand,
   244  			),
   245  		))
   246  		return diags
   247  	}
   248  
   249  	cfg := &tfe.Config{
   250  		Address:      service.String(),
   251  		BasePath:     service.Path,
   252  		Token:        token,
   253  		Headers:      make(http.Header),
   254  		RetryLogHook: b.retryLogHook,
   255  	}
   256  
   257  	// Set the version header to the current version.
   258  	cfg.Headers.Set(tfversion.Header, tfversion.Version)
   259  	cfg.Headers.Set(headerSourceKey, headerSourceValue)
   260  
   261  	// Create the TFC/E API client.
   262  	b.client, err = tfe.NewClient(cfg)
   263  	if err != nil {
   264  		diags = diags.Append(tfdiags.Sourceless(
   265  			tfdiags.Error,
   266  			"Failed to create the Terraform Cloud/Enterprise client",
   267  			fmt.Sprintf(
   268  				`Encountered an unexpected error while creating the `+
   269  					`Terraform Cloud/Enterprise client: %s.`, err,
   270  			),
   271  		))
   272  		return diags
   273  	}
   274  
   275  	// Check if the organization exists by reading its entitlements.
   276  	entitlements, err := b.client.Organizations.Entitlements(context.Background(), b.organization)
   277  	if err != nil {
   278  		if err == tfe.ErrResourceNotFound {
   279  			err = fmt.Errorf("organization %q at host %s not found.\n\n"+
   280  				"Please ensure that the organization and hostname are correct "+
   281  				"and that your API token for %s is valid.",
   282  				b.organization, b.hostname, b.hostname)
   283  		}
   284  		diags = diags.Append(tfdiags.AttributeValue(
   285  			tfdiags.Error,
   286  			fmt.Sprintf("Failed to read organization %q at host %s", b.organization, b.hostname),
   287  			fmt.Sprintf("Encountered an unexpected error while reading the "+
   288  				"organization settings: %s", err),
   289  			cty.Path{cty.GetAttrStep{Name: "organization"}},
   290  		))
   291  		return diags
   292  	}
   293  
   294  	// Check for the minimum version of Terraform Enterprise required.
   295  	//
   296  	// For API versions prior to 2.3, RemoteAPIVersion will return an empty string,
   297  	// so if there's an error when parsing the RemoteAPIVersion, it's handled as
   298  	// equivalent to an API version < 2.3.
   299  	currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion())
   300  	desiredAPIVersion, _ := version.NewVersion("2.5")
   301  
   302  	if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) {
   303  		log.Printf("[TRACE] API version check failed; want: >= %s, got: %s", desiredAPIVersion.Original(), currentAPIVersion)
   304  		if b.runningInAutomation {
   305  			// It should never be possible for this Terraform process to be mistakenly
   306  			// used internally within an unsupported Terraform Enterprise install - but
   307  			// just in case it happens, give an actionable error.
   308  			diags = diags.Append(
   309  				tfdiags.Sourceless(
   310  					tfdiags.Error,
   311  					"Unsupported Terraform Enterprise version",
   312  					cloudIntegrationUsedInUnsupportedTFE,
   313  				),
   314  			)
   315  		} else {
   316  			diags = diags.Append(tfdiags.Sourceless(
   317  				tfdiags.Error,
   318  				"Unsupported Terraform Enterprise version",
   319  				`The 'cloud' option is not supported with this version of Terraform Enterprise.`,
   320  			),
   321  			)
   322  		}
   323  	}
   324  
   325  	// Configure a local backend for when we need to run operations locally.
   326  	b.local = backendLocal.NewWithBackend(b)
   327  	b.forceLocal = b.forceLocal || !entitlements.Operations
   328  
   329  	// Enable retries for server errors as the backend is now fully configured.
   330  	b.client.RetryServerErrors(true)
   331  
   332  	return diags
   333  }
   334  
   335  func (b *Cloud) setConfigurationFields(obj cty.Value) tfdiags.Diagnostics {
   336  	var diags tfdiags.Diagnostics
   337  
   338  	// Get the hostname.
   339  	if val := obj.GetAttr("hostname"); !val.IsNull() && val.AsString() != "" {
   340  		b.hostname = val.AsString()
   341  	} else {
   342  		b.hostname = defaultHostname
   343  	}
   344  
   345  	// Get the organization.
   346  	if val := obj.GetAttr("organization"); !val.IsNull() {
   347  		b.organization = val.AsString()
   348  	}
   349  
   350  	// Get the workspaces configuration block and retrieve the
   351  	// default workspace name.
   352  	if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() {
   353  
   354  		// PrepareConfig checks that you cannot set both of these.
   355  		if val := workspaces.GetAttr("name"); !val.IsNull() {
   356  			b.WorkspaceMapping.Name = val.AsString()
   357  		}
   358  		if val := workspaces.GetAttr("tags"); !val.IsNull() {
   359  			var tags []string
   360  			err := gocty.FromCtyValue(val, &tags)
   361  			if err != nil {
   362  				log.Panicf("An unxpected error occurred: %s", err)
   363  			}
   364  
   365  			b.WorkspaceMapping.Tags = tags
   366  		}
   367  	}
   368  
   369  	// Determine if we are forced to use the local backend.
   370  	b.forceLocal = os.Getenv("TF_FORCE_LOCAL_BACKEND") != ""
   371  
   372  	return diags
   373  }
   374  
   375  // discover the TFC/E API service URL and version constraints.
   376  func (b *Cloud) discover() (*url.URL, error) {
   377  	hostname, err := svchost.ForComparison(b.hostname)
   378  	if err != nil {
   379  		return nil, err
   380  	}
   381  
   382  	host, err := b.services.Discover(hostname)
   383  	if err != nil {
   384  		return nil, err
   385  	}
   386  
   387  	service, err := host.ServiceURL(tfeServiceID)
   388  	// Return the error, unless its a disco.ErrVersionNotSupported error.
   389  	if _, ok := err.(*disco.ErrVersionNotSupported); !ok && err != nil {
   390  		return nil, err
   391  	}
   392  
   393  	return service, err
   394  }
   395  
   396  // token returns the token for this host as configured in the credentials
   397  // section of the CLI Config File. If no token was configured, an empty
   398  // string will be returned instead.
   399  func (b *Cloud) token() (string, error) {
   400  	hostname, err := svchost.ForComparison(b.hostname)
   401  	if err != nil {
   402  		return "", err
   403  	}
   404  	creds, err := b.services.CredentialsForHost(hostname)
   405  	if err != nil {
   406  		log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", b.hostname, err)
   407  		return "", nil
   408  	}
   409  	if creds != nil {
   410  		return creds.Token(), nil
   411  	}
   412  	return "", nil
   413  }
   414  
   415  // retryLogHook is invoked each time a request is retried allowing the
   416  // backend to log any connection issues to prevent data loss.
   417  func (b *Cloud) retryLogHook(attemptNum int, resp *http.Response) {
   418  	if b.CLI != nil {
   419  		// Ignore the first retry to make sure any delayed output will
   420  		// be written to the console before we start logging retries.
   421  		//
   422  		// The retry logic in the TFE client will retry both rate limited
   423  		// requests and server errors, but in the cloud backend we only
   424  		// care about server errors so we ignore rate limit (429) errors.
   425  		if attemptNum == 0 || (resp != nil && resp.StatusCode == 429) {
   426  			// Reset the last retry time.
   427  			b.lastRetry = time.Now()
   428  			return
   429  		}
   430  
   431  		if attemptNum == 1 {
   432  			b.CLI.Output(b.Colorize().Color(strings.TrimSpace(initialRetryError)))
   433  		} else {
   434  			b.CLI.Output(b.Colorize().Color(strings.TrimSpace(
   435  				fmt.Sprintf(repeatedRetryError, time.Since(b.lastRetry).Round(time.Second)))))
   436  		}
   437  	}
   438  }
   439  
   440  // Workspaces implements backend.Enhanced, returning a filtered list of workspace names according to
   441  // the workspace mapping strategy configured.
   442  func (b *Cloud) Workspaces() ([]string, error) {
   443  	// Create a slice to contain all the names.
   444  	var names []string
   445  
   446  	// If configured for a single workspace, return that exact name only.  The StateMgr for this
   447  	// backend will automatically create the remote workspace if it does not yet exist.
   448  	if b.WorkspaceMapping.Strategy() == WorkspaceNameStrategy {
   449  		names = append(names, b.WorkspaceMapping.Name)
   450  		return names, nil
   451  	}
   452  
   453  	// Otherwise, multiple workspaces are being mapped. Query Terraform Cloud for all the remote
   454  	// workspaces by the provided mapping strategy.
   455  	options := tfe.WorkspaceListOptions{}
   456  	if b.WorkspaceMapping.Strategy() == WorkspaceTagsStrategy {
   457  		taglist := strings.Join(b.WorkspaceMapping.Tags, ",")
   458  		options.Tags = &taglist
   459  	}
   460  
   461  	for {
   462  		wl, err := b.client.Workspaces.List(context.Background(), b.organization, options)
   463  		if err != nil {
   464  			return nil, err
   465  		}
   466  
   467  		for _, w := range wl.Items {
   468  			names = append(names, w.Name)
   469  		}
   470  
   471  		// Exit the loop when we've seen all pages.
   472  		if wl.CurrentPage >= wl.TotalPages {
   473  			break
   474  		}
   475  
   476  		// Update the page number to get the next page.
   477  		options.PageNumber = wl.NextPage
   478  	}
   479  
   480  	// Sort the result so we have consistent output.
   481  	sort.StringSlice(names).Sort()
   482  
   483  	return names, nil
   484  }
   485  
   486  // DeleteWorkspace implements backend.Enhanced.
   487  func (b *Cloud) DeleteWorkspace(name string) error {
   488  	if name == backend.DefaultStateName {
   489  		return backend.ErrDefaultWorkspaceNotSupported
   490  	}
   491  
   492  	if b.WorkspaceMapping.Strategy() == WorkspaceNameStrategy {
   493  		return backend.ErrWorkspacesNotSupported
   494  	}
   495  
   496  	// Configure the remote workspace name.
   497  	client := &remoteClient{
   498  		client:       b.client,
   499  		organization: b.organization,
   500  		workspace: &tfe.Workspace{
   501  			Name: name,
   502  		},
   503  	}
   504  
   505  	return client.Delete()
   506  }
   507  
   508  // StateMgr implements backend.Enhanced.
   509  func (b *Cloud) StateMgr(name string) (statemgr.Full, error) {
   510  	var remoteTFVersion string
   511  
   512  	if name == backend.DefaultStateName {
   513  		return nil, backend.ErrDefaultWorkspaceNotSupported
   514  	}
   515  
   516  	if b.WorkspaceMapping.Strategy() == WorkspaceNameStrategy && name != b.WorkspaceMapping.Name {
   517  		return nil, backend.ErrWorkspacesNotSupported
   518  	}
   519  
   520  	workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, name)
   521  	if err != nil && err != tfe.ErrResourceNotFound {
   522  		return nil, fmt.Errorf("Failed to retrieve workspace %s: %v", name, err)
   523  	}
   524  	if workspace != nil {
   525  		remoteTFVersion = workspace.TerraformVersion
   526  	}
   527  
   528  	if err == tfe.ErrResourceNotFound {
   529  		// Create a workspace
   530  		options := tfe.WorkspaceCreateOptions{
   531  			Name: tfe.String(name),
   532  			Tags: b.WorkspaceMapping.tfeTags(),
   533  		}
   534  
   535  		log.Printf("[TRACE] cloud: Creating Terraform Cloud workspace %s/%s", b.organization, name)
   536  		workspace, err = b.client.Workspaces.Create(context.Background(), b.organization, options)
   537  		if err != nil {
   538  			return nil, fmt.Errorf("Error creating workspace %s: %v", name, err)
   539  		}
   540  
   541  		remoteTFVersion = workspace.TerraformVersion
   542  
   543  		// Attempt to set the new workspace to use this version of Terraform. This
   544  		// can fail if there's no enabled tool_version whose name matches our
   545  		// version string, but that's expected sometimes -- just warn and continue.
   546  		versionOptions := tfe.WorkspaceUpdateOptions{
   547  			TerraformVersion: tfe.String(tfversion.String()),
   548  		}
   549  		_, err := b.client.Workspaces.UpdateByID(context.Background(), workspace.ID, versionOptions)
   550  		if err == nil {
   551  			remoteTFVersion = tfversion.String()
   552  		} else {
   553  			// TODO: Ideally we could rely on the client to tell us what the actual
   554  			// problem was, but we currently can't get enough context from the error
   555  			// object to do a nicely formatted message, so we're just assuming the
   556  			// issue was that the version wasn't available since that's probably what
   557  			// happened.
   558  			log.Printf("[TRACE] cloud: Attempted to select version %s for TFC workspace; unavailable, so %s will be used instead.", tfversion.String(), workspace.TerraformVersion)
   559  			if b.CLI != nil {
   560  				versionUnavailable := fmt.Sprintf(unavailableTerraformVersion, tfversion.String(), workspace.TerraformVersion)
   561  				b.CLI.Output(b.Colorize().Color(versionUnavailable))
   562  			}
   563  		}
   564  	}
   565  
   566  	if b.workspaceTagsRequireUpdate(workspace, b.WorkspaceMapping) {
   567  		options := tfe.WorkspaceAddTagsOptions{
   568  			Tags: b.WorkspaceMapping.tfeTags(),
   569  		}
   570  		log.Printf("[TRACE] cloud: Adding tags for Terraform Cloud workspace %s/%s", b.organization, name)
   571  		err = b.client.Workspaces.AddTags(context.Background(), workspace.ID, options)
   572  		if err != nil {
   573  			return nil, fmt.Errorf("Error updating workspace %s: %v", name, err)
   574  		}
   575  	}
   576  
   577  	// This is a fallback error check. Most code paths should use other
   578  	// mechanisms to check the version, then set the ignoreVersionConflict
   579  	// field to true. This check is only in place to ensure that we don't
   580  	// accidentally upgrade state with a new code path, and the version check
   581  	// logic is coarser and simpler.
   582  	if !b.ignoreVersionConflict {
   583  		// Explicitly ignore the pseudo-version "latest" here, as it will cause
   584  		// plan and apply to always fail.
   585  		if remoteTFVersion != tfversion.String() && remoteTFVersion != "latest" {
   586  			return nil, fmt.Errorf("Remote workspace Terraform version %q does not match local Terraform version %q", remoteTFVersion, tfversion.String())
   587  		}
   588  	}
   589  
   590  	client := &remoteClient{
   591  		client:       b.client,
   592  		organization: b.organization,
   593  		workspace:    workspace,
   594  
   595  		// This is optionally set during Terraform Enterprise runs.
   596  		runID: os.Getenv("TFE_RUN_ID"),
   597  	}
   598  
   599  	return &remote.State{Client: client}, nil
   600  }
   601  
   602  // Operation implements backend.Enhanced.
   603  func (b *Cloud) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) {
   604  	name := op.Workspace
   605  
   606  	// Retrieve the workspace for this operation.
   607  	w, err := b.client.Workspaces.Read(ctx, b.organization, name)
   608  	if err != nil {
   609  		switch err {
   610  		case context.Canceled:
   611  			return nil, err
   612  		case tfe.ErrResourceNotFound:
   613  			return nil, fmt.Errorf(
   614  				"workspace %s not found\n\n"+
   615  					"For security, Terraform Cloud returns '404 Not Found' responses for resources\n"+
   616  					"for resources that a user doesn't have access to, in addition to resources that\n"+
   617  					"do not exist. If the resource does exist, please check the permissions of the provided token.",
   618  				name,
   619  			)
   620  		default:
   621  			return nil, fmt.Errorf(
   622  				"Terraform Cloud returned an unexpected error:\n\n%s",
   623  				err,
   624  			)
   625  		}
   626  	}
   627  
   628  	// Terraform remote version conflicts are not a concern for operations. We
   629  	// are in one of three states:
   630  	//
   631  	// - Running remotely, in which case the local version is irrelevant;
   632  	// - Workspace configured for local operations, in which case the remote
   633  	//   version is meaningless;
   634  	// - Forcing local operations, which should only happen in the Terraform Cloud worker, in
   635  	//   which case the Terraform versions by definition match.
   636  	b.IgnoreVersionConflict()
   637  
   638  	// Check if we need to use the local backend to run the operation.
   639  	if b.forceLocal || isLocalExecutionMode(w.ExecutionMode) {
   640  		// Record that we're forced to run operations locally to allow the
   641  		// command package UI to operate correctly
   642  		b.forceLocal = true
   643  		return b.local.Operation(ctx, op)
   644  	}
   645  
   646  	// Set the remote workspace name.
   647  	op.Workspace = w.Name
   648  
   649  	// Determine the function to call for our operation
   650  	var f func(context.Context, context.Context, *backend.Operation, *tfe.Workspace) (*tfe.Run, error)
   651  	switch op.Type {
   652  	case backend.OperationTypePlan:
   653  		f = b.opPlan
   654  	case backend.OperationTypeApply:
   655  		f = b.opApply
   656  	case backend.OperationTypeRefresh:
   657  		// The `terraform refresh` command has been deprecated in favor of `terraform apply -refresh-state`.
   658  		// Rather than respond with an error telling the user to run the other command we can just run
   659  		// that command instead. We will tell the user what we are doing, and then do it.
   660  		if b.CLI != nil {
   661  			b.CLI.Output(b.Colorize().Color(strings.TrimSpace(refreshToApplyRefresh) + "\n"))
   662  		}
   663  		op.PlanMode = plans.RefreshOnlyMode
   664  		op.PlanRefresh = true
   665  		op.AutoApprove = true
   666  		f = b.opApply
   667  	default:
   668  		return nil, fmt.Errorf(
   669  			"\n\nTerraform Cloud does not support the %q operation.", op.Type)
   670  	}
   671  
   672  	// Lock
   673  	b.opLock.Lock()
   674  
   675  	// Build our running operation
   676  	// the runninCtx is only used to block until the operation returns.
   677  	runningCtx, done := context.WithCancel(context.Background())
   678  	runningOp := &backend.RunningOperation{
   679  		Context:   runningCtx,
   680  		PlanEmpty: true,
   681  	}
   682  
   683  	// stopCtx wraps the context passed in, and is used to signal a graceful Stop.
   684  	stopCtx, stop := context.WithCancel(ctx)
   685  	runningOp.Stop = stop
   686  
   687  	// cancelCtx is used to cancel the operation immediately, usually
   688  	// indicating that the process is exiting.
   689  	cancelCtx, cancel := context.WithCancel(context.Background())
   690  	runningOp.Cancel = cancel
   691  
   692  	// Do it.
   693  	go func() {
   694  		defer done()
   695  		defer stop()
   696  		defer cancel()
   697  
   698  		defer b.opLock.Unlock()
   699  
   700  		r, opErr := f(stopCtx, cancelCtx, op, w)
   701  		if opErr != nil && opErr != context.Canceled {
   702  			var diags tfdiags.Diagnostics
   703  			diags = diags.Append(opErr)
   704  			op.ReportResult(runningOp, diags)
   705  			return
   706  		}
   707  
   708  		if r == nil && opErr == context.Canceled {
   709  			runningOp.Result = backend.OperationFailure
   710  			return
   711  		}
   712  
   713  		if r != nil {
   714  			// Retrieve the run to get its current status.
   715  			r, err := b.client.Runs.Read(cancelCtx, r.ID)
   716  			if err != nil {
   717  				var diags tfdiags.Diagnostics
   718  				diags = diags.Append(generalError("Failed to retrieve run", err))
   719  				op.ReportResult(runningOp, diags)
   720  				return
   721  			}
   722  
   723  			// Record if there are any changes.
   724  			runningOp.PlanEmpty = !r.HasChanges
   725  
   726  			if opErr == context.Canceled {
   727  				if err := b.cancel(cancelCtx, op, r); err != nil {
   728  					var diags tfdiags.Diagnostics
   729  					diags = diags.Append(generalError("Failed to retrieve run", err))
   730  					op.ReportResult(runningOp, diags)
   731  					return
   732  				}
   733  			}
   734  
   735  			if r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored {
   736  				runningOp.Result = backend.OperationFailure
   737  			}
   738  		}
   739  	}()
   740  
   741  	// Return the running operation.
   742  	return runningOp, nil
   743  }
   744  
   745  func (b *Cloud) cancel(cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error {
   746  	if r.Actions.IsCancelable {
   747  		// Only ask if the remote operation should be canceled
   748  		// if the auto approve flag is not set.
   749  		if !op.AutoApprove {
   750  			v, err := op.UIIn.Input(cancelCtx, &terraform.InputOpts{
   751  				Id:          "cancel",
   752  				Query:       "\nDo you want to cancel the remote operation?",
   753  				Description: "Only 'yes' will be accepted to cancel.",
   754  			})
   755  			if err != nil {
   756  				return generalError("Failed asking to cancel", err)
   757  			}
   758  			if v != "yes" {
   759  				if b.CLI != nil {
   760  					b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationNotCanceled)))
   761  				}
   762  				return nil
   763  			}
   764  		} else {
   765  			if b.CLI != nil {
   766  				// Insert a blank line to separate the ouputs.
   767  				b.CLI.Output("")
   768  			}
   769  		}
   770  
   771  		// Try to cancel the remote operation.
   772  		err := b.client.Runs.Cancel(cancelCtx, r.ID, tfe.RunCancelOptions{})
   773  		if err != nil {
   774  			return generalError("Failed to cancel run", err)
   775  		}
   776  		if b.CLI != nil {
   777  			b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationCanceled)))
   778  		}
   779  	}
   780  
   781  	return nil
   782  }
   783  
   784  // IgnoreVersionConflict allows commands to disable the fall-back check that
   785  // the local Terraform version matches the remote workspace's configured
   786  // Terraform version. This should be called by commands where this check is
   787  // unnecessary, such as those performing remote operations, or read-only
   788  // operations. It will also be called if the user uses a command-line flag to
   789  // override this check.
   790  func (b *Cloud) IgnoreVersionConflict() {
   791  	b.ignoreVersionConflict = true
   792  }
   793  
   794  // VerifyWorkspaceTerraformVersion compares the local Terraform version against
   795  // the workspace's configured Terraform version. If they are compatible, this
   796  // means that there are no state compatibility concerns, so it returns no
   797  // diagnostics.
   798  //
   799  // If the versions aren't compatible, it returns an error (or, if
   800  // b.ignoreVersionConflict is set, a warning).
   801  func (b *Cloud) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Diagnostics {
   802  	var diags tfdiags.Diagnostics
   803  
   804  	workspace, err := b.getRemoteWorkspace(context.Background(), workspaceName)
   805  	if err != nil {
   806  		// If the workspace doesn't exist, there can be no compatibility
   807  		// problem, so we can return. This is most likely to happen when
   808  		// migrating state from a local backend to a new workspace.
   809  		if err == tfe.ErrResourceNotFound {
   810  			return nil
   811  		}
   812  
   813  		diags = diags.Append(tfdiags.Sourceless(
   814  			tfdiags.Error,
   815  			"Error looking up workspace",
   816  			fmt.Sprintf("Workspace read failed: %s", err),
   817  		))
   818  		return diags
   819  	}
   820  
   821  	// If the workspace has the pseudo-version "latest", all bets are off. We
   822  	// cannot reasonably determine what the intended Terraform version is, so
   823  	// we'll skip version verification.
   824  	if workspace.TerraformVersion == "latest" {
   825  		return nil
   826  	}
   827  
   828  	// If the workspace has execution-mode set to local, the remote Terraform
   829  	// version is effectively meaningless, so we'll skip version verification.
   830  	if isLocalExecutionMode(workspace.ExecutionMode) {
   831  		return nil
   832  	}
   833  
   834  	remoteConstraint, err := version.NewConstraint(workspace.TerraformVersion)
   835  	if err != nil {
   836  		message := fmt.Sprintf(
   837  			"The remote workspace specified an invalid Terraform version or constraint (%s), "+
   838  				"and it isn't possible to determine whether the local Terraform version (%s) is compatible.",
   839  			workspace.TerraformVersion,
   840  			tfversion.String(),
   841  		)
   842  		diags = diags.Append(incompatibleWorkspaceTerraformVersion(message, b.ignoreVersionConflict))
   843  		return diags
   844  	}
   845  
   846  	remoteVersion, _ := version.NewSemver(workspace.TerraformVersion)
   847  
   848  	// We can use a looser version constraint if the workspace specifies a
   849  	// literal Terraform version, and it is not a prerelease. The latter
   850  	// restriction is because we cannot compare prerelease versions with any
   851  	// operator other than simple equality.
   852  	if remoteVersion != nil && remoteVersion.Prerelease() == "" {
   853  		v014 := version.Must(version.NewSemver("0.14.0"))
   854  		v120 := version.Must(version.NewSemver("1.2.0"))
   855  
   856  		// Versions from 0.14 through the early 1.x series should be compatible
   857  		// (though we don't know about 1.2 yet).
   858  		if remoteVersion.GreaterThanOrEqual(v014) && remoteVersion.LessThan(v120) {
   859  			early1xCompatible, err := version.NewConstraint(fmt.Sprintf(">= 0.14.0, < %s", v120.String()))
   860  			if err != nil {
   861  				panic(err)
   862  			}
   863  			remoteConstraint = early1xCompatible
   864  		}
   865  
   866  		// Any future new state format will require at least a minor version
   867  		// increment, so x.y.* will always be compatible with each other.
   868  		if remoteVersion.GreaterThanOrEqual(v120) {
   869  			rwvs := remoteVersion.Segments64()
   870  			if len(rwvs) >= 3 {
   871  				// ~> x.y.0
   872  				minorVersionCompatible, err := version.NewConstraint(fmt.Sprintf("~> %d.%d.0", rwvs[0], rwvs[1]))
   873  				if err != nil {
   874  					panic(err)
   875  				}
   876  				remoteConstraint = minorVersionCompatible
   877  			}
   878  		}
   879  	}
   880  
   881  	// Re-parsing tfversion.String because tfversion.SemVer omits the prerelease
   882  	// prefix, and we want to allow constraints like `~> 1.2.0-beta1`.
   883  	fullTfversion := version.Must(version.NewSemver(tfversion.String()))
   884  
   885  	if remoteConstraint.Check(fullTfversion) {
   886  		return diags
   887  	}
   888  
   889  	message := fmt.Sprintf(
   890  		"The local Terraform version (%s) does not meet the version requirements for remote workspace %s/%s (%s).",
   891  		tfversion.String(),
   892  		b.organization,
   893  		workspace.Name,
   894  		remoteConstraint,
   895  	)
   896  	diags = diags.Append(incompatibleWorkspaceTerraformVersion(message, b.ignoreVersionConflict))
   897  	return diags
   898  }
   899  
   900  func (b *Cloud) IsLocalOperations() bool {
   901  	return b.forceLocal
   902  }
   903  
   904  // Colorize returns the Colorize structure that can be used for colorizing
   905  // output. This is guaranteed to always return a non-nil value and so useful
   906  // as a helper to wrap any potentially colored strings.
   907  //
   908  // TODO SvH: Rename this back to Colorize as soon as we can pass -no-color.
   909  //lint:ignore U1000 see above todo
   910  func (b *Cloud) cliColorize() *colorstring.Colorize {
   911  	if b.CLIColor != nil {
   912  		return b.CLIColor
   913  	}
   914  
   915  	return &colorstring.Colorize{
   916  		Colors:  colorstring.DefaultColors,
   917  		Disable: true,
   918  	}
   919  }
   920  
   921  func (b *Cloud) workspaceTagsRequireUpdate(workspace *tfe.Workspace, workspaceMapping WorkspaceMapping) bool {
   922  	if workspaceMapping.Strategy() != WorkspaceTagsStrategy {
   923  		return false
   924  	}
   925  
   926  	existingTags := map[string]struct{}{}
   927  	for _, t := range workspace.TagNames {
   928  		existingTags[t] = struct{}{}
   929  	}
   930  
   931  	for _, tag := range workspaceMapping.Tags {
   932  		if _, ok := existingTags[tag]; !ok {
   933  			return true
   934  		}
   935  	}
   936  
   937  	return false
   938  }
   939  
   940  type WorkspaceMapping struct {
   941  	Name string
   942  	Tags []string
   943  }
   944  
   945  type workspaceStrategy string
   946  
   947  const (
   948  	WorkspaceTagsStrategy    workspaceStrategy = "tags"
   949  	WorkspaceNameStrategy    workspaceStrategy = "name"
   950  	WorkspaceNoneStrategy    workspaceStrategy = "none"
   951  	WorkspaceInvalidStrategy workspaceStrategy = "invalid"
   952  )
   953  
   954  func (wm WorkspaceMapping) Strategy() workspaceStrategy {
   955  	switch {
   956  	case len(wm.Tags) > 0 && wm.Name == "":
   957  		return WorkspaceTagsStrategy
   958  	case len(wm.Tags) == 0 && wm.Name != "":
   959  		return WorkspaceNameStrategy
   960  	case len(wm.Tags) == 0 && wm.Name == "":
   961  		return WorkspaceNoneStrategy
   962  	default:
   963  		// Any other combination is invalid as each strategy is mutually exclusive
   964  		return WorkspaceInvalidStrategy
   965  	}
   966  }
   967  
   968  func isLocalExecutionMode(execMode string) bool {
   969  	return execMode == "local"
   970  }
   971  
   972  func (wm WorkspaceMapping) tfeTags() []*tfe.Tag {
   973  	var tags []*tfe.Tag
   974  
   975  	if wm.Strategy() != WorkspaceTagsStrategy {
   976  		return tags
   977  	}
   978  
   979  	for _, tag := range wm.Tags {
   980  		t := tfe.Tag{Name: tag}
   981  		tags = append(tags, &t)
   982  	}
   983  
   984  	return tags
   985  }
   986  
   987  func generalError(msg string, err error) error {
   988  	var diags tfdiags.Diagnostics
   989  
   990  	if urlErr, ok := err.(*url.Error); ok {
   991  		err = urlErr.Err
   992  	}
   993  
   994  	switch err {
   995  	case context.Canceled:
   996  		return err
   997  	case tfe.ErrResourceNotFound:
   998  		diags = diags.Append(tfdiags.Sourceless(
   999  			tfdiags.Error,
  1000  			fmt.Sprintf("%s: %v", msg, err),
  1001  			"For security, Terraform Cloud returns '404 Not Found' responses for resources\n"+
  1002  				"for resources that a user doesn't have access to, in addition to resources that\n"+
  1003  				"do not exist. If the resource does exist, please check the permissions of the provided token.",
  1004  		))
  1005  		return diags.Err()
  1006  	default:
  1007  		diags = diags.Append(tfdiags.Sourceless(
  1008  			tfdiags.Error,
  1009  			fmt.Sprintf("%s: %v", msg, err),
  1010  			`Terraform Cloud returned an unexpected error. Sometimes `+
  1011  				`this is caused by network connection problems, in which case you could retry `+
  1012  				`the command. If the issue persists please open a support ticket to get help `+
  1013  				`resolving the problem.`,
  1014  		))
  1015  		return diags.Err()
  1016  	}
  1017  }
  1018  
  1019  // The newline in this error is to make it look good in the CLI!
  1020  const initialRetryError = `
  1021  [reset][yellow]There was an error connecting to Terraform Cloud. Please do not exit
  1022  Terraform to prevent data loss! Trying to restore the connection...
  1023  [reset]
  1024  `
  1025  
  1026  const repeatedRetryError = `
  1027  [reset][yellow]Still trying to restore the connection... (%s elapsed)[reset]
  1028  `
  1029  
  1030  const operationCanceled = `
  1031  [reset][red]The remote operation was successfully cancelled.[reset]
  1032  `
  1033  
  1034  const operationNotCanceled = `
  1035  [reset][red]The remote operation was not cancelled.[reset]
  1036  `
  1037  
  1038  const refreshToApplyRefresh = `[bold][yellow]Proceeding with 'terraform apply -refresh-only -auto-approve'.[reset]`
  1039  
  1040  const unavailableTerraformVersion = `
  1041  [reset][yellow]The local Terraform version (%s) is not available in Terraform Cloud, or your
  1042  organization does not have access to it. The new workspace will use %s. You can
  1043  change this later in the workspace settings.[reset]`
  1044  
  1045  const cloudIntegrationUsedInUnsupportedTFE = `
  1046  This version of Terraform Cloud/Enterprise does not support the state mechanism
  1047  attempting to be used by the platform. This should never happen.
  1048  
  1049  Please reach out to HashiCorp Support to resolve this issue.`
  1050  
  1051  var (
  1052  	workspaceConfigurationHelp = fmt.Sprintf(
  1053  		`The 'workspaces' block configures how Terraform CLI maps its workspaces for this single
  1054  configuration to workspaces within a Terraform Cloud organization. Two strategies are available:
  1055  
  1056  [bold]tags[reset] - %s
  1057  
  1058  [bold]name[reset] - %s`, schemaDescriptionTags, schemaDescriptionName)
  1059  
  1060  	schemaDescriptionHostname = `The Terraform Enterprise hostname to connect to. This optional argument defaults to app.terraform.io
  1061  for use with Terraform Cloud.`
  1062  
  1063  	schemaDescriptionOrganization = `The name of the organization containing the targeted workspace(s).`
  1064  
  1065  	schemaDescriptionToken = `The token used to authenticate with Terraform Cloud/Enterprise. Typically this argument should not
  1066  be set, and 'terraform login' used instead; your credentials will then be fetched from your CLI
  1067  configuration file or configured credential helper.`
  1068  
  1069  	schemaDescriptionTags = `A set of tags used to select remote Terraform Cloud workspaces to be used for this single
  1070  configuration. New workspaces will automatically be tagged with these tag values. Generally, this
  1071  is the primary and recommended strategy to use.  This option conflicts with "name".`
  1072  
  1073  	schemaDescriptionName = `The name of a single Terraform Cloud workspace to be used with this configuration.
  1074  When configured, only the specified workspace can be used. This option conflicts with "tags".`
  1075  )