mirror of
https://github.com/hashicorp/terraform.git
synced 2026-02-19 02:39:17 -05:00
Some checks are pending
build / Determine intended Terraform version (push) Waiting to run
build / Determine Go toolchain version (push) Waiting to run
build / Generate release metadata (push) Blocked by required conditions
build / Build for freebsd_386 (push) Blocked by required conditions
build / Build for linux_386 (push) Blocked by required conditions
build / Build for openbsd_386 (push) Blocked by required conditions
build / Build for windows_386 (push) Blocked by required conditions
build / Build for darwin_amd64 (push) Blocked by required conditions
build / Build for freebsd_amd64 (push) Blocked by required conditions
build / Build for linux_amd64 (push) Blocked by required conditions
build / Build for openbsd_amd64 (push) Blocked by required conditions
build / Build for solaris_amd64 (push) Blocked by required conditions
build / Build for windows_amd64 (push) Blocked by required conditions
build / Build for freebsd_arm (push) Blocked by required conditions
build / Build for linux_arm (push) Blocked by required conditions
build / Build for darwin_arm64 (push) Blocked by required conditions
build / Build for linux_arm64 (push) Blocked by required conditions
build / Build for windows_arm64 (push) Blocked by required conditions
build / Build Docker image for linux_386 (push) Blocked by required conditions
build / Build Docker image for linux_amd64 (push) Blocked by required conditions
build / Build Docker image for linux_arm (push) Blocked by required conditions
build / Build Docker image for linux_arm64 (push) Blocked by required conditions
build / Build e2etest for linux_386 (push) Blocked by required conditions
build / Build e2etest for windows_386 (push) Blocked by required conditions
build / Build e2etest for darwin_amd64 (push) Blocked by required conditions
build / Build e2etest for linux_amd64 (push) Blocked by required conditions
build / Build e2etest for windows_amd64 (push) Blocked by required conditions
build / Build e2etest for linux_arm (push) Blocked by required conditions
build / Build e2etest for darwin_arm64 (push) Blocked by required conditions
build / Build e2etest for linux_arm64 (push) Blocked by required conditions
build / Run e2e test for linux_386 (push) Blocked by required conditions
build / Run e2e test for windows_386 (push) Blocked by required conditions
build / Run e2e test for darwin_amd64 (push) Blocked by required conditions
build / Run e2e test for linux_amd64 (push) Blocked by required conditions
build / Run e2e test for windows_amd64 (push) Blocked by required conditions
build / Run e2e test for linux_arm (push) Blocked by required conditions
build / Run e2e test for linux_arm64 (push) Blocked by required conditions
build / Run terraform-exec test for linux amd64 (push) Blocked by required conditions
Quick Checks / Unit Tests (push) Waiting to run
Quick Checks / Race Tests (push) Waiting to run
Quick Checks / End-to-end Tests (push) Waiting to run
Quick Checks / Code Consistency Checks (push) Waiting to run
* Fix crash when showing a cloud plan without a cloud backend * Add changelog
1554 lines
53 KiB
Go
1554 lines
53 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package cloud
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/hashicorp/cli"
|
|
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/mitchellh/colorstring"
|
|
"github.com/zclconf/go-cty/cty"
|
|
|
|
"github.com/hashicorp/terraform/internal/backend"
|
|
"github.com/hashicorp/terraform/internal/backend/backendrun"
|
|
"github.com/hashicorp/terraform/internal/command/jsonformat"
|
|
"github.com/hashicorp/terraform/internal/command/views"
|
|
"github.com/hashicorp/terraform/internal/configs/configschema"
|
|
"github.com/hashicorp/terraform/internal/plans"
|
|
"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"
|
|
|
|
backendLocal "github.com/hashicorp/terraform/internal/backend/local"
|
|
)
|
|
|
|
const (
|
|
defaultHostname = "app.terraform.io"
|
|
defaultParallelism = 10
|
|
tfeServiceID = "tfe.v2"
|
|
headerSourceKey = "X-Terraform-Integration"
|
|
headerSourceValue = "cloud"
|
|
genericHostname = "localterraform.com"
|
|
)
|
|
|
|
var ErrCloudDoesNotSupportKVTags = errors.New("your version of Terraform Enterprise does not support key-value tags. Please upgrade Terraform Enterprise to a version that supports this feature or use set type tags instead.")
|
|
|
|
// Cloud is an implementation of backendrun.OperationsBackend in service of the HCP Terraform or Terraform Enterprise
|
|
// integration for Terraform CLI. This backend is not intended to be surfaced at the user level and
|
|
// is instead an implementation detail of cloud.Cloud.
|
|
type Cloud 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 HCP Terraform or Terraform Enterprise API client.
|
|
client *tfe.Client
|
|
|
|
// viewHooks implements functions integrating the tfe.Client with the CLI
|
|
// output.
|
|
viewHooks views.CloudHooks
|
|
|
|
// Hostname of HCP Terraform or Terraform Enterprise
|
|
Hostname string
|
|
|
|
// Token for HCP Terraform or Terraform Enterprise
|
|
Token string
|
|
|
|
// Organization is the Organization that contains the target workspaces.
|
|
Organization string
|
|
|
|
// WorkspaceMapping contains strategies for mapping CLI workspaces in the working directory
|
|
// to remote HCP Terraform workspaces.
|
|
WorkspaceMapping WorkspaceMapping
|
|
|
|
// ServicesHost is the full account of discovered Terraform services at the
|
|
// HCP Terraform instance. It should include at least the tfe v2 API, and
|
|
// possibly other services.
|
|
ServicesHost *disco.Host
|
|
|
|
// appName is the name of the instance the cloud backend is currently
|
|
// configured against
|
|
appName string
|
|
|
|
// services is used for service discovery
|
|
services *disco.Disco
|
|
|
|
// renderer is used for rendering JSON plan output and streamed logs.
|
|
renderer *jsonformat.Renderer
|
|
|
|
// local allows local operations, where HCP Terraform serves as a state storage backend.
|
|
local backendrun.OperationsBackend
|
|
|
|
// 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
|
|
|
|
runningInAutomation bool
|
|
|
|
// input stores the value of the -input flag, since it will be used
|
|
// to determine whether or not to ask the user for approval of a run.
|
|
input bool
|
|
}
|
|
|
|
var _ backend.Backend = (*Cloud)(nil)
|
|
var _ backendrun.OperationsBackend = (*Cloud)(nil)
|
|
var _ backendrun.Local = (*Cloud)(nil)
|
|
|
|
// New creates a new initialized cloud backend.
|
|
func New(services *disco.Disco) *Cloud {
|
|
return &Cloud{
|
|
services: services,
|
|
}
|
|
}
|
|
|
|
// ConfigSchema implements backend.Backend (which is embedded in backendrun.OperationsBackend).
|
|
func (b *Cloud) ConfigSchema() *configschema.Block {
|
|
return &configschema.Block{
|
|
Attributes: map[string]*configschema.Attribute{
|
|
"hostname": {
|
|
Type: cty.String,
|
|
Optional: true,
|
|
Description: schemaDescriptionHostname,
|
|
},
|
|
"organization": {
|
|
Type: cty.String,
|
|
Optional: true,
|
|
Description: schemaDescriptionOrganization,
|
|
},
|
|
"token": {
|
|
Type: cty.String,
|
|
Optional: true,
|
|
Description: schemaDescriptionToken,
|
|
},
|
|
},
|
|
|
|
BlockTypes: map[string]*configschema.NestedBlock{
|
|
"workspaces": {
|
|
Block: configschema.Block{
|
|
Attributes: map[string]*configschema.Attribute{
|
|
"name": {
|
|
Type: cty.String,
|
|
Optional: true,
|
|
Description: schemaDescriptionName,
|
|
},
|
|
"project": {
|
|
Type: cty.String,
|
|
Optional: true,
|
|
Description: schemaDescriptionProject,
|
|
},
|
|
"tags": {
|
|
Type: cty.DynamicPseudoType,
|
|
Optional: true,
|
|
Description: schemaDescriptionTags,
|
|
},
|
|
},
|
|
},
|
|
Nesting: configschema.NestingSingle,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// PrepareConfig implements backend.Backend (which is embedded in backendrun.OperationsBackend).
|
|
// Per the interface contract, it should catch invalid contents in the config value and populate
|
|
// knowable default values, but must NOT consult environment variables or other knowledge
|
|
// outside the config value itself.
|
|
func (b *Cloud) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) {
|
|
var diags tfdiags.Diagnostics
|
|
if obj.IsNull() {
|
|
return obj, diags
|
|
}
|
|
|
|
// Since this backend uses environment variables extensively, this function
|
|
// can't do very much! We do our main validity checks in resolveCloudConfig,
|
|
// which is allowed to resolve fallback values from the environment. About
|
|
// the only thing we can check for here is whether the conflicting `name`
|
|
// and `tags` attributes are both set.
|
|
if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() {
|
|
if val := workspaces.GetAttr("name"); !val.IsNull() {
|
|
if val := workspaces.GetAttr("tags"); !val.IsNull() {
|
|
diags = diags.Append(invalidWorkspaceConfigMisconfiguration)
|
|
}
|
|
}
|
|
}
|
|
|
|
return obj, diags
|
|
}
|
|
|
|
func (b *Cloud) ServiceDiscoveryAliases() ([]backendrun.HostAlias, error) {
|
|
aliasHostname, err := svchost.ForComparison(genericHostname)
|
|
if err != nil {
|
|
// This should never happen because the hostname is statically defined.
|
|
return nil, fmt.Errorf("failed to create backend alias from alias %q. The hostname is not in the correct format. This is a bug in the backend", genericHostname)
|
|
}
|
|
|
|
targetHostname, err := svchost.ForComparison(b.Hostname)
|
|
if err != nil {
|
|
// This should never happen because the 'to' alias is the backend host, which has
|
|
// already been ev
|
|
return nil, fmt.Errorf("failed to create backend alias to target %q. The hostname is not in the correct format.", b.Hostname)
|
|
}
|
|
|
|
return []backendrun.HostAlias{
|
|
{
|
|
From: aliasHostname,
|
|
To: targetHostname,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (b *Cloud) Services() *disco.Disco {
|
|
return b.services
|
|
}
|
|
|
|
// Configure implements backend.Backend (which is embedded in backendrun.OperationsBackend).
|
|
func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics {
|
|
var diags tfdiags.Diagnostics
|
|
if obj.IsNull() {
|
|
return diags
|
|
}
|
|
|
|
// Combine environment variables and the cloud block to get the full config.
|
|
// We are now completely done with `obj`!
|
|
config, configDiags := resolveCloudConfig(obj)
|
|
diags = diags.Append(configDiags)
|
|
if diags.HasErrors() {
|
|
return diags
|
|
}
|
|
|
|
// Use resolved config to set fields on backend (except token, see below)
|
|
b.Hostname = config.hostname
|
|
b.Organization = config.organization
|
|
b.WorkspaceMapping = config.workspaceMapping
|
|
|
|
// Discover the service URL to confirm that it provides the Terraform
|
|
// Cloud/Enterprise API... and while we're at it, cache the full discovery
|
|
// results.
|
|
var tfcService *url.URL
|
|
var host *disco.Host
|
|
// We want to handle errors from URL normalization and service discovery in
|
|
// the same way. So we only perform each step if there wasn't a previous
|
|
// error, and use the same block to handle errors from anywhere in the
|
|
// process.
|
|
hostname, err := svchost.ForComparison(b.Hostname)
|
|
if err == nil {
|
|
host, err = b.services.Discover(hostname)
|
|
|
|
if err == nil {
|
|
// The discovery request worked, so cache the full results.
|
|
b.ServicesHost = host
|
|
|
|
// Find the TFE API service URL
|
|
tfcService, err = host.ServiceURL(tfeServiceID)
|
|
} else {
|
|
// Network errors from Discover() can read like non-sequiters, so we wrap em.
|
|
var serviceDiscoErr *disco.ErrServiceDiscoveryNetworkRequest
|
|
if errors.As(err, &serviceDiscoErr) {
|
|
err = fmt.Errorf("a network issue prevented cloud configuration; %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle any errors from URL normalization and service discovery 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
|
|
}
|
|
|
|
// Token time. First, see if the configuration had one:
|
|
token := config.token
|
|
|
|
// Get the token from the CLI Config File in the credentials section
|
|
// if no token was set in the configuration
|
|
if token == "" {
|
|
token, err = CliConfigToken(hostname, b.services)
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
b.Token = token
|
|
|
|
if b.client == nil {
|
|
cfg := &tfe.Config{
|
|
Address: tfcService.String(),
|
|
BasePath: tfcService.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)
|
|
cfg.Headers.Set(headerSourceKey, headerSourceValue)
|
|
|
|
// Create the HCP Terraform API client.
|
|
b.client, err = tfe.NewClient(cfg)
|
|
if err != nil {
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
"Failed to create the HCP Terraform or Terraform Enterprise client",
|
|
fmt.Sprintf(
|
|
`Encountered an unexpected error while creating the `+
|
|
`HCP Terraform or Terraform Enterprise client: %s.`, err,
|
|
),
|
|
))
|
|
return diags
|
|
}
|
|
}
|
|
|
|
// Read the app name header and if empty, provide a default
|
|
b.appName = b.client.AppName()
|
|
// Validate the header's value to ensure no tampering
|
|
if !isValidAppName(b.appName) {
|
|
b.appName = "HCP Terraform"
|
|
}
|
|
|
|
// Check if the organization exists by reading its entitlements.
|
|
entitlements, err := b.client.Organizations.ReadEntitlements(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("Encountered an unexpected error while reading the "+
|
|
"organization settings: %s", err),
|
|
cty.Path{cty.GetAttrStep{Name: "organization"}},
|
|
))
|
|
return diags
|
|
}
|
|
|
|
// If TF_WORKSPACE specifies a current workspace to use, make sure it's usable.
|
|
if ws, ok := os.LookupEnv("TF_WORKSPACE"); ok {
|
|
if ws == b.WorkspaceMapping.Name || b.WorkspaceMapping.IsTagsStrategy() {
|
|
diag := b.validWorkspaceEnvVar(context.Background(), b.Organization, ws)
|
|
if diag != nil {
|
|
diags = diags.Append(diag)
|
|
return diags
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for the minimum version of Terraform Enterprise required.
|
|
//
|
|
// For API versions prior to 2.3, RemoteAPIVersion will return an empty string,
|
|
// so if there's an error when parsing the RemoteAPIVersion, it's handled as
|
|
// equivalent to an API version < 2.3.
|
|
currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion())
|
|
desiredAPIVersion, _ := version.NewVersion("2.5")
|
|
|
|
if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) {
|
|
log.Printf("[TRACE] API version check failed; want: >= %s, got: %s", desiredAPIVersion.Original(), currentAPIVersion)
|
|
if b.runningInAutomation {
|
|
// It should never be possible for this Terraform process to be mistakenly
|
|
// used internally within an unsupported Terraform Enterprise install - but
|
|
// just in case it happens, give an actionable error.
|
|
diags = diags.Append(
|
|
tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
"Unsupported Terraform Enterprise version",
|
|
fmt.Sprintf(cloudIntegrationUsedInUnsupportedTFE, b.appName),
|
|
),
|
|
)
|
|
} else {
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
"Unsupported Terraform Enterprise version",
|
|
`The 'cloud' option is not supported with this version of Terraform Enterprise.`,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Configure a local backend for when we need to run operations locally.
|
|
b.local = backendLocal.NewWithBackend(b)
|
|
|
|
// Determine if we are forced to use the local backend.
|
|
b.forceLocal = os.Getenv("TF_FORCE_LOCAL_BACKEND") != "" || !entitlements.Operations
|
|
|
|
// Enable retries for server errors as the backend is now fully configured.
|
|
b.client.RetryServerErrors(true)
|
|
|
|
return diags
|
|
}
|
|
|
|
func (b *Cloud) AppName() string {
|
|
if b != nil && isValidAppName(b.appName) {
|
|
return b.appName
|
|
}
|
|
return "HCP Terraform"
|
|
}
|
|
|
|
// resolveCloudConfig fills in a potentially incomplete cloud config block using
|
|
// environment variables and defaults. If the returned Diagnostics are clean,
|
|
// the resulting value is a logically valid cloud config. If the Diagnostics
|
|
// contain any errors, the resolved config value is invalid and should not be
|
|
// used. Note that this function does not verify that any objects referenced in
|
|
// the config actually exist in the remote system; it only validates that the
|
|
// resulting config is internally consistent.
|
|
func resolveCloudConfig(obj cty.Value) (cloudConfig, tfdiags.Diagnostics) {
|
|
var ret cloudConfig
|
|
var diags tfdiags.Diagnostics
|
|
|
|
// Get the hostname. Config beats environment. Absent means use the default
|
|
// hostname.
|
|
if val := obj.GetAttr("hostname"); !val.IsNull() && val.AsString() != "" {
|
|
ret.hostname = val.AsString()
|
|
log.Printf("[TRACE] cloud: using hostname %q from cloud config block", ret.hostname)
|
|
} else {
|
|
ret.hostname = os.Getenv("TF_CLOUD_HOSTNAME")
|
|
log.Printf("[TRACE] cloud: using hostname %q from TF_CLOUD_HOSTNAME variable", ret.hostname)
|
|
}
|
|
if ret.hostname == "" {
|
|
ret.hostname = defaultHostname
|
|
log.Printf("[TRACE] cloud: using default hostname %q", ret.hostname)
|
|
}
|
|
|
|
// Get the organization. Config beats environment. There's no default, so
|
|
// absent means error.
|
|
if val := obj.GetAttr("organization"); !val.IsNull() && val.AsString() != "" {
|
|
ret.organization = val.AsString()
|
|
log.Printf("[TRACE] cloud: using organization %q from cloud config block", ret.organization)
|
|
} else {
|
|
ret.organization = os.Getenv("TF_CLOUD_ORGANIZATION")
|
|
log.Printf("[TRACE] cloud: using organization %q from TF_CLOUD_ORGANIZATION variable", ret.organization)
|
|
}
|
|
if ret.organization == "" {
|
|
diags = diags.Append(missingConfigAttributeAndEnvVar("organization", "TF_CLOUD_ORGANIZATION"))
|
|
}
|
|
|
|
// Get the token. We only report what's in the config! An empty value is
|
|
// ok; later, after this function is called, Configure() can try to resolve
|
|
// per-hostname credentials from a variety of sources (including
|
|
// hostname-specific env vars).
|
|
if val := obj.GetAttr("token"); !val.IsNull() {
|
|
ret.token = val.AsString()
|
|
log.Printf("[TRACE] cloud: found token in cloud config block")
|
|
}
|
|
|
|
// Grab any workspace/project info from the nested config object in one go,
|
|
// so it's easier to work with.
|
|
var name, project string
|
|
if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() {
|
|
if val := workspaces.GetAttr("name"); !val.IsNull() {
|
|
name = val.AsString()
|
|
log.Printf("[TRACE] cloud: found workspace name %q in cloud config block", name)
|
|
}
|
|
if val := workspaces.GetAttr("tags"); !val.IsNull() {
|
|
log.Printf("[TRACE] tags is a %q type", val.Type().FriendlyName())
|
|
tagsAsMap := make(map[string]string)
|
|
if val.Type().IsObjectType() || val.Type().IsMapType() {
|
|
for k, v := range val.AsValueMap() {
|
|
if v.Type() != cty.String {
|
|
diags = diags.Append(errors.New("tag object values must be strings"))
|
|
return ret, diags
|
|
}
|
|
tagsAsMap[k] = v.AsString()
|
|
}
|
|
log.Printf("[TRACE] cloud: using tags %q from cloud config block", tagsAsMap)
|
|
ret.workspaceMapping.TagsAsMap = tagsAsMap
|
|
} else if val.Type().IsTupleType() || val.Type().IsSetType() {
|
|
var tagsAsSet []string
|
|
length := val.LengthInt()
|
|
if length > 0 {
|
|
it := val.ElementIterator()
|
|
for it.Next() {
|
|
_, v := it.Element()
|
|
if !v.Type().Equals(cty.String) {
|
|
diags = diags.Append(errors.New("tag elements must be strings"))
|
|
return ret, diags
|
|
}
|
|
if vs := v.AsString(); vs != "" {
|
|
tagsAsSet = append(tagsAsSet, vs)
|
|
}
|
|
}
|
|
}
|
|
|
|
log.Printf("[TRACE] cloud: using tags %q from cloud config block", tagsAsSet)
|
|
ret.workspaceMapping.TagsAsSet = tagsAsSet
|
|
} else {
|
|
diags = diags.Append(fmt.Errorf("tags must be a set or object, not %s", val.Type().FriendlyName()))
|
|
return ret, diags
|
|
}
|
|
}
|
|
if val := workspaces.GetAttr("project"); !val.IsNull() {
|
|
project = val.AsString()
|
|
log.Printf("[TRACE] cloud: found project name %q in cloud config block", project)
|
|
}
|
|
}
|
|
|
|
// Get the project. Config beats environment, and the default value is the
|
|
// empty string.
|
|
if project != "" {
|
|
ret.workspaceMapping.Project = project
|
|
log.Printf("[TRACE] cloud: using project %q from cloud config block", ret.workspaceMapping.Project)
|
|
} else {
|
|
ret.workspaceMapping.Project = os.Getenv("TF_CLOUD_PROJECT")
|
|
log.Printf("[TRACE] cloud: using project %q from TF_CLOUD_PROJECT variable", ret.workspaceMapping.Project)
|
|
}
|
|
|
|
// Get the name, and validate the WorkspaceMapping as a whole. This is the
|
|
// only real tricky one, because TF_WORKSPACE is used in places beyond
|
|
// the cloud backend config. The rules are:
|
|
// - If the config had neither `name` nor `tags`, we fall back to TF_WORKSPACE as the name.
|
|
// - If the config had `tags`, it's still legal to set TF_WORKSPACE, and it indicates
|
|
// which workspace should be *current,* but we leave Name blank in the mapping.
|
|
// This is mostly useful in CI.
|
|
// - If the config had `name`, it's NOT LEGAL to set TF_WORKSPACE, but we make
|
|
// an exception if it's the same as the specified `name` because the intent was clear.
|
|
|
|
// Start out with the name from the config (if any)
|
|
ret.workspaceMapping.Name = name
|
|
|
|
// Then examine the combination of name + tags:
|
|
switch ret.workspaceMapping.Strategy() {
|
|
// Invalid can't really happen here because b.PrepareConfig() already
|
|
// checked for it. But, still:
|
|
case WorkspaceInvalidStrategy:
|
|
diags = diags.Append(invalidWorkspaceConfigMisconfiguration)
|
|
// If both name and TF_WORKSPACE are set, error (unless they match)
|
|
case WorkspaceNameStrategy:
|
|
if tfws, ok := os.LookupEnv("TF_WORKSPACE"); ok && tfws != ret.workspaceMapping.Name {
|
|
diags = diags.Append(invalidWorkspaceConfigNameConflict)
|
|
} else {
|
|
log.Printf("[TRACE] cloud: using workspace name %q from cloud config block", ret.workspaceMapping.Name)
|
|
}
|
|
// If config had nothing, use TF_WORKSPACE.
|
|
case WorkspaceNoneStrategy:
|
|
ret.workspaceMapping.Name = os.Getenv("TF_WORKSPACE")
|
|
log.Printf("[TRACE] cloud: using workspace name %q from TF_WORKSPACE variable", ret.workspaceMapping.Name)
|
|
// And, if config only had tags, do nothing.
|
|
}
|
|
|
|
// If our workspace mapping is still None after all that, then we don't have
|
|
// a valid completed config!
|
|
if ret.workspaceMapping.Strategy() == WorkspaceNoneStrategy {
|
|
diags = diags.Append(invalidWorkspaceConfigMissingValues)
|
|
}
|
|
|
|
return ret, diags
|
|
}
|
|
|
|
// CliConfigToken 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 CliConfigToken(hostname svchost.Hostname, services *disco.Disco) (string, error) {
|
|
creds, err := services.CredentialsForHost(hostname)
|
|
if err != nil {
|
|
log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", hostname.ForDisplay(), 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 *Cloud) retryLogHook(attemptNum int, resp *http.Response) {
|
|
if b.CLI != nil {
|
|
if output := b.viewHooks.RetryLogHook(attemptNum, resp, true); len(output) > 0 {
|
|
b.CLI.Output(b.Colorize().Color(output))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Workspaces implements backend.Backend (which is embedded in backendrun.OperationsBackend),
|
|
// returning a filtered list of workspace names according to the workspace mapping strategy configured.
|
|
func (b *Cloud) Workspaces() ([]string, tfdiags.Diagnostics) {
|
|
var diags tfdiags.Diagnostics
|
|
// Create a slice to contain all the names.
|
|
var names []string
|
|
|
|
// If configured for a single workspace, return that exact name only. The StateMgr for this
|
|
// backend will automatically create the remote workspace if it does not yet exist.
|
|
if b.WorkspaceMapping.Strategy() == WorkspaceNameStrategy {
|
|
names = append(names, b.WorkspaceMapping.Name)
|
|
return names, diags
|
|
}
|
|
|
|
// Otherwise, multiple workspaces are being mapped. Query HCP Terraform for all the remote
|
|
// workspaces by the provided mapping strategy.
|
|
options := &tfe.WorkspaceListOptions{}
|
|
if b.WorkspaceMapping.Strategy() == WorkspaceTagsStrategy {
|
|
options.Tags = strings.Join(b.WorkspaceMapping.TagsAsSet, ",")
|
|
} else if b.WorkspaceMapping.Strategy() == WorkspaceKVTagsStrategy {
|
|
options.TagBindings = b.WorkspaceMapping.asTFETagBindings()
|
|
|
|
// Populate keys, too, just in case backend does not support key/value tags.
|
|
// The backend will end up applying both filters but that should always
|
|
// be the same result set anyway.
|
|
for _, tag := range options.TagBindings {
|
|
if options.Tags != "" {
|
|
options.Tags = options.Tags + ","
|
|
}
|
|
options.Tags = options.Tags + tag.Key
|
|
}
|
|
|
|
}
|
|
log.Printf("[TRACE] cloud: Listing workspaces with tag bindings %q", b.WorkspaceMapping.DescribeTags())
|
|
|
|
if b.WorkspaceMapping.Project != "" {
|
|
listOpts := &tfe.ProjectListOptions{
|
|
Name: b.WorkspaceMapping.Project,
|
|
}
|
|
projects, err := b.client.Projects.List(context.Background(), b.Organization, listOpts)
|
|
if err != nil && err != tfe.ErrResourceNotFound {
|
|
return nil, diags.Append(fmt.Errorf("failed to retrieve project %s: %v", listOpts.Name, err))
|
|
}
|
|
for _, p := range projects.Items {
|
|
if p.Name == b.WorkspaceMapping.Project {
|
|
options.ProjectID = p.ID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
for {
|
|
wl, err := b.client.Workspaces.List(context.Background(), b.Organization, options)
|
|
if err != nil {
|
|
return nil, diags.Append(err)
|
|
}
|
|
|
|
for _, w := range wl.Items {
|
|
names = append(names, w.Name)
|
|
}
|
|
|
|
// 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, diags
|
|
}
|
|
|
|
// DeleteWorkspace implements backend.Backend (which is embedded in backendrun.OperationsBackend).
|
|
func (b *Cloud) DeleteWorkspace(name string, force bool) tfdiags.Diagnostics {
|
|
var diags tfdiags.Diagnostics
|
|
|
|
if name == backend.DefaultStateName {
|
|
return diags.Append(backend.ErrDefaultWorkspaceNotSupported)
|
|
}
|
|
|
|
if b.WorkspaceMapping.Strategy() == WorkspaceNameStrategy {
|
|
return diags.Append(backend.ErrWorkspacesNotSupported)
|
|
}
|
|
|
|
workspace, err := b.client.Workspaces.Read(context.Background(), b.Organization, name)
|
|
if err == tfe.ErrResourceNotFound {
|
|
return nil // If the workspace does not exist, succeed
|
|
}
|
|
|
|
if err != nil {
|
|
return diags.Append(fmt.Errorf("failed to retrieve workspace %s: %v", name, err))
|
|
}
|
|
|
|
// Configure the remote workspace name.
|
|
State := &State{tfeClient: b.client, organization: b.Organization, workspace: workspace, enableIntermediateSnapshots: false}
|
|
return diags.Append(State.Delete(force))
|
|
}
|
|
|
|
// StateMgr implements backend.Backend (which is embedded in backendrun.OperationsBackend).
|
|
func (b *Cloud) StateMgr(name string) (statemgr.Full, tfdiags.Diagnostics) {
|
|
var diags tfdiags.Diagnostics
|
|
|
|
var remoteTFVersion string
|
|
|
|
if name == backend.DefaultStateName {
|
|
return nil, diags.Append(backend.ErrDefaultWorkspaceNotSupported)
|
|
}
|
|
|
|
if b.WorkspaceMapping.Strategy() == WorkspaceNameStrategy && name != b.WorkspaceMapping.Name {
|
|
return nil, diags.Append(backend.ErrWorkspacesNotSupported)
|
|
}
|
|
|
|
workspace, err := b.client.Workspaces.Read(context.Background(), b.Organization, name)
|
|
if err != nil && err != tfe.ErrResourceNotFound {
|
|
return nil, diags.Append(fmt.Errorf("Failed to retrieve workspace %s: %v", name, err))
|
|
}
|
|
if workspace != nil {
|
|
remoteTFVersion = workspace.TerraformVersion
|
|
}
|
|
|
|
var configuredProject *tfe.Project
|
|
|
|
// Attempt to find project if configured
|
|
if b.WorkspaceMapping.Project != "" {
|
|
listOpts := &tfe.ProjectListOptions{
|
|
Name: b.WorkspaceMapping.Project,
|
|
}
|
|
projects, err := b.client.Projects.List(context.Background(), b.Organization, listOpts)
|
|
if err != nil && err != tfe.ErrResourceNotFound {
|
|
// This is a failure to make an API request, fail to initialize
|
|
return nil, diags.Append(fmt.Errorf("Attempted to find configured project %s but was unable to.", b.WorkspaceMapping.Project))
|
|
}
|
|
for _, p := range projects.Items {
|
|
if p.Name == b.WorkspaceMapping.Project {
|
|
configuredProject = p
|
|
break
|
|
}
|
|
}
|
|
|
|
if configuredProject == nil {
|
|
// We were able to read project, but were unable to find the configured project
|
|
// This is not fatal as we may attempt to create the project if we need to create
|
|
// the workspace
|
|
log.Printf("[TRACE] cloud: Attempted to find configured project %s but was unable to.", b.WorkspaceMapping.Project)
|
|
}
|
|
}
|
|
|
|
if err == tfe.ErrResourceNotFound {
|
|
// Create workspace if it was not found
|
|
|
|
// Workspace Create Options
|
|
workspaceCreateOptions := tfe.WorkspaceCreateOptions{
|
|
Name: tfe.String(name),
|
|
Project: configuredProject,
|
|
}
|
|
|
|
if b.WorkspaceMapping.Strategy() == WorkspaceTagsStrategy {
|
|
workspaceCreateOptions.Tags = b.WorkspaceMapping.tfeTags()
|
|
} else if b.WorkspaceMapping.Strategy() == WorkspaceKVTagsStrategy {
|
|
workspaceCreateOptions.TagBindings = b.WorkspaceMapping.asTFETagBindings()
|
|
}
|
|
|
|
// Create project if not exists, otherwise use it
|
|
if workspaceCreateOptions.Project == nil && b.WorkspaceMapping.Project != "" {
|
|
// If we didn't find the project, try to create it
|
|
if workspaceCreateOptions.Project == nil {
|
|
createOpts := tfe.ProjectCreateOptions{
|
|
Name: b.WorkspaceMapping.Project,
|
|
}
|
|
// didn't find project, create it instead
|
|
log.Printf("[TRACE] cloud: Creating %s project %s/%s", b.appName, b.Organization, b.WorkspaceMapping.Project)
|
|
project, err := b.client.Projects.Create(context.Background(), b.Organization, createOpts)
|
|
if err != nil && err != tfe.ErrResourceNotFound {
|
|
return nil, diags.Append(fmt.Errorf("failed to create project %s: %v", b.WorkspaceMapping.Project, err))
|
|
}
|
|
configuredProject = project
|
|
workspaceCreateOptions.Project = configuredProject
|
|
}
|
|
}
|
|
|
|
// Create a workspace
|
|
log.Printf("[TRACE] cloud: Creating %s workspace %s/%s", b.appName, b.Organization, name)
|
|
workspace, err = b.client.Workspaces.Create(context.Background(), b.Organization, workspaceCreateOptions)
|
|
if err != nil {
|
|
return nil, diags.Append(fmt.Errorf("error creating workspace %s: %v", name, err))
|
|
}
|
|
|
|
remoteTFVersion = workspace.TerraformVersion
|
|
|
|
// Attempt to set the new workspace to use this version of Terraform. This
|
|
// can fail if there's no enabled tool_version whose name matches our
|
|
// version string, but that's expected sometimes -- just warn and continue.
|
|
versionOptions := tfe.WorkspaceUpdateOptions{
|
|
TerraformVersion: tfe.String(tfversion.String()),
|
|
}
|
|
_, err := b.client.Workspaces.UpdateByID(context.Background(), workspace.ID, versionOptions)
|
|
if err == nil {
|
|
remoteTFVersion = tfversion.String()
|
|
} else {
|
|
// TODO: Ideally we could rely on the client to tell us what the actual
|
|
// problem was, but we currently can't get enough context from the error
|
|
// object to do a nicely formatted message, so we're just assuming the
|
|
// issue was that the version wasn't available since that's probably what
|
|
// happened.
|
|
log.Printf("[TRACE] cloud: Attempted to select version %s for this %s workspace; unavailable, so %s will be used instead.", tfversion.String(), b.appName, workspace.TerraformVersion)
|
|
if b.CLI != nil {
|
|
versionUnavailable := fmt.Sprintf(unavailableTerraformVersion, tfversion.String(), b.appName, workspace.TerraformVersion)
|
|
b.CLI.Output(b.Colorize().Color(versionUnavailable))
|
|
}
|
|
}
|
|
}
|
|
|
|
tagCheck, errFromTagCheck := b.workspaceTagsRequireUpdate(context.Background(), workspace, b.WorkspaceMapping)
|
|
if tagCheck.requiresUpdate {
|
|
if errFromTagCheck != nil {
|
|
if errors.Is(errFromTagCheck, ErrCloudDoesNotSupportKVTags) {
|
|
return nil, diags.Append(fmt.Errorf("backend does not support key/value tags. Try using key-only tags: %w", errFromTagCheck))
|
|
}
|
|
}
|
|
|
|
log.Printf("[TRACE] cloud: Updating tags for %s workspace %s/%s to %q", b.appName, b.Organization, name, b.WorkspaceMapping.DescribeTags())
|
|
// Always update using KV tags if possible
|
|
if !tagCheck.supportsKVTags {
|
|
options := tfe.WorkspaceAddTagsOptions{
|
|
Tags: b.WorkspaceMapping.tfeTags(),
|
|
}
|
|
err = b.client.Workspaces.AddTags(context.Background(), workspace.ID, options)
|
|
} else {
|
|
options := tfe.WorkspaceAddTagBindingsOptions{
|
|
TagBindings: b.WorkspaceMapping.asTFETagBindings(),
|
|
}
|
|
_, err = b.client.Workspaces.AddTagBindings(context.Background(), workspace.ID, options)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, diags.Append(fmt.Errorf("error updating workspace %q tags: %w", 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 {
|
|
// Explicitly ignore the pseudo-version "latest" here, as it will cause
|
|
// plan and apply to always fail.
|
|
if remoteTFVersion != tfversion.String() && remoteTFVersion != "latest" {
|
|
return nil, diags.Append(fmt.Errorf("Remote workspace Terraform version %q does not match local Terraform version %q", remoteTFVersion, tfversion.String()))
|
|
}
|
|
}
|
|
|
|
return &State{tfeClient: b.client, organization: b.Organization, workspace: workspace, enableIntermediateSnapshots: false}, diags
|
|
}
|
|
|
|
// Operation implements backendrun.OperationsBackend.
|
|
func (b *Cloud) Operation(ctx context.Context, op *backendrun.Operation) (*backendrun.RunningOperation, error) {
|
|
// Retrieve the workspace for this operation.
|
|
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, which should only happen in the HCP Terraform 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
|
|
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, *backendrun.Operation, *tfe.Workspace) (OperationResult, error)
|
|
switch op.Type {
|
|
case backendrun.OperationTypePlan:
|
|
if op.Query {
|
|
f = b.opQuery
|
|
} else {
|
|
f = b.opPlan
|
|
}
|
|
case backendrun.OperationTypeApply:
|
|
f = b.opApply
|
|
case backendrun.OperationTypeRefresh:
|
|
// The `terraform refresh` command has been deprecated in favor of `terraform apply -refresh-state`.
|
|
// Rather than respond with an error telling the user to run the other command we can just run
|
|
// that command instead. We will tell the user what we are doing, and then do it.
|
|
if b.CLI != nil {
|
|
b.CLI.Output(b.Colorize().Color(strings.TrimSpace(refreshToApplyRefresh) + "\n"))
|
|
}
|
|
op.PlanMode = plans.RefreshOnlyMode
|
|
op.PlanRefresh = true
|
|
op.AutoApprove = true
|
|
f = b.opApply
|
|
default:
|
|
return nil, fmt.Errorf(
|
|
"\n\n%s does not support the %q operation.", b.appName, 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 := &backendrun.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.HasResult() && opErr == context.Canceled {
|
|
runningOp.Result = backendrun.OperationFailure
|
|
return
|
|
}
|
|
|
|
if r.HasResult() {
|
|
// Retrieve the run to get its current status.
|
|
latest, err := r.Read(cancelCtx)
|
|
if err != nil {
|
|
var diags tfdiags.Diagnostics
|
|
diags = diags.Append(b.generalError("Failed to retrieve run", err))
|
|
op.ReportResult(runningOp, diags)
|
|
return
|
|
}
|
|
|
|
// Record if there are any changes.
|
|
runningOp.PlanEmpty = !latest.HasChanges()
|
|
|
|
if opErr == context.Canceled {
|
|
if err := latest.Cancel(cancelCtx, op); err != nil {
|
|
var diags tfdiags.Diagnostics
|
|
diags = diags.Append(b.generalError("Failed to retrieve run", err))
|
|
op.ReportResult(runningOp, diags)
|
|
return
|
|
}
|
|
}
|
|
|
|
if latest.IsCanceled() || latest.IsErrored() {
|
|
runningOp.Result = backendrun.OperationFailure
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Return the running operation.
|
|
return runningOp, nil
|
|
}
|
|
|
|
func (b *Cloud) cancel(cancelCtx context.Context, op *backendrun.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 b.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 b.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 *Cloud) IgnoreVersionConflict() {
|
|
b.ignoreVersionConflict = true
|
|
}
|
|
|
|
// VerifyWorkspaceTerraformVersion compares the local Terraform version against
|
|
// the workspace's configured Terraform version. If they are compatible, this
|
|
// means that there are no state compatibility concerns, so it returns no
|
|
// diagnostics.
|
|
//
|
|
// If the versions aren't compatible, it returns an error (or, if
|
|
// b.ignoreVersionConflict is set, a warning).
|
|
func (b *Cloud) 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 execution-mode set to local, the remote Terraform
|
|
// version is effectively meaningless, so we'll skip version verification.
|
|
if isLocalExecutionMode(workspace.ExecutionMode) {
|
|
return nil
|
|
}
|
|
|
|
remoteConstraint, err := version.NewConstraint(workspace.TerraformVersion)
|
|
if err != nil {
|
|
message := fmt.Sprintf(
|
|
"The remote workspace specified an invalid Terraform version or constraint (%s), "+
|
|
"and it isn't possible to determine whether the local Terraform version (%s) is compatible.",
|
|
workspace.TerraformVersion,
|
|
tfversion.String(),
|
|
)
|
|
diags = diags.Append(incompatibleWorkspaceTerraformVersion(message, b.ignoreVersionConflict))
|
|
return diags
|
|
}
|
|
|
|
remoteVersion, _ := version.NewSemver(workspace.TerraformVersion)
|
|
|
|
// We can use a looser version constraint if the workspace specifies a
|
|
// literal Terraform version, and it is not a prerelease. The latter
|
|
// restriction is because we cannot compare prerelease versions with any
|
|
// operator other than simple equality.
|
|
if remoteVersion != nil && remoteVersion.Prerelease() == "" {
|
|
v014 := version.Must(version.NewSemver("0.14.0"))
|
|
v130 := version.Must(version.NewSemver("1.3.0"))
|
|
|
|
// Versions from 0.14 through the early 1.x series should be compatible
|
|
// (though we don't know about 1.3 yet).
|
|
if remoteVersion.GreaterThanOrEqual(v014) && remoteVersion.LessThan(v130) {
|
|
early1xCompatible, err := version.NewConstraint(fmt.Sprintf(">= 0.14.0, < %s", v130.String()))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
remoteConstraint = early1xCompatible
|
|
}
|
|
|
|
// Any future new state format will require at least a minor version
|
|
// increment, so x.y.* will always be compatible with each other.
|
|
if remoteVersion.GreaterThanOrEqual(v130) {
|
|
rwvs := remoteVersion.Segments64()
|
|
if len(rwvs) >= 3 {
|
|
// ~> x.y.0
|
|
minorVersionCompatible, err := version.NewConstraint(fmt.Sprintf("~> %d.%d.0", rwvs[0], rwvs[1]))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
remoteConstraint = minorVersionCompatible
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-parsing tfversion.String because tfversion.SemVer omits the prerelease
|
|
// prefix, and we want to allow constraints like `~> 1.2.0-beta1`.
|
|
fullTfversion := version.Must(version.NewSemver(tfversion.String()))
|
|
|
|
if remoteConstraint.Check(fullTfversion) {
|
|
return diags
|
|
}
|
|
|
|
message := fmt.Sprintf(
|
|
"The local Terraform version (%s) does not meet the version requirements for remote workspace %s/%s (%s).",
|
|
tfversion.String(),
|
|
b.Organization,
|
|
workspace.Name,
|
|
remoteConstraint,
|
|
)
|
|
diags = diags.Append(incompatibleWorkspaceTerraformVersion(message, b.ignoreVersionConflict))
|
|
return diags
|
|
}
|
|
|
|
func (b *Cloud) 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.
|
|
//
|
|
//lint:ignore U1000 see above todo
|
|
func (b *Cloud) cliColorize() *colorstring.Colorize {
|
|
if b.CLIColor != nil {
|
|
return b.CLIColor
|
|
}
|
|
|
|
return &colorstring.Colorize{
|
|
Colors: colorstring.DefaultColors,
|
|
Disable: true,
|
|
}
|
|
}
|
|
|
|
type tagRequiresUpdateResult struct {
|
|
requiresUpdate bool
|
|
supportsKVTags bool
|
|
}
|
|
|
|
func (b *Cloud) workspaceTagsRequireUpdate(ctx context.Context, workspace *tfe.Workspace, workspaceMapping WorkspaceMapping) (result tagRequiresUpdateResult, err error) {
|
|
result = tagRequiresUpdateResult{
|
|
supportsKVTags: true,
|
|
}
|
|
|
|
// First, depending on the strategy, build a map of the tags defined in config
|
|
// so we can compare them to the actual tags on the workspace
|
|
normalizedTagMap := make(map[string]string)
|
|
if workspaceMapping.IsTagsStrategy() {
|
|
for _, b := range workspaceMapping.asTFETagBindings() {
|
|
normalizedTagMap[b.Key] = b.Value
|
|
}
|
|
} else {
|
|
// Not a tag strategy
|
|
return
|
|
}
|
|
|
|
// Fetch tag bindings and determine if they should be checked
|
|
bindings, err := b.client.Workspaces.ListTagBindings(ctx, workspace.ID)
|
|
if err != nil && errors.Is(err, tfe.ErrResourceNotFound) {
|
|
// By this time, the workspace should have been fetched, proving that the
|
|
// authenticated user has access to it. If the tag bindings are not found,
|
|
// it would mean that the backend does not support tag bindings.
|
|
result.supportsKVTags = false
|
|
} else if err != nil {
|
|
return
|
|
}
|
|
|
|
err = nil
|
|
check:
|
|
// Check desired workspace tags against existing tags
|
|
for k, v := range normalizedTagMap {
|
|
log.Printf("[TRACE] cloud: Checking tag %q=%q", k, v)
|
|
if v == "" {
|
|
// Tag can exist in legacy tags or tag bindings
|
|
if !slices.Contains(workspace.TagNames, k) || (result.supportsKVTags && !slices.ContainsFunc(bindings, func(b *tfe.TagBinding) bool {
|
|
return b.Key == k
|
|
})) {
|
|
result.requiresUpdate = true
|
|
break check
|
|
}
|
|
} else if !result.supportsKVTags {
|
|
// There is a value defined, but the backend does not support tag bindings
|
|
result.requiresUpdate = true
|
|
err = ErrCloudDoesNotSupportKVTags
|
|
break check
|
|
} else {
|
|
// There is a value, so it must match a tag binding
|
|
if !slices.ContainsFunc(bindings, func(b *tfe.TagBinding) bool {
|
|
return b.Key == k && b.Value == v
|
|
}) {
|
|
result.requiresUpdate = true
|
|
break check
|
|
}
|
|
}
|
|
}
|
|
|
|
doesOrDoesnot := "does "
|
|
if !result.requiresUpdate {
|
|
doesOrDoesnot = "does not "
|
|
}
|
|
log.Printf("[TRACE] cloud: Workspace %s %srequire tag update", workspace.Name, doesOrDoesnot)
|
|
|
|
return
|
|
}
|
|
|
|
type WorkspaceMapping struct {
|
|
Name string
|
|
Project string
|
|
TagsAsSet []string
|
|
TagsAsMap map[string]string
|
|
}
|
|
|
|
type workspaceStrategy string
|
|
|
|
const (
|
|
WorkspaceKVTagsStrategy workspaceStrategy = "kvtags"
|
|
WorkspaceTagsStrategy workspaceStrategy = "tags"
|
|
WorkspaceNameStrategy workspaceStrategy = "name"
|
|
WorkspaceNoneStrategy workspaceStrategy = "none"
|
|
WorkspaceInvalidStrategy workspaceStrategy = "invalid"
|
|
)
|
|
|
|
func (wm WorkspaceMapping) IsTagsStrategy() bool {
|
|
return wm.Strategy() == WorkspaceTagsStrategy || wm.Strategy() == WorkspaceKVTagsStrategy
|
|
}
|
|
|
|
func (wm WorkspaceMapping) Strategy() workspaceStrategy {
|
|
switch {
|
|
case len(wm.TagsAsMap) > 0 && wm.Name == "":
|
|
return WorkspaceKVTagsStrategy
|
|
case len(wm.TagsAsSet) > 0 && wm.Name == "":
|
|
return WorkspaceTagsStrategy
|
|
case len(wm.TagsAsSet) == 0 && wm.Name != "":
|
|
return WorkspaceNameStrategy
|
|
case len(wm.TagsAsSet) == 0 && wm.Name == "":
|
|
return WorkspaceNoneStrategy
|
|
default:
|
|
// Any other combination is invalid as each strategy is mutually exclusive
|
|
return WorkspaceInvalidStrategy
|
|
}
|
|
}
|
|
|
|
// DescribeTags returns a string representation of the tags in the workspace
|
|
// mapping, based on the strategy used.
|
|
func (wm WorkspaceMapping) DescribeTags() string {
|
|
result := ""
|
|
|
|
switch wm.Strategy() {
|
|
case WorkspaceKVTagsStrategy:
|
|
for key, val := range wm.TagsAsMap {
|
|
if len(result) > 0 {
|
|
result += ", "
|
|
}
|
|
result += fmt.Sprintf("%s=%s", key, val)
|
|
}
|
|
case WorkspaceTagsStrategy:
|
|
result = strings.Join(wm.TagsAsSet, ", ")
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// cloudConfig is an intermediate type that represents the completed
|
|
// cloud block config as a plain Go value.
|
|
type cloudConfig struct {
|
|
hostname string
|
|
organization string
|
|
token string
|
|
workspaceMapping WorkspaceMapping
|
|
}
|
|
|
|
func isLocalExecutionMode(execMode string) bool {
|
|
return execMode == "local"
|
|
}
|
|
|
|
func (b *Cloud) fetchWorkspace(ctx context.Context, organization string, workspace string) (*tfe.Workspace, error) {
|
|
// Retrieve the workspace for this operation.
|
|
w, err := b.client.Workspaces.Read(ctx, organization, workspace)
|
|
if err != nil {
|
|
switch err {
|
|
case context.Canceled:
|
|
return nil, err
|
|
case tfe.ErrResourceNotFound:
|
|
return nil, fmt.Errorf(
|
|
"workspace %s not found\n\n"+
|
|
fmt.Sprintf("For security, %s returns '404 Not Found' responses for resources\n", b.appName)+
|
|
"for resources that a user doesn't have access to, in addition to resources that\n"+
|
|
"do not exist. If the resource does exist, please check the permissions of the provided token.",
|
|
workspace,
|
|
)
|
|
default:
|
|
err := fmt.Errorf(
|
|
"%s returned an unexpected error:\n\n%s",
|
|
b.appName,
|
|
err,
|
|
)
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return w, nil
|
|
}
|
|
|
|
// validWorkspaceEnvVar ensures we have selected a valid workspace using TF_WORKSPACE:
|
|
// First, it ensures the workspace specified by TF_WORKSPACE exists in the organization.
|
|
// (This is because we deliberately DON'T implicitly create a workspace from TF_WORKSPACE,
|
|
// unlike with a workspace specified via `name`.)
|
|
// Second, if tags are specified in the configuration, it ensures TF_WORKSPACE belongs to the set
|
|
// of available workspaces with those given tags.
|
|
func (b *Cloud) validWorkspaceEnvVar(ctx context.Context, organization, workspace string) tfdiags.Diagnostic {
|
|
// first ensure the workspace exists
|
|
_, err := b.client.Workspaces.Read(ctx, organization, workspace)
|
|
if err != nil && err != tfe.ErrResourceNotFound {
|
|
return tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
fmt.Sprintf("%s returned an unexpected error", b.appName),
|
|
err.Error(),
|
|
)
|
|
}
|
|
|
|
if err == tfe.ErrResourceNotFound {
|
|
return tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
"Invalid workspace selection",
|
|
fmt.Sprintf(`Terraform failed to find workspace %q in organization %s.`, workspace, organization),
|
|
)
|
|
}
|
|
|
|
// The remaining code is only concerned with tags configurations
|
|
if !b.WorkspaceMapping.IsTagsStrategy() {
|
|
return nil
|
|
}
|
|
|
|
// if the configuration has specified tags, we need to ensure TF_WORKSPACE
|
|
// is a valid member
|
|
opts := &tfe.WorkspaceListOptions{}
|
|
if b.WorkspaceMapping.Strategy() == WorkspaceTagsStrategy {
|
|
opts.Tags = strings.Join(b.WorkspaceMapping.TagsAsSet, ",")
|
|
} else if b.WorkspaceMapping.Strategy() == WorkspaceKVTagsStrategy {
|
|
opts.TagBindings = make([]*tfe.TagBinding, len(b.WorkspaceMapping.TagsAsMap))
|
|
|
|
index := 0
|
|
for key, val := range b.WorkspaceMapping.TagsAsMap {
|
|
opts.TagBindings[index] = &tfe.TagBinding{
|
|
Key: key,
|
|
Value: val,
|
|
}
|
|
index += 1
|
|
}
|
|
}
|
|
|
|
for {
|
|
wl, err := b.client.Workspaces.List(ctx, b.Organization, opts)
|
|
if err != nil {
|
|
return tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
fmt.Sprintf("%s returned an unexpected error", b.appName),
|
|
err.Error(),
|
|
)
|
|
}
|
|
|
|
for _, ws := range wl.Items {
|
|
if ws.Name == workspace {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if wl.CurrentPage >= wl.TotalPages {
|
|
break
|
|
}
|
|
|
|
opts.PageNumber = wl.NextPage
|
|
}
|
|
|
|
return tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
"Invalid workspace selection",
|
|
fmt.Sprintf(
|
|
"Terraform failed to find workspace %q with the tags specified in your configuration:\n[%s]",
|
|
workspace,
|
|
b.WorkspaceMapping.DescribeTags(),
|
|
),
|
|
)
|
|
}
|
|
|
|
func (wm WorkspaceMapping) tfeTags() []*tfe.Tag {
|
|
var tags []*tfe.Tag
|
|
|
|
if wm.Strategy() != WorkspaceTagsStrategy {
|
|
return tags
|
|
}
|
|
|
|
for _, tag := range wm.TagsAsSet {
|
|
t := tfe.Tag{Name: tag}
|
|
tags = append(tags, &t)
|
|
}
|
|
|
|
return tags
|
|
}
|
|
|
|
func (wm WorkspaceMapping) asTFETagBindings() []*tfe.TagBinding {
|
|
var tagBindings []*tfe.TagBinding
|
|
|
|
if wm.Strategy() == WorkspaceKVTagsStrategy {
|
|
tagBindings = make([]*tfe.TagBinding, len(wm.TagsAsMap))
|
|
|
|
index := 0
|
|
for key, val := range wm.TagsAsMap {
|
|
tagBindings[index] = &tfe.TagBinding{Key: key, Value: val}
|
|
index += 1
|
|
}
|
|
} else if wm.Strategy() == WorkspaceTagsStrategy {
|
|
tagBindings = make([]*tfe.TagBinding, len(wm.TagsAsSet))
|
|
|
|
for i, tag := range wm.TagsAsSet {
|
|
tagBindings[i] = &tfe.TagBinding{Key: tag}
|
|
}
|
|
}
|
|
return tagBindings
|
|
}
|
|
|
|
func (b *Cloud) 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),
|
|
fmt.Sprintf("For security, %s returns '404 Not Found' responses for resources\n", b.appName)+
|
|
"for resources that a user doesn't have access to, in addition to resources that\n"+
|
|
"do not exist. If the resource does exist, please check the permissions of the provided token.",
|
|
))
|
|
return diags.Err()
|
|
default:
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
fmt.Sprintf("%s: %v", msg, err),
|
|
fmt.Sprintf(`%s returned an unexpected error. Sometimes `, b.appName)+
|
|
`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()
|
|
}
|
|
}
|
|
|
|
const operationCanceled = `
|
|
[reset][red]The remote operation was successfully cancelled.[reset]
|
|
`
|
|
|
|
const operationNotCanceled = `
|
|
[reset][red]The remote operation was not cancelled.[reset]
|
|
`
|
|
|
|
const refreshToApplyRefresh = `[bold][yellow]Proceeding with 'terraform apply -refresh-only -auto-approve'.[reset]`
|
|
|
|
const unavailableTerraformVersion = `
|
|
[reset][yellow]The local Terraform version (%s) is not available in %s, or your
|
|
organization does not have access to it. The new workspace will use %s. You can
|
|
change this later in the workspace settings.[reset]`
|
|
|
|
const cloudIntegrationUsedInUnsupportedTFE = `
|
|
This version of %s does not support the state mechanism
|
|
attempting to be used by the platform. This should never happen.
|
|
|
|
Please reach out to HashiCorp Support to resolve this issue.`
|
|
|
|
var (
|
|
workspaceConfigurationHelp = fmt.Sprintf(
|
|
`The 'workspaces' block configures how Terraform CLI maps its workspaces for this single
|
|
configuration to workspaces within an HCP Terraform or Terraform Enterprise organization. Two strategies are available:
|
|
|
|
[bold]tags[reset] - %s
|
|
|
|
[bold]name[reset] - %s`, schemaDescriptionTags, schemaDescriptionName)
|
|
|
|
schemaDescriptionHostname = `The Terraform Enterprise hostname to connect to. This optional argument defaults to app.terraform.io
|
|
for use with HCP Terraform.`
|
|
|
|
schemaDescriptionOrganization = `The name of the organization containing the targeted workspace(s).`
|
|
|
|
schemaDescriptionToken = `The token used to authenticate with HCP Terraform or Terraform Enterprise. Typically this argument should not
|
|
be set, and 'terraform login' used instead; your credentials will then be fetched from your CLI
|
|
configuration file or configured credential helper.`
|
|
|
|
schemaDescriptionTags = `A set of tags used to select remote HCP Terraform or Terraform Enterprise workspaces to be used for this single
|
|
configuration. New workspaces will automatically be tagged with these tag values. Generally, this
|
|
is the primary and recommended strategy to use. This option conflicts with "name".`
|
|
|
|
schemaDescriptionName = `The name of a single HCP Terraform or Terraform Enterprise workspace to be used with this configuration.
|
|
When configured, only the specified workspace can be used. This option conflicts with "tags"
|
|
and with the TF_WORKSPACE environment variable.`
|
|
|
|
schemaDescriptionProject = `The name of an HCP Terraform or Terraform Enterprise project. Workspaces that need creating
|
|
will be created within this project.`
|
|
)
|