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