| // 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 |
| } |
| |
| // 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 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, error) { |
| // 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, nil |
| } |
| |
| // 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, 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, 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, nil |
| } |
| |
| // DeleteWorkspace implements backend.Backend (which is embedded in backendrun.OperationsBackend). |
| func (b *Cloud) DeleteWorkspace(name string, force bool) error { |
| if name == backend.DefaultStateName { |
| return backend.ErrDefaultWorkspaceNotSupported |
| } |
| |
| if b.WorkspaceMapping.Strategy() == WorkspaceNameStrategy { |
| return 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 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 State.Delete(force) |
| } |
| |
| // StateMgr implements backend.Backend (which is embedded in backendrun.OperationsBackend). |
| func (b *Cloud) StateMgr(name string) (statemgr.Full, error) { |
| var remoteTFVersion string |
| |
| if name == backend.DefaultStateName { |
| return nil, backend.ErrDefaultWorkspaceNotSupported |
| } |
| |
| if b.WorkspaceMapping.Strategy() == WorkspaceNameStrategy && name != b.WorkspaceMapping.Name { |
| return nil, backend.ErrWorkspacesNotSupported |
| } |
| |
| 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 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, 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, 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, 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, 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, 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, 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}, nil |
| } |
| |
| // 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) (*tfe.Run, error) |
| switch op.Type { |
| case backendrun.OperationTypePlan: |
| 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 == nil && opErr == context.Canceled { |
| runningOp.Result = backendrun.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(b.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(b.generalError("Failed to retrieve run", err)) |
| op.ReportResult(runningOp, diags) |
| return |
| } |
| } |
| |
| if r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored { |
| 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 Enterpise project. Workspaces that need creating |
| will be created within this project.` |
| ) |