mirror of
https://github.com/opentofu/opentofu.git
synced 2025-01-18 12:42:58 -06:00
73dda868cc
This is part of a general effort to move all of Terraform's non-library package surface under internal in order to reinforce that these are for internal use within Terraform only. If you were previously importing packages under this prefix into an external codebase, you could pin to an earlier release tag as an interim solution until you've make a plan to achieve the same functionality some other way.
1048 lines
31 KiB
Go
1048 lines
31 KiB
Go
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/configs/configschema"
|
|
"github.com/hashicorp/terraform/internal/backend"
|
|
"github.com/hashicorp/terraform/states/remote"
|
|
"github.com/hashicorp/terraform/states/statemgr"
|
|
"github.com/hashicorp/terraform/terraform"
|
|
"github.com/hashicorp/terraform/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)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Operation implements backend.Enhanced.
|
|
func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) {
|
|
// Get the remote workspace name.
|
|
name := op.Workspace
|
|
switch {
|
|
case op.Workspace == backend.DefaultStateName:
|
|
name = b.workspace
|
|
case b.prefix != "" && !strings.HasPrefix(op.Workspace, b.prefix):
|
|
name = b.prefix + op.Workspace
|
|
}
|
|
|
|
// Retrieve the workspace for this operation.
|
|
w, err := b.client.Workspaces.Read(ctx, b.organization, name)
|
|
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:
|
|
return nil, fmt.Errorf(
|
|
"The configured \"remote\" backend encountered an unexpected error:\n\n%s",
|
|
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 || !w.Operations {
|
|
// Record that we're forced to run operations locally to allow the
|
|
// command package UI to operate correctly
|
|
b.forceLocal = true
|
|
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
|
|
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 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 workspace.Operations == false {
|
|
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.1.0
|
|
v110 := version.Must(version.NewSemver("1.1.0"))
|
|
if tfversion.SemVer.LessThan(v110) && remoteVersion.LessThan(v110) {
|
|
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
|
|
}
|
|
|
|
// Colorize returns the Colorize structure that can be used for colorizing
|
|
// output. This is guaranteed to always return a non-nil value and so useful
|
|
// as a helper to wrap any potentially colored strings.
|
|
//
|
|
// TODO SvH: Rename this back to Colorize as soon as we can pass -no-color.
|
|
func (b *Remote) cliColorize() *colorstring.Colorize {
|
|
if b.CLIColor != nil {
|
|
return b.CLIColor
|
|
}
|
|
|
|
return &colorstring.Colorize{
|
|
Colors: colorstring.DefaultColors,
|
|
Disable: true,
|
|
}
|
|
}
|
|
|
|
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\"",
|
|
}
|