package remote import ( "context" "fmt" "log" "net/http" "net/url" "os" "sort" "strings" "sync" "time" tfe "github.com/hashicorp/go-tfe" version "github.com/hashicorp/go-version" svchost "github.com/hashicorp/terraform-svchost" "github.com/hashicorp/terraform-svchost/disco" "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/states/remote" "github.com/hashicorp/terraform/internal/states/statemgr" "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" tfversion "github.com/hashicorp/terraform/version" "github.com/mitchellh/cli" "github.com/mitchellh/colorstring" "github.com/zclconf/go-cty/cty" backendLocal "github.com/hashicorp/terraform/internal/backend/local" ) const ( defaultHostname = "app.terraform.io" defaultParallelism = 10 stateServiceID = "state.v2" tfeServiceID = "tfe.v2.1" ) // Remote is an implementation of EnhancedBackend that performs all // operations in a remote backend. type Remote struct { // CLI and Colorize control the CLI output. If CLI is nil then no CLI // output will be done. If CLIColor is nil then no coloring will be done. CLI cli.Ui CLIColor *colorstring.Colorize // ContextOpts are the base context options to set when initializing a // new Terraform context. Many of these will be overridden or merged by // Operation. See Operation for more details. ContextOpts *terraform.ContextOpts // client is the remote backend API client. client *tfe.Client // lastRetry is set to the last time a request was retried. lastRetry time.Time // hostname of the remote backend server. hostname string // organization is the organization that contains the target workspaces. organization string // workspace is used to map the default workspace to a remote workspace. workspace string // prefix is used to filter down a set of workspaces that use a single // configuration. prefix string // services is used for service discovery services *disco.Disco // local, if non-nil, will be used for all enhanced behavior. This // allows local behavior with the remote backend functioning as remote // state storage backend. local backend.Enhanced // forceLocal, if true, will force the use of the local backend. forceLocal bool // opLock locks operations opLock sync.Mutex // ignoreVersionConflict, if true, will disable the requirement that the // local Terraform version matches the remote workspace's configured // version. This will also cause VerifyWorkspaceTerraformVersion to return // a warning diagnostic instead of an error. ignoreVersionConflict bool } var _ backend.Backend = (*Remote)(nil) var _ backend.Enhanced = (*Remote)(nil) var _ backend.Local = (*Remote)(nil) // New creates a new initialized remote backend. func New(services *disco.Disco) *Remote { return &Remote{ services: services, } } // ConfigSchema implements backend.Enhanced. func (b *Remote) ConfigSchema() *configschema.Block { return &configschema.Block{ Attributes: map[string]*configschema.Attribute{ "hostname": { Type: cty.String, Optional: true, Description: schemaDescriptions["hostname"], }, "organization": { Type: cty.String, Required: true, Description: schemaDescriptions["organization"], }, "token": { Type: cty.String, Optional: true, Description: schemaDescriptions["token"], }, }, BlockTypes: map[string]*configschema.NestedBlock{ "workspaces": { Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ "name": { Type: cty.String, Optional: true, Description: schemaDescriptions["name"], }, "prefix": { Type: cty.String, Optional: true, Description: schemaDescriptions["prefix"], }, }, }, Nesting: configschema.NestingSingle, }, }, } } // PrepareConfig implements backend.Backend. func (b *Remote) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics if obj.IsNull() { return obj, diags } if val := obj.GetAttr("organization"); val.IsNull() || val.AsString() == "" { diags = diags.Append(tfdiags.AttributeValue( tfdiags.Error, "Invalid organization value", `The "organization" attribute value must not be empty.`, cty.Path{cty.GetAttrStep{Name: "organization"}}, )) } var name, prefix string if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { if val := workspaces.GetAttr("name"); !val.IsNull() { name = val.AsString() } if val := workspaces.GetAttr("prefix"); !val.IsNull() { prefix = val.AsString() } } // Make sure that we have either a workspace name or a prefix. if name == "" && prefix == "" { diags = diags.Append(tfdiags.AttributeValue( tfdiags.Error, "Invalid workspaces configuration", `Either workspace "name" or "prefix" is required.`, cty.Path{cty.GetAttrStep{Name: "workspaces"}}, )) } // Make sure that only one of workspace name or a prefix is configured. if name != "" && prefix != "" { diags = diags.Append(tfdiags.AttributeValue( tfdiags.Error, "Invalid workspaces configuration", `Only one of workspace "name" or "prefix" is allowed.`, cty.Path{cty.GetAttrStep{Name: "workspaces"}}, )) } return obj, diags } // Configure implements backend.Enhanced. func (b *Remote) Configure(obj cty.Value) tfdiags.Diagnostics { var diags tfdiags.Diagnostics if obj.IsNull() { return diags } // Get the hostname. if val := obj.GetAttr("hostname"); !val.IsNull() && val.AsString() != "" { b.hostname = val.AsString() } else { b.hostname = defaultHostname } // Get the organization. if val := obj.GetAttr("organization"); !val.IsNull() { b.organization = val.AsString() } // Get the workspaces configuration block and retrieve the // default workspace name and prefix. if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { if val := workspaces.GetAttr("name"); !val.IsNull() { b.workspace = val.AsString() } if val := workspaces.GetAttr("prefix"); !val.IsNull() { b.prefix = val.AsString() } } // Determine if we are forced to use the local backend. b.forceLocal = os.Getenv("TF_FORCE_LOCAL_BACKEND") != "" serviceID := tfeServiceID if b.forceLocal { serviceID = stateServiceID } // Discover the service URL for this host to confirm that it provides // a remote backend API and to get the version constraints. service, constraints, err := b.discover(serviceID) // First check any contraints we might have received. if constraints != nil { diags = diags.Append(b.checkConstraints(constraints)) if diags.HasErrors() { return diags } } // When we don't have any constraints errors, also check for discovery // errors before we continue. if err != nil { diags = diags.Append(tfdiags.AttributeValue( tfdiags.Error, strings.ToUpper(err.Error()[:1])+err.Error()[1:], "", // no description is needed here, the error is clear cty.Path{cty.GetAttrStep{Name: "hostname"}}, )) return diags } // Retrieve the token for this host as configured in the credentials // section of the CLI Config File. token, err := b.token() if err != nil { diags = diags.Append(tfdiags.AttributeValue( tfdiags.Error, strings.ToUpper(err.Error()[:1])+err.Error()[1:], "", // no description is needed here, the error is clear cty.Path{cty.GetAttrStep{Name: "hostname"}}, )) return diags } // Get the token from the config if no token was configured for this // host in credentials section of the CLI Config File. if token == "" { if val := obj.GetAttr("token"); !val.IsNull() { token = val.AsString() } } // Return an error if we still don't have a token at this point. if token == "" { loginCommand := "terraform login" if b.hostname != defaultHostname { loginCommand = loginCommand + " " + b.hostname } diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Required token could not be found", fmt.Sprintf( "Run the following command to generate a token for %s:\n %s", b.hostname, loginCommand, ), )) return diags } cfg := &tfe.Config{ Address: service.String(), BasePath: service.Path, Token: token, Headers: make(http.Header), RetryLogHook: b.retryLogHook, } // Set the version header to the current version. cfg.Headers.Set(tfversion.Header, tfversion.Version) // Create the remote backend API client. b.client, err = tfe.NewClient(cfg) if err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to create the Terraform Enterprise client", fmt.Sprintf( `The "remote" backend encountered an unexpected error while creating the `+ `Terraform Enterprise client: %s.`, err, ), )) return diags } // Check if the organization exists by reading its entitlements. entitlements, err := b.client.Organizations.Entitlements(context.Background(), b.organization) if err != nil { if err == tfe.ErrResourceNotFound { err = fmt.Errorf("organization %q at host %s not found.\n\n"+ "Please ensure that the organization and hostname are correct "+ "and that your API token for %s is valid.", b.organization, b.hostname, b.hostname) } diags = diags.Append(tfdiags.AttributeValue( tfdiags.Error, fmt.Sprintf("Failed to read organization %q at host %s", b.organization, b.hostname), fmt.Sprintf("The \"remote\" backend encountered an unexpected error while reading the "+ "organization settings: %s", err), cty.Path{cty.GetAttrStep{Name: "organization"}}, )) return diags } // Configure a local backend for when we need to run operations locally. b.local = backendLocal.NewWithBackend(b) b.forceLocal = b.forceLocal || !entitlements.Operations // Enable retries for server errors as the backend is now fully configured. b.client.RetryServerErrors(true) return diags } // discover the remote backend API service URL and version constraints. func (b *Remote) discover(serviceID string) (*url.URL, *disco.Constraints, error) { hostname, err := svchost.ForComparison(b.hostname) if err != nil { return nil, nil, err } host, err := b.services.Discover(hostname) if err != nil { return nil, nil, err } service, err := host.ServiceURL(serviceID) // Return the error, unless its a disco.ErrVersionNotSupported error. if _, ok := err.(*disco.ErrVersionNotSupported); !ok && err != nil { return nil, nil, err } // We purposefully ignore the error and return the previous error, as // checking for version constraints is considered optional. constraints, _ := host.VersionConstraints(serviceID, "terraform") return service, constraints, err } // checkConstraints checks service version constrains against our own // version and returns rich and informational diagnostics in case any // incompatibilities are detected. func (b *Remote) checkConstraints(c *disco.Constraints) tfdiags.Diagnostics { var diags tfdiags.Diagnostics if c == nil || c.Minimum == "" || c.Maximum == "" { return diags } // Generate a parsable constraints string. excluding := "" if len(c.Excluding) > 0 { excluding = fmt.Sprintf(", != %s", strings.Join(c.Excluding, ", != ")) } constStr := fmt.Sprintf(">= %s%s, <= %s", c.Minimum, excluding, c.Maximum) // Create the constraints to check against. constraints, err := version.NewConstraint(constStr) if err != nil { return diags.Append(checkConstraintsWarning(err)) } // Create the version to check. v, err := version.NewVersion(tfversion.Version) if err != nil { return diags.Append(checkConstraintsWarning(err)) } // Return if we satisfy all constraints. if constraints.Check(v) { return diags } // Find out what action (upgrade/downgrade) we should advice. minimum, err := version.NewVersion(c.Minimum) if err != nil { return diags.Append(checkConstraintsWarning(err)) } maximum, err := version.NewVersion(c.Maximum) if err != nil { return diags.Append(checkConstraintsWarning(err)) } var excludes []*version.Version for _, exclude := range c.Excluding { v, err := version.NewVersion(exclude) if err != nil { return diags.Append(checkConstraintsWarning(err)) } excludes = append(excludes, v) } // Sort all the excludes. sort.Sort(version.Collection(excludes)) var action, toVersion string switch { case minimum.GreaterThan(v): action = "upgrade" toVersion = ">= " + minimum.String() case maximum.LessThan(v): action = "downgrade" toVersion = "<= " + maximum.String() case len(excludes) > 0: // Get the latest excluded version. action = "upgrade" toVersion = "> " + excludes[len(excludes)-1].String() } switch { case len(excludes) == 1: excluding = fmt.Sprintf(", excluding version %s", excludes[0].String()) case len(excludes) > 1: var vs []string for _, v := range excludes { vs = append(vs, v.String()) } excluding = fmt.Sprintf(", excluding versions %s", strings.Join(vs, ", ")) default: excluding = "" } summary := fmt.Sprintf("Incompatible Terraform version v%s", v.String()) details := fmt.Sprintf( "The configured Terraform Enterprise backend is compatible with Terraform "+ "versions >= %s, <= %s%s.", c.Minimum, c.Maximum, excluding, ) if action != "" && toVersion != "" { summary = fmt.Sprintf("Please %s Terraform to %s", action, toVersion) details += fmt.Sprintf(" Please %s to a supported version and try again.", action) } // Return the customized and informational error message. return diags.Append(tfdiags.Sourceless(tfdiags.Error, summary, details)) } // token returns the token for this host as configured in the credentials // section of the CLI Config File. If no token was configured, an empty // string will be returned instead. func (b *Remote) token() (string, error) { hostname, err := svchost.ForComparison(b.hostname) if err != nil { return "", err } creds, err := b.services.CredentialsForHost(hostname) if err != nil { log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", b.hostname, err) return "", nil } if creds != nil { return creds.Token(), nil } return "", nil } // retryLogHook is invoked each time a request is retried allowing the // backend to log any connection issues to prevent data loss. func (b *Remote) retryLogHook(attemptNum int, resp *http.Response) { if b.CLI != nil { // Ignore the first retry to make sure any delayed output will // be written to the console before we start logging retries. // // The retry logic in the TFE client will retry both rate limited // requests and server errors, but in the remote backend we only // care about server errors so we ignore rate limit (429) errors. if attemptNum == 0 || (resp != nil && resp.StatusCode == 429) { // Reset the last retry time. b.lastRetry = time.Now() return } if attemptNum == 1 { b.CLI.Output(b.Colorize().Color(strings.TrimSpace(initialRetryError))) } else { b.CLI.Output(b.Colorize().Color(strings.TrimSpace( fmt.Sprintf(repeatedRetryError, time.Since(b.lastRetry).Round(time.Second))))) } } } // Workspaces implements backend.Enhanced. func (b *Remote) Workspaces() ([]string, error) { if b.prefix == "" { return nil, backend.ErrWorkspacesNotSupported } return b.workspaces() } // workspaces returns a filtered list of remote workspace names. func (b *Remote) workspaces() ([]string, error) { options := tfe.WorkspaceListOptions{} switch { case b.workspace != "": options.Search = tfe.String(b.workspace) case b.prefix != "": options.Search = tfe.String(b.prefix) } // Create a slice to contain all the names. var names []string for { wl, err := b.client.Workspaces.List(context.Background(), b.organization, options) if err != nil { return nil, err } for _, w := range wl.Items { if b.workspace != "" && w.Name == b.workspace { names = append(names, backend.DefaultStateName) continue } if b.prefix != "" && strings.HasPrefix(w.Name, b.prefix) { names = append(names, strings.TrimPrefix(w.Name, b.prefix)) } } // Exit the loop when we've seen all pages. if wl.CurrentPage >= wl.TotalPages { break } // Update the page number to get the next page. options.PageNumber = wl.NextPage } // Sort the result so we have consistent output. sort.StringSlice(names).Sort() return names, nil } // WorkspaceNamePattern provides an appropriate workspace renaming pattern for backend migration // purposes (handled outside of this package), based on previous usage of this backend with the // 'prefix' workspace functionality. As of this writing, see meta_backend.migrate.go func (b *Remote) WorkspaceNamePattern() string { if b.prefix != "" { return b.prefix + "*" } return "" } // DeleteWorkspace implements backend.Enhanced. func (b *Remote) DeleteWorkspace(name string) error { if b.workspace == "" && name == backend.DefaultStateName { return backend.ErrDefaultWorkspaceNotSupported } if b.prefix == "" && name != backend.DefaultStateName { return backend.ErrWorkspacesNotSupported } // Configure the remote workspace name. switch { case name == backend.DefaultStateName: name = b.workspace case b.prefix != "" && !strings.HasPrefix(name, b.prefix): name = b.prefix + name } client := &remoteClient{ client: b.client, organization: b.organization, workspace: &tfe.Workspace{ Name: name, }, } return client.Delete() } // StateMgr implements backend.Enhanced. func (b *Remote) StateMgr(name string) (statemgr.Full, error) { if b.workspace == "" && name == backend.DefaultStateName { return nil, backend.ErrDefaultWorkspaceNotSupported } if b.prefix == "" && name != backend.DefaultStateName { return nil, backend.ErrWorkspacesNotSupported } // Configure the remote workspace name. switch { case name == backend.DefaultStateName: name = b.workspace case b.prefix != "" && !strings.HasPrefix(name, b.prefix): name = b.prefix + name } workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, name) if err != nil && err != tfe.ErrResourceNotFound { return nil, fmt.Errorf("Failed to retrieve workspace %s: %v", name, err) } if err == tfe.ErrResourceNotFound { options := tfe.WorkspaceCreateOptions{ Name: tfe.String(name), } // We only set the Terraform Version for the new workspace if this is // a release candidate or a final release. if tfversion.Prerelease == "" || strings.HasPrefix(tfversion.Prerelease, "rc") { options.TerraformVersion = tfe.String(tfversion.String()) } workspace, err = b.client.Workspaces.Create(context.Background(), b.organization, options) if err != nil { return nil, fmt.Errorf("Error creating workspace %s: %v", name, err) } } // This is a fallback error check. Most code paths should use other // mechanisms to check the version, then set the ignoreVersionConflict // field to true. This check is only in place to ensure that we don't // accidentally upgrade state with a new code path, and the version check // logic is coarser and simpler. if !b.ignoreVersionConflict { wsv := workspace.TerraformVersion // Explicitly ignore the pseudo-version "latest" here, as it will cause // plan and apply to always fail. if wsv != tfversion.String() && wsv != "latest" { return nil, fmt.Errorf("Remote workspace Terraform version %q does not match local Terraform version %q", workspace.TerraformVersion, tfversion.String()) } } client := &remoteClient{ client: b.client, organization: b.organization, workspace: workspace, // This is optionally set during Terraform Enterprise runs. runID: os.Getenv("TFE_RUN_ID"), } return &remote.State{Client: client}, nil } func isLocalExecutionMode(execMode string) bool { return execMode == "local" } func (b *Remote) fetchWorkspace(ctx context.Context, organization string, name string) (*tfe.Workspace, error) { remoteWorkspaceName := b.getRemoteWorkspaceName(name) // Retrieve the workspace for this operation. w, err := b.client.Workspaces.Read(ctx, b.organization, remoteWorkspaceName) if err != nil { switch err { case context.Canceled: return nil, err case tfe.ErrResourceNotFound: return nil, fmt.Errorf( "workspace %s not found\n\n"+ "The configured \"remote\" backend returns '404 Not Found' errors for resources\n"+ "that do not exist, as well as for resources that a user doesn't have access\n"+ "to. If the resource does exist, please check the rights for the used token", name, ) default: err := fmt.Errorf( "the configured \"remote\" backend encountered an unexpected error:\n\n%s", err, ) return nil, err } } return w, nil } // Operation implements backend.Enhanced. func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) { w, err := b.fetchWorkspace(ctx, b.organization, op.Workspace) if err != nil { return nil, err } // Terraform remote version conflicts are not a concern for operations. We // are in one of three states: // // - Running remotely, in which case the local version is irrelevant; // - Workspace configured for local operations, in which case the remote // version is meaningless; // - Forcing local operations with a remote backend, which should only // happen in the Terraform Cloud worker, in which case the Terraform // versions by definition match. b.IgnoreVersionConflict() // Check if we need to use the local backend to run the operation. if b.forceLocal || isLocalExecutionMode(w.ExecutionMode) { // Record that we're forced to run operations locally to allow the // command package UI to operate correctly b.forceLocal = true log.Printf("[DEBUG] Remote backend is delegating %s to the local backend", op.Type) return b.local.Operation(ctx, op) } // Set the remote workspace name. op.Workspace = w.Name // Determine the function to call for our operation var f func(context.Context, context.Context, *backend.Operation, *tfe.Workspace) (*tfe.Run, error) switch op.Type { case backend.OperationTypePlan: f = b.opPlan case backend.OperationTypeApply: f = b.opApply case backend.OperationTypeRefresh: return nil, fmt.Errorf( "\n\nThe \"refresh\" operation is not supported when using the \"remote\" backend. " + "Use \"terraform apply -refresh-only\" instead.") default: return nil, fmt.Errorf( "\n\nThe \"remote\" backend does not support the %q operation.", op.Type) } // Lock b.opLock.Lock() // Build our running operation // the runninCtx is only used to block until the operation returns. runningCtx, done := context.WithCancel(context.Background()) runningOp := &backend.RunningOperation{ Context: runningCtx, PlanEmpty: true, } // stopCtx wraps the context passed in, and is used to signal a graceful Stop. stopCtx, stop := context.WithCancel(ctx) runningOp.Stop = stop // cancelCtx is used to cancel the operation immediately, usually // indicating that the process is exiting. cancelCtx, cancel := context.WithCancel(context.Background()) runningOp.Cancel = cancel // Do it. go func() { defer logging.PanicHandler() defer done() defer stop() defer cancel() defer b.opLock.Unlock() r, opErr := f(stopCtx, cancelCtx, op, w) if opErr != nil && opErr != context.Canceled { var diags tfdiags.Diagnostics diags = diags.Append(opErr) op.ReportResult(runningOp, diags) return } if r == nil && opErr == context.Canceled { runningOp.Result = backend.OperationFailure return } if r != nil { // Retrieve the run to get its current status. r, err := b.client.Runs.Read(cancelCtx, r.ID) if err != nil { var diags tfdiags.Diagnostics diags = diags.Append(generalError("Failed to retrieve run", err)) op.ReportResult(runningOp, diags) return } // Record if there are any changes. runningOp.PlanEmpty = !r.HasChanges if opErr == context.Canceled { if err := b.cancel(cancelCtx, op, r); err != nil { var diags tfdiags.Diagnostics diags = diags.Append(generalError("Failed to retrieve run", err)) op.ReportResult(runningOp, diags) return } } if r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored { runningOp.Result = backend.OperationFailure } } }() // Return the running operation. return runningOp, nil } func (b *Remote) cancel(cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { if r.Actions.IsCancelable { // Only ask if the remote operation should be canceled // if the auto approve flag is not set. if !op.AutoApprove { v, err := op.UIIn.Input(cancelCtx, &terraform.InputOpts{ Id: "cancel", Query: "\nDo you want to cancel the remote operation?", Description: "Only 'yes' will be accepted to cancel.", }) if err != nil { return generalError("Failed asking to cancel", err) } if v != "yes" { if b.CLI != nil { b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationNotCanceled))) } return nil } } else { if b.CLI != nil { // Insert a blank line to separate the ouputs. b.CLI.Output("") } } // Try to cancel the remote operation. err := b.client.Runs.Cancel(cancelCtx, r.ID, tfe.RunCancelOptions{}) if err != nil { return generalError("Failed to cancel run", err) } if b.CLI != nil { b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationCanceled))) } } return nil } // IgnoreVersionConflict allows commands to disable the fall-back check that // the local Terraform version matches the remote workspace's configured // Terraform version. This should be called by commands where this check is // unnecessary, such as those performing remote operations, or read-only // operations. It will also be called if the user uses a command-line flag to // override this check. func (b *Remote) IgnoreVersionConflict() { b.ignoreVersionConflict = true } // VerifyWorkspaceTerraformVersion compares the local Terraform version against // the workspace's configured Terraform version. If they are equal, this means // that there are no compatibility concerns, so it returns no diagnostics. // // If the versions differ, func (b *Remote) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Diagnostics { var diags tfdiags.Diagnostics workspace, err := b.getRemoteWorkspace(context.Background(), workspaceName) if err != nil { // If the workspace doesn't exist, there can be no compatibility // problem, so we can return. This is most likely to happen when // migrating state from a local backend to a new workspace. if err == tfe.ErrResourceNotFound { return nil } diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Error looking up workspace", fmt.Sprintf("Workspace read failed: %s", err), )) return diags } // If the workspace has the pseudo-version "latest", all bets are off. We // cannot reasonably determine what the intended Terraform version is, so // we'll skip version verification. if workspace.TerraformVersion == "latest" { return nil } // If the workspace has remote operations disabled, the remote Terraform // version is effectively meaningless, so we'll skip version verification. if isLocalExecutionMode(workspace.ExecutionMode) { return nil } remoteVersion, err := version.NewSemver(workspace.TerraformVersion) if err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Error looking up workspace", fmt.Sprintf("Invalid Terraform version: %s", err), )) return diags } v014 := version.Must(version.NewSemver("0.14.0")) if tfversion.SemVer.LessThan(v014) || remoteVersion.LessThan(v014) { // Versions of Terraform prior to 0.14.0 will refuse to load state files // written by a newer version of Terraform, even if it is only a patch // level difference. As a result we require an exact match. if tfversion.SemVer.Equal(remoteVersion) { return diags } } if tfversion.SemVer.GreaterThanOrEqual(v014) && remoteVersion.GreaterThanOrEqual(v014) { // Versions of Terraform after 0.14.0 should be compatible with each // other. At the time this code was written, the only constraints we // are aware of are: // // - 0.14.0 is guaranteed to be compatible with versions up to but not // including 1.2.0 v120 := version.Must(version.NewSemver("1.2.0")) if tfversion.SemVer.LessThan(v120) && remoteVersion.LessThan(v120) { return diags } // - Any new Terraform state version will require at least minor patch // increment, so x.y.* will always be compatible with each other tfvs := tfversion.SemVer.Segments64() rwvs := remoteVersion.Segments64() if len(tfvs) == 3 && len(rwvs) == 3 && tfvs[0] == rwvs[0] && tfvs[1] == rwvs[1] { return diags } } // Even if ignoring version conflicts, it may still be useful to call this // method and warn the user about a mismatch between the local and remote // Terraform versions. severity := tfdiags.Error if b.ignoreVersionConflict { severity = tfdiags.Warning } suggestion := " If you're sure you want to upgrade the state, you can force Terraform to continue using the -ignore-remote-version flag. This may result in an unusable workspace." if b.ignoreVersionConflict { suggestion = "" } diags = diags.Append(tfdiags.Sourceless( severity, "Terraform version mismatch", fmt.Sprintf( "The local Terraform version (%s) does not match the configured version for remote workspace %s/%s (%s).%s", tfversion.String(), b.organization, workspace.Name, workspace.TerraformVersion, suggestion, ), )) return diags } func (b *Remote) IsLocalOperations() bool { return b.forceLocal } func generalError(msg string, err error) error { var diags tfdiags.Diagnostics if urlErr, ok := err.(*url.Error); ok { err = urlErr.Err } switch err { case context.Canceled: return err case tfe.ErrResourceNotFound: diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, fmt.Sprintf("%s: %v", msg, err), `The configured "remote" backend returns '404 Not Found' errors for resources `+ `that do not exist, as well as for resources that a user doesn't have access `+ `to. If the resource does exist, please check the rights for the used token.`, )) return diags.Err() default: diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, fmt.Sprintf("%s: %v", msg, err), `The configured "remote" backend encountered an unexpected error. Sometimes `+ `this is caused by network connection problems, in which case you could retry `+ `the command. If the issue persists please open a support ticket to get help `+ `resolving the problem.`, )) return diags.Err() } } func checkConstraintsWarning(err error) tfdiags.Diagnostic { return tfdiags.Sourceless( tfdiags.Warning, fmt.Sprintf("Failed to check version constraints: %v", err), "Checking version constraints is considered optional, but this is an"+ "unexpected error which should be reported.", ) } // The newline in this error is to make it look good in the CLI! const initialRetryError = ` [reset][yellow]There was an error connecting to the remote backend. Please do not exit Terraform to prevent data loss! Trying to restore the connection... [reset] ` const repeatedRetryError = ` [reset][yellow]Still trying to restore the connection... (%s elapsed)[reset] ` const operationCanceled = ` [reset][red]The remote operation was successfully cancelled.[reset] ` const operationNotCanceled = ` [reset][red]The remote operation was not cancelled.[reset] ` var schemaDescriptions = map[string]string{ "hostname": "The remote backend hostname to connect to (defaults to app.terraform.io).", "organization": "The name of the organization containing the targeted workspace(s).", "token": "The token used to authenticate with the remote backend. If credentials for the\n" + "host are configured in the CLI Config File, then those will be used instead.", "name": "A workspace name used to map the default workspace to a named remote workspace.\n" + "When configured only the default workspace can be used. This option conflicts\n" + "with \"prefix\"", "prefix": "A prefix used to filter workspaces using a single configuration. New workspaces\n" + "will automatically be prefixed with this prefix. If omitted only the default\n" + "workspace can be used. This option conflicts with \"name\"", }