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