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