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