github.com/hugorut/terraform@v1.1.3/src/backend/remote/backend.go (about) 1 package remote 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/logging" 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 31 backendLocal "github.com/hugorut/terraform/src/backend/local" 32 ) 33 34 const ( 35 defaultHostname = "app.terraform.io" 36 defaultParallelism = 10 37 stateServiceID = "state.v2" 38 tfeServiceID = "tfe.v2.1" 39 ) 40 41 // Remote is an implementation of EnhancedBackend that performs all 42 // operations in a remote backend. 43 type Remote struct { 44 // CLI and Colorize control the CLI output. If CLI is nil then no CLI 45 // output will be done. If CLIColor is nil then no coloring will be done. 46 CLI cli.Ui 47 CLIColor *colorstring.Colorize 48 49 // ContextOpts are the base context options to set when initializing a 50 // new Terraform context. Many of these will be overridden or merged by 51 // Operation. See Operation for more details. 52 ContextOpts *terraform.ContextOpts 53 54 // client is the remote backend API client. 55 client *tfe.Client 56 57 // lastRetry is set to the last time a request was retried. 58 lastRetry time.Time 59 60 // hostname of the remote backend server. 61 hostname string 62 63 // organization is the organization that contains the target workspaces. 64 organization string 65 66 // workspace is used to map the default workspace to a remote workspace. 67 workspace string 68 69 // prefix is used to filter down a set of workspaces that use a single 70 // configuration. 71 prefix string 72 73 // services is used for service discovery 74 services *disco.Disco 75 76 // local, if non-nil, will be used for all enhanced behavior. This 77 // allows local behavior with the remote backend functioning as remote 78 // state storage backend. 79 local backend.Enhanced 80 81 // forceLocal, if true, will force the use of the local backend. 82 forceLocal bool 83 84 // opLock locks operations 85 opLock sync.Mutex 86 87 // ignoreVersionConflict, if true, will disable the requirement that the 88 // local Terraform version matches the remote workspace's configured 89 // version. This will also cause VerifyWorkspaceTerraformVersion to return 90 // a warning diagnostic instead of an error. 91 ignoreVersionConflict bool 92 } 93 94 var _ backend.Backend = (*Remote)(nil) 95 var _ backend.Enhanced = (*Remote)(nil) 96 var _ backend.Local = (*Remote)(nil) 97 98 // New creates a new initialized remote backend. 99 func New(services *disco.Disco) *Remote { 100 return &Remote{ 101 services: services, 102 } 103 } 104 105 // ConfigSchema implements backend.Enhanced. 106 func (b *Remote) ConfigSchema() *configschema.Block { 107 return &configschema.Block{ 108 Attributes: map[string]*configschema.Attribute{ 109 "hostname": { 110 Type: cty.String, 111 Optional: true, 112 Description: schemaDescriptions["hostname"], 113 }, 114 "organization": { 115 Type: cty.String, 116 Required: true, 117 Description: schemaDescriptions["organization"], 118 }, 119 "token": { 120 Type: cty.String, 121 Optional: true, 122 Description: schemaDescriptions["token"], 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: schemaDescriptions["name"], 134 }, 135 "prefix": { 136 Type: cty.String, 137 Optional: true, 138 Description: schemaDescriptions["prefix"], 139 }, 140 }, 141 }, 142 Nesting: configschema.NestingSingle, 143 }, 144 }, 145 } 146 } 147 148 // PrepareConfig implements backend.Backend. 149 func (b *Remote) 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(tfdiags.AttributeValue( 157 tfdiags.Error, 158 "Invalid organization value", 159 `The "organization" attribute value must not be empty.`, 160 cty.Path{cty.GetAttrStep{Name: "organization"}}, 161 )) 162 } 163 164 var name, prefix string 165 if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { 166 if val := workspaces.GetAttr("name"); !val.IsNull() { 167 name = val.AsString() 168 } 169 if val := workspaces.GetAttr("prefix"); !val.IsNull() { 170 prefix = val.AsString() 171 } 172 } 173 174 // Make sure that we have either a workspace name or a prefix. 175 if name == "" && prefix == "" { 176 diags = diags.Append(tfdiags.AttributeValue( 177 tfdiags.Error, 178 "Invalid workspaces configuration", 179 `Either workspace "name" or "prefix" is required.`, 180 cty.Path{cty.GetAttrStep{Name: "workspaces"}}, 181 )) 182 } 183 184 // Make sure that only one of workspace name or a prefix is configured. 185 if name != "" && prefix != "" { 186 diags = diags.Append(tfdiags.AttributeValue( 187 tfdiags.Error, 188 "Invalid workspaces configuration", 189 `Only one of workspace "name" or "prefix" is allowed.`, 190 cty.Path{cty.GetAttrStep{Name: "workspaces"}}, 191 )) 192 } 193 194 return obj, diags 195 } 196 197 // Configure implements backend.Enhanced. 198 func (b *Remote) Configure(obj cty.Value) tfdiags.Diagnostics { 199 var diags tfdiags.Diagnostics 200 if obj.IsNull() { 201 return diags 202 } 203 204 // Get the hostname. 205 if val := obj.GetAttr("hostname"); !val.IsNull() && val.AsString() != "" { 206 b.hostname = val.AsString() 207 } else { 208 b.hostname = defaultHostname 209 } 210 211 // Get the organization. 212 if val := obj.GetAttr("organization"); !val.IsNull() { 213 b.organization = val.AsString() 214 } 215 216 // Get the workspaces configuration block and retrieve the 217 // default workspace name and prefix. 218 if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { 219 if val := workspaces.GetAttr("name"); !val.IsNull() { 220 b.workspace = val.AsString() 221 } 222 if val := workspaces.GetAttr("prefix"); !val.IsNull() { 223 b.prefix = val.AsString() 224 } 225 } 226 227 // Determine if we are forced to use the local backend. 228 b.forceLocal = os.Getenv("TF_FORCE_LOCAL_BACKEND") != "" 229 230 serviceID := tfeServiceID 231 if b.forceLocal { 232 serviceID = stateServiceID 233 } 234 235 // Discover the service URL for this host to confirm that it provides 236 // a remote backend API and to get the version constraints. 237 service, constraints, err := b.discover(serviceID) 238 239 // First check any contraints we might have received. 240 if constraints != nil { 241 diags = diags.Append(b.checkConstraints(constraints)) 242 if diags.HasErrors() { 243 return diags 244 } 245 } 246 247 // When we don't have any constraints errors, also check for discovery 248 // errors before we continue. 249 if err != nil { 250 diags = diags.Append(tfdiags.AttributeValue( 251 tfdiags.Error, 252 strings.ToUpper(err.Error()[:1])+err.Error()[1:], 253 "", // no description is needed here, the error is clear 254 cty.Path{cty.GetAttrStep{Name: "hostname"}}, 255 )) 256 return diags 257 } 258 259 // Retrieve the token for this host as configured in the credentials 260 // section of the CLI Config File. 261 token, err := b.token() 262 if err != nil { 263 diags = diags.Append(tfdiags.AttributeValue( 264 tfdiags.Error, 265 strings.ToUpper(err.Error()[:1])+err.Error()[1:], 266 "", // no description is needed here, the error is clear 267 cty.Path{cty.GetAttrStep{Name: "hostname"}}, 268 )) 269 return diags 270 } 271 272 // Get the token from the config if no token was configured for this 273 // host in credentials section of the CLI Config File. 274 if token == "" { 275 if val := obj.GetAttr("token"); !val.IsNull() { 276 token = val.AsString() 277 } 278 } 279 280 // Return an error if we still don't have a token at this point. 281 if token == "" { 282 loginCommand := "terraform login" 283 if b.hostname != defaultHostname { 284 loginCommand = loginCommand + " " + b.hostname 285 } 286 diags = diags.Append(tfdiags.Sourceless( 287 tfdiags.Error, 288 "Required token could not be found", 289 fmt.Sprintf( 290 "Run the following command to generate a token for %s:\n %s", 291 b.hostname, 292 loginCommand, 293 ), 294 )) 295 return diags 296 } 297 298 cfg := &tfe.Config{ 299 Address: service.String(), 300 BasePath: service.Path, 301 Token: token, 302 Headers: make(http.Header), 303 RetryLogHook: b.retryLogHook, 304 } 305 306 // Set the version header to the current version. 307 cfg.Headers.Set(tfversion.Header, tfversion.Version) 308 309 // Create the remote backend API client. 310 b.client, err = tfe.NewClient(cfg) 311 if err != nil { 312 diags = diags.Append(tfdiags.Sourceless( 313 tfdiags.Error, 314 "Failed to create the Terraform Enterprise client", 315 fmt.Sprintf( 316 `The "remote" backend encountered an unexpected error while creating the `+ 317 `Terraform Enterprise client: %s.`, err, 318 ), 319 )) 320 return diags 321 } 322 323 // Check if the organization exists by reading its entitlements. 324 entitlements, err := b.client.Organizations.Entitlements(context.Background(), b.organization) 325 if err != nil { 326 if err == tfe.ErrResourceNotFound { 327 err = fmt.Errorf("organization %q at host %s not found.\n\n"+ 328 "Please ensure that the organization and hostname are correct "+ 329 "and that your API token for %s is valid.", 330 b.organization, b.hostname, b.hostname) 331 } 332 diags = diags.Append(tfdiags.AttributeValue( 333 tfdiags.Error, 334 fmt.Sprintf("Failed to read organization %q at host %s", b.organization, b.hostname), 335 fmt.Sprintf("The \"remote\" backend encountered an unexpected error while reading the "+ 336 "organization settings: %s", err), 337 cty.Path{cty.GetAttrStep{Name: "organization"}}, 338 )) 339 return diags 340 } 341 342 // Configure a local backend for when we need to run operations locally. 343 b.local = backendLocal.NewWithBackend(b) 344 b.forceLocal = b.forceLocal || !entitlements.Operations 345 346 // Enable retries for server errors as the backend is now fully configured. 347 b.client.RetryServerErrors(true) 348 349 return diags 350 } 351 352 // discover the remote backend API service URL and version constraints. 353 func (b *Remote) discover(serviceID string) (*url.URL, *disco.Constraints, error) { 354 hostname, err := svchost.ForComparison(b.hostname) 355 if err != nil { 356 return nil, nil, err 357 } 358 359 host, err := b.services.Discover(hostname) 360 if err != nil { 361 return nil, nil, err 362 } 363 364 service, err := host.ServiceURL(serviceID) 365 // Return the error, unless its a disco.ErrVersionNotSupported error. 366 if _, ok := err.(*disco.ErrVersionNotSupported); !ok && err != nil { 367 return nil, nil, err 368 } 369 370 // We purposefully ignore the error and return the previous error, as 371 // checking for version constraints is considered optional. 372 constraints, _ := host.VersionConstraints(serviceID, "terraform") 373 374 return service, constraints, err 375 } 376 377 // checkConstraints checks service version constrains against our own 378 // version and returns rich and informational diagnostics in case any 379 // incompatibilities are detected. 380 func (b *Remote) checkConstraints(c *disco.Constraints) tfdiags.Diagnostics { 381 var diags tfdiags.Diagnostics 382 383 if c == nil || c.Minimum == "" || c.Maximum == "" { 384 return diags 385 } 386 387 // Generate a parsable constraints string. 388 excluding := "" 389 if len(c.Excluding) > 0 { 390 excluding = fmt.Sprintf(", != %s", strings.Join(c.Excluding, ", != ")) 391 } 392 constStr := fmt.Sprintf(">= %s%s, <= %s", c.Minimum, excluding, c.Maximum) 393 394 // Create the constraints to check against. 395 constraints, err := version.NewConstraint(constStr) 396 if err != nil { 397 return diags.Append(checkConstraintsWarning(err)) 398 } 399 400 // Create the version to check. 401 v, err := version.NewVersion(tfversion.Version) 402 if err != nil { 403 return diags.Append(checkConstraintsWarning(err)) 404 } 405 406 // Return if we satisfy all constraints. 407 if constraints.Check(v) { 408 return diags 409 } 410 411 // Find out what action (upgrade/downgrade) we should advice. 412 minimum, err := version.NewVersion(c.Minimum) 413 if err != nil { 414 return diags.Append(checkConstraintsWarning(err)) 415 } 416 417 maximum, err := version.NewVersion(c.Maximum) 418 if err != nil { 419 return diags.Append(checkConstraintsWarning(err)) 420 } 421 422 var excludes []*version.Version 423 for _, exclude := range c.Excluding { 424 v, err := version.NewVersion(exclude) 425 if err != nil { 426 return diags.Append(checkConstraintsWarning(err)) 427 } 428 excludes = append(excludes, v) 429 } 430 431 // Sort all the excludes. 432 sort.Sort(version.Collection(excludes)) 433 434 var action, toVersion string 435 switch { 436 case minimum.GreaterThan(v): 437 action = "upgrade" 438 toVersion = ">= " + minimum.String() 439 case maximum.LessThan(v): 440 action = "downgrade" 441 toVersion = "<= " + maximum.String() 442 case len(excludes) > 0: 443 // Get the latest excluded version. 444 action = "upgrade" 445 toVersion = "> " + excludes[len(excludes)-1].String() 446 } 447 448 switch { 449 case len(excludes) == 1: 450 excluding = fmt.Sprintf(", excluding version %s", excludes[0].String()) 451 case len(excludes) > 1: 452 var vs []string 453 for _, v := range excludes { 454 vs = append(vs, v.String()) 455 } 456 excluding = fmt.Sprintf(", excluding versions %s", strings.Join(vs, ", ")) 457 default: 458 excluding = "" 459 } 460 461 summary := fmt.Sprintf("Incompatible Terraform version v%s", v.String()) 462 details := fmt.Sprintf( 463 "The configured Terraform Enterprise backend is compatible with Terraform "+ 464 "versions >= %s, <= %s%s.", c.Minimum, c.Maximum, excluding, 465 ) 466 467 if action != "" && toVersion != "" { 468 summary = fmt.Sprintf("Please %s Terraform to %s", action, toVersion) 469 details += fmt.Sprintf(" Please %s to a supported version and try again.", action) 470 } 471 472 // Return the customized and informational error message. 473 return diags.Append(tfdiags.Sourceless(tfdiags.Error, summary, details)) 474 } 475 476 // token returns the token for this host as configured in the credentials 477 // section of the CLI Config File. If no token was configured, an empty 478 // string will be returned instead. 479 func (b *Remote) token() (string, error) { 480 hostname, err := svchost.ForComparison(b.hostname) 481 if err != nil { 482 return "", err 483 } 484 creds, err := b.services.CredentialsForHost(hostname) 485 if err != nil { 486 log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", b.hostname, err) 487 return "", nil 488 } 489 if creds != nil { 490 return creds.Token(), nil 491 } 492 return "", nil 493 } 494 495 // retryLogHook is invoked each time a request is retried allowing the 496 // backend to log any connection issues to prevent data loss. 497 func (b *Remote) retryLogHook(attemptNum int, resp *http.Response) { 498 if b.CLI != nil { 499 // Ignore the first retry to make sure any delayed output will 500 // be written to the console before we start logging retries. 501 // 502 // The retry logic in the TFE client will retry both rate limited 503 // requests and server errors, but in the remote backend we only 504 // care about server errors so we ignore rate limit (429) errors. 505 if attemptNum == 0 || (resp != nil && resp.StatusCode == 429) { 506 // Reset the last retry time. 507 b.lastRetry = time.Now() 508 return 509 } 510 511 if attemptNum == 1 { 512 b.CLI.Output(b.Colorize().Color(strings.TrimSpace(initialRetryError))) 513 } else { 514 b.CLI.Output(b.Colorize().Color(strings.TrimSpace( 515 fmt.Sprintf(repeatedRetryError, time.Since(b.lastRetry).Round(time.Second))))) 516 } 517 } 518 } 519 520 // Workspaces implements backend.Enhanced. 521 func (b *Remote) Workspaces() ([]string, error) { 522 if b.prefix == "" { 523 return nil, backend.ErrWorkspacesNotSupported 524 } 525 return b.workspaces() 526 } 527 528 // workspaces returns a filtered list of remote workspace names. 529 func (b *Remote) workspaces() ([]string, error) { 530 options := tfe.WorkspaceListOptions{} 531 switch { 532 case b.workspace != "": 533 options.Search = tfe.String(b.workspace) 534 case b.prefix != "": 535 options.Search = tfe.String(b.prefix) 536 } 537 538 // Create a slice to contain all the names. 539 var names []string 540 541 for { 542 wl, err := b.client.Workspaces.List(context.Background(), b.organization, options) 543 if err != nil { 544 return nil, err 545 } 546 547 for _, w := range wl.Items { 548 if b.workspace != "" && w.Name == b.workspace { 549 names = append(names, backend.DefaultStateName) 550 continue 551 } 552 if b.prefix != "" && strings.HasPrefix(w.Name, b.prefix) { 553 names = append(names, strings.TrimPrefix(w.Name, b.prefix)) 554 } 555 } 556 557 // Exit the loop when we've seen all pages. 558 if wl.CurrentPage >= wl.TotalPages { 559 break 560 } 561 562 // Update the page number to get the next page. 563 options.PageNumber = wl.NextPage 564 } 565 566 // Sort the result so we have consistent output. 567 sort.StringSlice(names).Sort() 568 569 return names, nil 570 } 571 572 // WorkspaceNamePattern provides an appropriate workspace renaming pattern for backend migration 573 // purposes (handled outside of this package), based on previous usage of this backend with the 574 // 'prefix' workspace functionality. As of this writing, see meta_backend.migrate.go 575 func (b *Remote) WorkspaceNamePattern() string { 576 if b.prefix != "" { 577 return b.prefix + "*" 578 } 579 580 return "" 581 } 582 583 // DeleteWorkspace implements backend.Enhanced. 584 func (b *Remote) DeleteWorkspace(name string) error { 585 if b.workspace == "" && name == backend.DefaultStateName { 586 return backend.ErrDefaultWorkspaceNotSupported 587 } 588 if b.prefix == "" && name != backend.DefaultStateName { 589 return backend.ErrWorkspacesNotSupported 590 } 591 592 // Configure the remote workspace name. 593 switch { 594 case name == backend.DefaultStateName: 595 name = b.workspace 596 case b.prefix != "" && !strings.HasPrefix(name, b.prefix): 597 name = b.prefix + name 598 } 599 600 client := &remoteClient{ 601 client: b.client, 602 organization: b.organization, 603 workspace: &tfe.Workspace{ 604 Name: name, 605 }, 606 } 607 608 return client.Delete() 609 } 610 611 // StateMgr implements backend.Enhanced. 612 func (b *Remote) StateMgr(name string) (statemgr.Full, error) { 613 if b.workspace == "" && name == backend.DefaultStateName { 614 return nil, backend.ErrDefaultWorkspaceNotSupported 615 } 616 if b.prefix == "" && name != backend.DefaultStateName { 617 return nil, backend.ErrWorkspacesNotSupported 618 } 619 620 // Configure the remote workspace name. 621 switch { 622 case name == backend.DefaultStateName: 623 name = b.workspace 624 case b.prefix != "" && !strings.HasPrefix(name, b.prefix): 625 name = b.prefix + name 626 } 627 628 workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, name) 629 if err != nil && err != tfe.ErrResourceNotFound { 630 return nil, fmt.Errorf("Failed to retrieve workspace %s: %v", name, err) 631 } 632 633 if err == tfe.ErrResourceNotFound { 634 options := tfe.WorkspaceCreateOptions{ 635 Name: tfe.String(name), 636 } 637 638 // We only set the Terraform Version for the new workspace if this is 639 // a release candidate or a final release. 640 if tfversion.Prerelease == "" || strings.HasPrefix(tfversion.Prerelease, "rc") { 641 options.TerraformVersion = tfe.String(tfversion.String()) 642 } 643 644 workspace, err = b.client.Workspaces.Create(context.Background(), b.organization, options) 645 if err != nil { 646 return nil, fmt.Errorf("Error creating workspace %s: %v", name, err) 647 } 648 } 649 650 // This is a fallback error check. Most code paths should use other 651 // mechanisms to check the version, then set the ignoreVersionConflict 652 // field to true. This check is only in place to ensure that we don't 653 // accidentally upgrade state with a new code path, and the version check 654 // logic is coarser and simpler. 655 if !b.ignoreVersionConflict { 656 wsv := workspace.TerraformVersion 657 // Explicitly ignore the pseudo-version "latest" here, as it will cause 658 // plan and apply to always fail. 659 if wsv != tfversion.String() && wsv != "latest" { 660 return nil, fmt.Errorf("Remote workspace Terraform version %q does not match local Terraform version %q", workspace.TerraformVersion, tfversion.String()) 661 } 662 } 663 664 client := &remoteClient{ 665 client: b.client, 666 organization: b.organization, 667 workspace: workspace, 668 669 // This is optionally set during Terraform Enterprise runs. 670 runID: os.Getenv("TFE_RUN_ID"), 671 } 672 673 return &remote.State{Client: client}, nil 674 } 675 676 // Operation implements backend.Enhanced. 677 func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) { 678 // Get the remote workspace name. 679 name := op.Workspace 680 switch { 681 case op.Workspace == backend.DefaultStateName: 682 name = b.workspace 683 case b.prefix != "" && !strings.HasPrefix(op.Workspace, b.prefix): 684 name = b.prefix + op.Workspace 685 } 686 687 // Retrieve the workspace for this operation. 688 w, err := b.client.Workspaces.Read(ctx, b.organization, name) 689 if err != nil { 690 switch err { 691 case context.Canceled: 692 return nil, err 693 case tfe.ErrResourceNotFound: 694 return nil, fmt.Errorf( 695 "workspace %s not found\n\n"+ 696 "The configured \"remote\" backend returns '404 Not Found' errors for resources\n"+ 697 "that do not exist, as well as for resources that a user doesn't have access\n"+ 698 "to. If the resource does exist, please check the rights for the used token.", 699 name, 700 ) 701 default: 702 return nil, fmt.Errorf( 703 "The configured \"remote\" backend encountered an unexpected error:\n\n%s", 704 err, 705 ) 706 } 707 } 708 709 // Terraform remote version conflicts are not a concern for operations. We 710 // are in one of three states: 711 // 712 // - Running remotely, in which case the local version is irrelevant; 713 // - Workspace configured for local operations, in which case the remote 714 // version is meaningless; 715 // - Forcing local operations with a remote backend, which should only 716 // happen in the Terraform Cloud worker, in which case the Terraform 717 // versions by definition match. 718 b.IgnoreVersionConflict() 719 720 // Check if we need to use the local backend to run the operation. 721 if b.forceLocal || !w.Operations { 722 // Record that we're forced to run operations locally to allow the 723 // command package UI to operate correctly 724 b.forceLocal = true 725 log.Printf("[DEBUG] Remote backend is delegating %s to the local backend", op.Type) 726 return b.local.Operation(ctx, op) 727 } 728 729 // Set the remote workspace name. 730 op.Workspace = w.Name 731 732 // Determine the function to call for our operation 733 var f func(context.Context, context.Context, *backend.Operation, *tfe.Workspace) (*tfe.Run, error) 734 switch op.Type { 735 case backend.OperationTypePlan: 736 f = b.opPlan 737 case backend.OperationTypeApply: 738 f = b.opApply 739 case backend.OperationTypeRefresh: 740 return nil, fmt.Errorf( 741 "\n\nThe \"refresh\" operation is not supported when using the \"remote\" backend. " + 742 "Use \"terraform apply -refresh-only\" instead.") 743 default: 744 return nil, fmt.Errorf( 745 "\n\nThe \"remote\" backend does not support the %q operation.", op.Type) 746 } 747 748 // Lock 749 b.opLock.Lock() 750 751 // Build our running operation 752 // the runninCtx is only used to block until the operation returns. 753 runningCtx, done := context.WithCancel(context.Background()) 754 runningOp := &backend.RunningOperation{ 755 Context: runningCtx, 756 PlanEmpty: true, 757 } 758 759 // stopCtx wraps the context passed in, and is used to signal a graceful Stop. 760 stopCtx, stop := context.WithCancel(ctx) 761 runningOp.Stop = stop 762 763 // cancelCtx is used to cancel the operation immediately, usually 764 // indicating that the process is exiting. 765 cancelCtx, cancel := context.WithCancel(context.Background()) 766 runningOp.Cancel = cancel 767 768 // Do it. 769 go func() { 770 defer logging.PanicHandler() 771 defer done() 772 defer stop() 773 defer cancel() 774 775 defer b.opLock.Unlock() 776 777 r, opErr := f(stopCtx, cancelCtx, op, w) 778 if opErr != nil && opErr != context.Canceled { 779 var diags tfdiags.Diagnostics 780 diags = diags.Append(opErr) 781 op.ReportResult(runningOp, diags) 782 return 783 } 784 785 if r == nil && opErr == context.Canceled { 786 runningOp.Result = backend.OperationFailure 787 return 788 } 789 790 if r != nil { 791 // Retrieve the run to get its current status. 792 r, err := b.client.Runs.Read(cancelCtx, r.ID) 793 if err != nil { 794 var diags tfdiags.Diagnostics 795 diags = diags.Append(generalError("Failed to retrieve run", err)) 796 op.ReportResult(runningOp, diags) 797 return 798 } 799 800 // Record if there are any changes. 801 runningOp.PlanEmpty = !r.HasChanges 802 803 if opErr == context.Canceled { 804 if err := b.cancel(cancelCtx, op, r); err != nil { 805 var diags tfdiags.Diagnostics 806 diags = diags.Append(generalError("Failed to retrieve run", err)) 807 op.ReportResult(runningOp, diags) 808 return 809 } 810 } 811 812 if r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored { 813 runningOp.Result = backend.OperationFailure 814 } 815 } 816 }() 817 818 // Return the running operation. 819 return runningOp, nil 820 } 821 822 func (b *Remote) cancel(cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { 823 if r.Actions.IsCancelable { 824 // Only ask if the remote operation should be canceled 825 // if the auto approve flag is not set. 826 if !op.AutoApprove { 827 v, err := op.UIIn.Input(cancelCtx, &terraform.InputOpts{ 828 Id: "cancel", 829 Query: "\nDo you want to cancel the remote operation?", 830 Description: "Only 'yes' will be accepted to cancel.", 831 }) 832 if err != nil { 833 return generalError("Failed asking to cancel", err) 834 } 835 if v != "yes" { 836 if b.CLI != nil { 837 b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationNotCanceled))) 838 } 839 return nil 840 } 841 } else { 842 if b.CLI != nil { 843 // Insert a blank line to separate the ouputs. 844 b.CLI.Output("") 845 } 846 } 847 848 // Try to cancel the remote operation. 849 err := b.client.Runs.Cancel(cancelCtx, r.ID, tfe.RunCancelOptions{}) 850 if err != nil { 851 return generalError("Failed to cancel run", err) 852 } 853 if b.CLI != nil { 854 b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationCanceled))) 855 } 856 } 857 858 return nil 859 } 860 861 // IgnoreVersionConflict allows commands to disable the fall-back check that 862 // the local Terraform version matches the remote workspace's configured 863 // Terraform version. This should be called by commands where this check is 864 // unnecessary, such as those performing remote operations, or read-only 865 // operations. It will also be called if the user uses a command-line flag to 866 // override this check. 867 func (b *Remote) IgnoreVersionConflict() { 868 b.ignoreVersionConflict = true 869 } 870 871 // VerifyWorkspaceTerraformVersion compares the local Terraform version against 872 // the workspace's configured Terraform version. If they are equal, this means 873 // that there are no compatibility concerns, so it returns no diagnostics. 874 // 875 // If the versions differ, 876 func (b *Remote) VerifyWorkspaceTerraformVersion(workspaceName string) tfdiags.Diagnostics { 877 var diags tfdiags.Diagnostics 878 879 workspace, err := b.getRemoteWorkspace(context.Background(), workspaceName) 880 if err != nil { 881 // If the workspace doesn't exist, there can be no compatibility 882 // problem, so we can return. This is most likely to happen when 883 // migrating state from a local backend to a new workspace. 884 if err == tfe.ErrResourceNotFound { 885 return nil 886 } 887 888 diags = diags.Append(tfdiags.Sourceless( 889 tfdiags.Error, 890 "Error looking up workspace", 891 fmt.Sprintf("Workspace read failed: %s", err), 892 )) 893 return diags 894 } 895 896 // If the workspace has the pseudo-version "latest", all bets are off. We 897 // cannot reasonably determine what the intended Terraform version is, so 898 // we'll skip version verification. 899 if workspace.TerraformVersion == "latest" { 900 return nil 901 } 902 903 // If the workspace has remote operations disabled, the remote Terraform 904 // version is effectively meaningless, so we'll skip version verification. 905 if !workspace.Operations { 906 return nil 907 } 908 909 remoteVersion, err := version.NewSemver(workspace.TerraformVersion) 910 if err != nil { 911 diags = diags.Append(tfdiags.Sourceless( 912 tfdiags.Error, 913 "Error looking up workspace", 914 fmt.Sprintf("Invalid Terraform version: %s", err), 915 )) 916 return diags 917 } 918 919 v014 := version.Must(version.NewSemver("0.14.0")) 920 if tfversion.SemVer.LessThan(v014) || remoteVersion.LessThan(v014) { 921 // Versions of Terraform prior to 0.14.0 will refuse to load state files 922 // written by a newer version of Terraform, even if it is only a patch 923 // level difference. As a result we require an exact match. 924 if tfversion.SemVer.Equal(remoteVersion) { 925 return diags 926 } 927 } 928 if tfversion.SemVer.GreaterThanOrEqual(v014) && remoteVersion.GreaterThanOrEqual(v014) { 929 // Versions of Terraform after 0.14.0 should be compatible with each 930 // other. At the time this code was written, the only constraints we 931 // are aware of are: 932 // 933 // - 0.14.0 is guaranteed to be compatible with versions up to but not 934 // including 1.2.0 935 v120 := version.Must(version.NewSemver("1.2.0")) 936 if tfversion.SemVer.LessThan(v120) && remoteVersion.LessThan(v120) { 937 return diags 938 } 939 // - Any new Terraform state version will require at least minor patch 940 // increment, so x.y.* will always be compatible with each other 941 tfvs := tfversion.SemVer.Segments64() 942 rwvs := remoteVersion.Segments64() 943 if len(tfvs) == 3 && len(rwvs) == 3 && tfvs[0] == rwvs[0] && tfvs[1] == rwvs[1] { 944 return diags 945 } 946 } 947 948 // Even if ignoring version conflicts, it may still be useful to call this 949 // method and warn the user about a mismatch between the local and remote 950 // Terraform versions. 951 severity := tfdiags.Error 952 if b.ignoreVersionConflict { 953 severity = tfdiags.Warning 954 } 955 956 suggestion := " If you're sure you want to upgrade the state, you can force Terraform to continue using the -ignore-remote-version flag. This may result in an unusable workspace." 957 if b.ignoreVersionConflict { 958 suggestion = "" 959 } 960 diags = diags.Append(tfdiags.Sourceless( 961 severity, 962 "Terraform version mismatch", 963 fmt.Sprintf( 964 "The local Terraform version (%s) does not match the configured version for remote workspace %s/%s (%s).%s", 965 tfversion.String(), 966 b.organization, 967 workspace.Name, 968 workspace.TerraformVersion, 969 suggestion, 970 ), 971 )) 972 973 return diags 974 } 975 976 func (b *Remote) IsLocalOperations() bool { 977 return b.forceLocal 978 } 979 980 func generalError(msg string, err error) error { 981 var diags tfdiags.Diagnostics 982 983 if urlErr, ok := err.(*url.Error); ok { 984 err = urlErr.Err 985 } 986 987 switch err { 988 case context.Canceled: 989 return err 990 case tfe.ErrResourceNotFound: 991 diags = diags.Append(tfdiags.Sourceless( 992 tfdiags.Error, 993 fmt.Sprintf("%s: %v", msg, err), 994 `The configured "remote" backend returns '404 Not Found' errors for resources `+ 995 `that do not exist, as well as for resources that a user doesn't have access `+ 996 `to. If the resource does exist, please check the rights for the used token.`, 997 )) 998 return diags.Err() 999 default: 1000 diags = diags.Append(tfdiags.Sourceless( 1001 tfdiags.Error, 1002 fmt.Sprintf("%s: %v", msg, err), 1003 `The configured "remote" backend encountered an unexpected error. Sometimes `+ 1004 `this is caused by network connection problems, in which case you could retry `+ 1005 `the command. If the issue persists please open a support ticket to get help `+ 1006 `resolving the problem.`, 1007 )) 1008 return diags.Err() 1009 } 1010 } 1011 1012 func checkConstraintsWarning(err error) tfdiags.Diagnostic { 1013 return tfdiags.Sourceless( 1014 tfdiags.Warning, 1015 fmt.Sprintf("Failed to check version constraints: %v", err), 1016 "Checking version constraints is considered optional, but this is an"+ 1017 "unexpected error which should be reported.", 1018 ) 1019 } 1020 1021 // The newline in this error is to make it look good in the CLI! 1022 const initialRetryError = ` 1023 [reset][yellow]There was an error connecting to the remote backend. Please do not exit 1024 Terraform to prevent data loss! Trying to restore the connection... 1025 [reset] 1026 ` 1027 1028 const repeatedRetryError = ` 1029 [reset][yellow]Still trying to restore the connection... (%s elapsed)[reset] 1030 ` 1031 1032 const operationCanceled = ` 1033 [reset][red]The remote operation was successfully cancelled.[reset] 1034 ` 1035 1036 const operationNotCanceled = ` 1037 [reset][red]The remote operation was not cancelled.[reset] 1038 ` 1039 1040 var schemaDescriptions = map[string]string{ 1041 "hostname": "The remote backend hostname to connect to (defaults to app.terraform.io).", 1042 "organization": "The name of the organization containing the targeted workspace(s).", 1043 "token": "The token used to authenticate with the remote backend. If credentials for the\n" + 1044 "host are configured in the CLI Config File, then those will be used instead.", 1045 "name": "A workspace name used to map the default workspace to a named remote workspace.\n" + 1046 "When configured only the default workspace can be used. This option conflicts\n" + 1047 "with \"prefix\"", 1048 "prefix": "A prefix used to filter workspaces using a single configuration. New workspaces\n" + 1049 "will automatically be prefixed with this prefix. If omitted only the default\n" + 1050 "workspace can be used. This option conflicts with \"name\"", 1051 }