github.com/svenhamers/terraform@v0.11.12-beta1/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 14 tfe "github.com/hashicorp/go-tfe" 15 version "github.com/hashicorp/go-version" 16 "github.com/hashicorp/terraform/backend" 17 "github.com/hashicorp/terraform/helper/schema" 18 "github.com/hashicorp/terraform/state" 19 "github.com/hashicorp/terraform/state/remote" 20 "github.com/hashicorp/terraform/svchost" 21 "github.com/hashicorp/terraform/svchost/disco" 22 "github.com/hashicorp/terraform/terraform" 23 tfversion "github.com/hashicorp/terraform/version" 24 "github.com/mitchellh/cli" 25 "github.com/mitchellh/colorstring" 26 27 backendLocal "github.com/hashicorp/terraform/backend/local" 28 ) 29 30 const ( 31 defaultHostname = "app.terraform.io" 32 defaultModuleDepth = -1 33 defaultParallelism = 10 34 tfeServiceID = "tfe.v2.1" 35 ) 36 37 // Remote is an implementation of EnhancedBackend that performs all 38 // operations in a remote backend. 39 type Remote struct { 40 // CLI and Colorize control the CLI output. If CLI is nil then no CLI 41 // output will be done. If CLIColor is nil then no coloring will be done. 42 CLI cli.Ui 43 CLIColor *colorstring.Colorize 44 45 // ContextOpts are the base context options to set when initializing a 46 // new Terraform context. Many of these will be overridden or merged by 47 // Operation. See Operation for more details. 48 ContextOpts *terraform.ContextOpts 49 50 // client is the remote backend API client 51 client *tfe.Client 52 53 // hostname of the remote backend server 54 hostname string 55 56 // organization is the organization that contains the target workspaces 57 organization string 58 59 // workspace is used to map the default workspace to a remote workspace 60 workspace string 61 62 // prefix is used to filter down a set of workspaces that use a single 63 // configuration 64 prefix string 65 66 // schema defines the configuration for the backend 67 schema *schema.Backend 68 69 // services is used for service discovery 70 services *disco.Disco 71 72 // local, if non-nil, will be used for all enhanced behavior. This 73 // allows local behavior with the remote backend functioning as remote 74 // state storage backend. 75 local backend.Enhanced 76 77 // forceLocal, if true, will force the use of the local backend. 78 forceLocal bool 79 80 // opLock locks operations 81 opLock sync.Mutex 82 } 83 84 // New creates a new initialized remote backend. 85 func New(services *disco.Disco) *Remote { 86 b := &Remote{ 87 services: services, 88 } 89 90 b.schema = &schema.Backend{ 91 Schema: map[string]*schema.Schema{ 92 "hostname": &schema.Schema{ 93 Type: schema.TypeString, 94 Optional: true, 95 Description: schemaDescriptions["hostname"], 96 Default: defaultHostname, 97 }, 98 99 "organization": &schema.Schema{ 100 Type: schema.TypeString, 101 Required: true, 102 Description: schemaDescriptions["organization"], 103 }, 104 105 "token": &schema.Schema{ 106 Type: schema.TypeString, 107 Optional: true, 108 Description: schemaDescriptions["token"], 109 }, 110 111 "workspaces": &schema.Schema{ 112 Type: schema.TypeSet, 113 Required: true, 114 Description: schemaDescriptions["workspaces"], 115 MinItems: 1, 116 MaxItems: 1, 117 Elem: &schema.Resource{ 118 Schema: map[string]*schema.Schema{ 119 "name": &schema.Schema{ 120 Type: schema.TypeString, 121 Optional: true, 122 Description: schemaDescriptions["name"], 123 }, 124 125 "prefix": &schema.Schema{ 126 Type: schema.TypeString, 127 Optional: true, 128 Description: schemaDescriptions["prefix"], 129 }, 130 }, 131 }, 132 }, 133 }, 134 135 ConfigureFunc: b.configure, 136 } 137 138 return b 139 } 140 141 func (b *Remote) configure(ctx context.Context) error { 142 d := schema.FromContextBackendConfig(ctx) 143 144 // Get the hostname and organization. 145 b.hostname = d.Get("hostname").(string) 146 b.organization = d.Get("organization").(string) 147 148 // Get and assert the workspaces configuration block. 149 workspace := d.Get("workspaces").(*schema.Set).List()[0].(map[string]interface{}) 150 151 // Get the default workspace name and prefix. 152 b.workspace = workspace["name"].(string) 153 b.prefix = workspace["prefix"].(string) 154 155 // Make sure that we have either a workspace name or a prefix. 156 if b.workspace == "" && b.prefix == "" { 157 return fmt.Errorf("either workspace 'name' or 'prefix' is required") 158 } 159 160 // Make sure that only one of workspace name or a prefix is configured. 161 if b.workspace != "" && b.prefix != "" { 162 return fmt.Errorf("only one of workspace 'name' or 'prefix' is allowed") 163 } 164 165 // Discover the service URL for this host to confirm that it provides 166 // a remote backend API and to get the version constraints. 167 service, constraints, err := b.discover() 168 169 // First check any contraints we might have received.<Paste> 170 if constraints != nil { 171 if err := b.checkConstraints(constraints); err != nil { 172 return err 173 } 174 } 175 176 // When we don't have any constraints errors, also check for discovery 177 // errors before we continue. 178 if err != nil { 179 return err 180 } 181 182 // Retrieve the token for this host as configured in the credentials 183 // section of the CLI Config File. 184 token, err := b.token() 185 if err != nil { 186 return err 187 } 188 189 // Get the token from the config if no token was configured for this 190 // host in credentials section of the CLI Config File. 191 if token == "" { 192 token = d.Get("token").(string) 193 } 194 195 // Return an error if we still don't have a token at this point. 196 if token == "" { 197 return fmt.Errorf("required token could not be found") 198 } 199 200 cfg := &tfe.Config{ 201 Address: service.String(), 202 BasePath: service.Path, 203 Token: token, 204 Headers: make(http.Header), 205 } 206 207 // Set the version header to the current version. 208 cfg.Headers.Set(tfversion.Header, tfversion.Version) 209 210 // Create the remote backend API client. 211 b.client, err = tfe.NewClient(cfg) 212 if err != nil { 213 return err 214 } 215 216 // Check if the organization exists by reading its entitlements. 217 entitlements, err := b.client.Organizations.Entitlements(context.Background(), b.organization) 218 if err != nil { 219 if err == tfe.ErrResourceNotFound { 220 return fmt.Errorf("organization %s does not exist", b.organization) 221 } 222 return fmt.Errorf("failed to read organization entitlements: %v", err) 223 } 224 225 // Configure a local backend for when we need to run operations locally. 226 b.local = backendLocal.NewWithBackend(b) 227 b.forceLocal = !entitlements.Operations || os.Getenv("TF_FORCE_LOCAL_BACKEND") != "" 228 229 return nil 230 } 231 232 // discover the remote backend API service URL and version constraints. 233 func (b *Remote) discover() (*url.URL, *disco.Constraints, error) { 234 hostname, err := svchost.ForComparison(b.hostname) 235 if err != nil { 236 return nil, nil, err 237 } 238 239 host, err := b.services.Discover(hostname) 240 if err != nil { 241 return nil, nil, err 242 } 243 244 service, err := host.ServiceURL(tfeServiceID) 245 // Return the error, unless its a disco.ErrVersionNotSupported error. 246 if _, ok := err.(*disco.ErrVersionNotSupported); !ok && err != nil { 247 return nil, nil, err 248 } 249 250 // Return early if we are a development build. 251 if tfversion.Prerelease == "dev" { 252 return service, nil, err 253 } 254 255 // We purposefully ignore the error and return the previous error, as 256 // checking for version constraints is considered optional. 257 constraints, _ := host.VersionConstraints(tfeServiceID, "terraform") 258 259 return service, constraints, err 260 } 261 262 // checkConstraints checks service version constrains against our own 263 // version and returns rich and informational diagnostics in case any 264 // incompatibilities are detected. 265 func (b *Remote) checkConstraints(c *disco.Constraints) error { 266 if c == nil || c.Minimum == "" || c.Maximum == "" { 267 return nil 268 } 269 270 // Generate a parsable constraints string. 271 excluding := "" 272 if len(c.Excluding) > 0 { 273 excluding = fmt.Sprintf(", != %s", strings.Join(c.Excluding, ", != ")) 274 } 275 constStr := fmt.Sprintf(">= %s%s, <= %s", c.Minimum, excluding, c.Maximum) 276 277 // Create the constraints to check against. 278 constraints, err := version.NewConstraint(constStr) 279 if err != nil { 280 return checkConstraintsWarning(err) 281 } 282 283 // Create the version to check. 284 v, err := version.NewVersion(tfversion.Version) 285 if err != nil { 286 return checkConstraintsWarning(err) 287 } 288 289 // Return if we satisfy all constraints. 290 if constraints.Check(v) { 291 return nil 292 } 293 294 // Find out what action (upgrade/downgrade) we should advice. 295 minimum, err := version.NewVersion(c.Minimum) 296 if err != nil { 297 return checkConstraintsWarning(err) 298 } 299 300 maximum, err := version.NewVersion(c.Maximum) 301 if err != nil { 302 return checkConstraintsWarning(err) 303 } 304 305 var excludes []*version.Version 306 for _, exclude := range c.Excluding { 307 v, err := version.NewVersion(exclude) 308 if err != nil { 309 return checkConstraintsWarning(err) 310 } 311 excludes = append(excludes, v) 312 } 313 314 // Sort all the excludes. 315 sort.Sort(version.Collection(excludes)) 316 317 var action, toVersion string 318 switch { 319 case minimum.GreaterThan(v): 320 action = "upgrade" 321 toVersion = ">= " + minimum.String() 322 case maximum.LessThan(v): 323 action = "downgrade" 324 toVersion = "<= " + maximum.String() 325 case len(excludes) > 0: 326 // Get the latest excluded version. 327 action = "upgrade" 328 toVersion = "> " + excludes[len(excludes)-1].String() 329 } 330 331 switch { 332 case len(excludes) == 1: 333 excluding = fmt.Sprintf(", excluding version %s", excludes[0].String()) 334 case len(excludes) > 1: 335 var vs []string 336 for _, v := range excludes { 337 vs = append(vs, v.String()) 338 } 339 excluding = fmt.Sprintf(", excluding versions %s", strings.Join(vs, ", ")) 340 default: 341 excluding = "" 342 } 343 344 summary := fmt.Sprintf("Incompatible Terraform version v%s", v.String()) 345 details := fmt.Sprintf( 346 "The configured Terraform Enterprise backend is compatible with Terraform\n"+ 347 "versions >= %s, <= %s%s.", c.Minimum, c.Maximum, excluding, 348 ) 349 350 if action != "" && toVersion != "" { 351 summary = fmt.Sprintf("Please %s Terraform to %s", action, toVersion) 352 } 353 354 // Return the customized and informational error message. 355 return fmt.Errorf("%s\n\n%s", summary, details) 356 } 357 358 // token returns the token for this host as configured in the credentials 359 // section of the CLI Config File. If no token was configured, an empty 360 // string will be returned instead. 361 func (b *Remote) token() (string, error) { 362 hostname, err := svchost.ForComparison(b.hostname) 363 if err != nil { 364 return "", err 365 } 366 creds, err := b.services.CredentialsForHost(hostname) 367 if err != nil { 368 log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", b.hostname, err) 369 return "", nil 370 } 371 if creds != nil { 372 return creds.Token(), nil 373 } 374 return "", nil 375 } 376 377 // Input is called to ask the user for input for completing the configuration. 378 func (b *Remote) Input(ui terraform.UIInput, c *terraform.ResourceConfig) (*terraform.ResourceConfig, error) { 379 return b.schema.Input(ui, c) 380 } 381 382 // Validate is called once at the beginning with the raw configuration and 383 // can return a list of warnings and/or errors. 384 func (b *Remote) Validate(c *terraform.ResourceConfig) ([]string, []error) { 385 return b.schema.Validate(c) 386 } 387 388 // Configure configures the backend itself with the configuration given. 389 func (b *Remote) Configure(c *terraform.ResourceConfig) error { 390 return b.schema.Configure(c) 391 } 392 393 // State returns the latest state of the given remote workspace. The workspace 394 // will be created if it doesn't exist. 395 func (b *Remote) State(name string) (state.State, error) { 396 if b.workspace == "" && name == backend.DefaultStateName { 397 return nil, backend.ErrDefaultStateNotSupported 398 } 399 if b.prefix == "" && name != backend.DefaultStateName { 400 return nil, backend.ErrNamedStatesNotSupported 401 } 402 403 // Configure the remote workspace name. 404 switch { 405 case name == backend.DefaultStateName: 406 name = b.workspace 407 case b.prefix != "" && !strings.HasPrefix(name, b.prefix): 408 name = b.prefix + name 409 } 410 411 workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, name) 412 if err != nil && err != tfe.ErrResourceNotFound { 413 return nil, fmt.Errorf("Failed to retrieve workspace %s: %v", name, err) 414 } 415 416 if err == tfe.ErrResourceNotFound { 417 options := tfe.WorkspaceCreateOptions{ 418 Name: tfe.String(name), 419 } 420 421 // We only set the Terraform Version for the new workspace if this is 422 // a release candidate or a final release. 423 if tfversion.Prerelease == "" || strings.HasPrefix(tfversion.Prerelease, "rc") { 424 options.TerraformVersion = tfe.String(tfversion.String()) 425 } 426 427 workspace, err = b.client.Workspaces.Create(context.Background(), b.organization, options) 428 if err != nil { 429 return nil, fmt.Errorf("Error creating workspace %s: %v", name, err) 430 } 431 } 432 433 client := &remoteClient{ 434 client: b.client, 435 organization: b.organization, 436 workspace: workspace, 437 438 // This is optionally set during Terraform Enterprise runs. 439 runID: os.Getenv("TFE_RUN_ID"), 440 } 441 442 return &remote.State{Client: client}, nil 443 } 444 445 // DeleteState removes the remote workspace if it exists. 446 func (b *Remote) DeleteState(name string) error { 447 if b.workspace == "" && name == backend.DefaultStateName { 448 return backend.ErrDefaultStateNotSupported 449 } 450 if b.prefix == "" && name != backend.DefaultStateName { 451 return backend.ErrNamedStatesNotSupported 452 } 453 454 // Configure the remote workspace name. 455 switch { 456 case name == backend.DefaultStateName: 457 name = b.workspace 458 case b.prefix != "" && !strings.HasPrefix(name, b.prefix): 459 name = b.prefix + name 460 } 461 462 client := &remoteClient{ 463 client: b.client, 464 organization: b.organization, 465 workspace: &tfe.Workspace{ 466 Name: name, 467 }, 468 } 469 470 return client.Delete() 471 } 472 473 // States returns a filtered list of remote workspace names. 474 func (b *Remote) States() ([]string, error) { 475 if b.prefix == "" { 476 return nil, backend.ErrNamedStatesNotSupported 477 } 478 return b.states() 479 } 480 481 func (b *Remote) states() ([]string, error) { 482 options := tfe.WorkspaceListOptions{} 483 switch { 484 case b.workspace != "": 485 options.Search = tfe.String(b.workspace) 486 case b.prefix != "": 487 options.Search = tfe.String(b.prefix) 488 } 489 490 // Create a slice to contain all the names. 491 var names []string 492 493 for { 494 wl, err := b.client.Workspaces.List(context.Background(), b.organization, options) 495 if err != nil { 496 return nil, err 497 } 498 499 for _, w := range wl.Items { 500 if b.workspace != "" && w.Name == b.workspace { 501 names = append(names, backend.DefaultStateName) 502 continue 503 } 504 if b.prefix != "" && strings.HasPrefix(w.Name, b.prefix) { 505 names = append(names, strings.TrimPrefix(w.Name, b.prefix)) 506 } 507 } 508 509 // Exit the loop when we've seen all pages. 510 if wl.CurrentPage >= wl.TotalPages { 511 break 512 } 513 514 // Update the page number to get the next page. 515 options.PageNumber = wl.NextPage 516 } 517 518 // Sort the result so we have consistent output. 519 sort.StringSlice(names).Sort() 520 521 return names, nil 522 } 523 524 // Operation implements backend.Enhanced 525 func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) { 526 // Configure the remote workspace name. 527 name := op.Workspace 528 switch { 529 case op.Workspace == backend.DefaultStateName: 530 name = b.workspace 531 case b.prefix != "" && !strings.HasPrefix(op.Workspace, b.prefix): 532 name = b.prefix + op.Workspace 533 } 534 535 // Retrieve the workspace for this operation. 536 w, err := b.client.Workspaces.Read(ctx, b.organization, name) 537 if err != nil { 538 switch err { 539 case context.Canceled: 540 return nil, err 541 case tfe.ErrResourceNotFound: 542 return nil, fmt.Errorf( 543 "workspace %s not found\n\n"+ 544 "The configured \"remote\" backend returns '404 Not Found' errors for resources\n"+ 545 "that do not exist, as well as for resources that a user doesn't have access\n"+ 546 "to. If the resource does exists, please check the rights for the used token.", 547 name, 548 ) 549 default: 550 return nil, fmt.Errorf( 551 "%s\n\n"+ 552 "The configured \"remote\" backend encountered an unexpected error. Sometimes\n"+ 553 "this is caused by network connection problems, in which case you could retry\n"+ 554 "the command. If the issue persists please open a support ticket to get help\n"+ 555 "resolving the problem.", 556 err, 557 ) 558 } 559 } 560 561 // Check if we need to use the local backend to run the operation. 562 if b.forceLocal || !w.Operations { 563 return b.local.Operation(ctx, op) 564 } 565 566 // Set the remote workspace name. 567 op.Workspace = w.Name 568 569 // Determine the function to call for our operation 570 var f func(context.Context, context.Context, *backend.Operation, *tfe.Workspace) (*tfe.Run, error) 571 switch op.Type { 572 case backend.OperationTypePlan: 573 f = b.opPlan 574 case backend.OperationTypeApply: 575 f = b.opApply 576 default: 577 return nil, fmt.Errorf( 578 "\n\nThe \"remote\" backend does not support the %q operation.", op.Type) 579 } 580 581 // Lock 582 b.opLock.Lock() 583 584 // Build our running operation 585 // the runninCtx is only used to block until the operation returns. 586 runningCtx, done := context.WithCancel(context.Background()) 587 runningOp := &backend.RunningOperation{ 588 Context: runningCtx, 589 PlanEmpty: true, 590 } 591 592 // stopCtx wraps the context passed in, and is used to signal a graceful Stop. 593 stopCtx, stop := context.WithCancel(ctx) 594 runningOp.Stop = stop 595 596 // cancelCtx is used to cancel the operation immediately, usually 597 // indicating that the process is exiting. 598 cancelCtx, cancel := context.WithCancel(context.Background()) 599 runningOp.Cancel = cancel 600 601 // Do it. 602 go func() { 603 defer done() 604 defer stop() 605 defer cancel() 606 607 defer b.opLock.Unlock() 608 609 r, opErr := f(stopCtx, cancelCtx, op, w) 610 if opErr != nil && opErr != context.Canceled { 611 runningOp.Err = opErr 612 return 613 } 614 615 if r != nil { 616 // Retrieve the run to get its current status. 617 r, err := b.client.Runs.Read(cancelCtx, r.ID) 618 if err != nil { 619 runningOp.Err = generalError("error retrieving run", err) 620 return 621 } 622 623 // Record if there are any changes. 624 runningOp.PlanEmpty = !r.HasChanges 625 626 if opErr == context.Canceled { 627 runningOp.Err = b.cancel(cancelCtx, op, r) 628 } 629 630 if runningOp.Err == nil && r.Status == tfe.RunErrored { 631 runningOp.ExitCode = 1 632 } 633 } 634 }() 635 636 // Return the running operation. 637 return runningOp, nil 638 } 639 640 func (b *Remote) cancel(cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { 641 if r.Status == tfe.RunPending && r.Actions.IsCancelable { 642 // Only ask if the remote operation should be canceled 643 // if the auto approve flag is not set. 644 if !op.AutoApprove { 645 v, err := op.UIIn.Input(&terraform.InputOpts{ 646 Id: "cancel", 647 Query: "\nDo you want to cancel the pending remote operation?", 648 Description: "Only 'yes' will be accepted to cancel.", 649 }) 650 if err != nil { 651 return generalError("error asking to cancel", err) 652 } 653 if v != "yes" { 654 if b.CLI != nil { 655 b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationNotCanceled))) 656 } 657 return nil 658 } 659 } else { 660 if b.CLI != nil { 661 // Insert a blank line to separate the ouputs. 662 b.CLI.Output("") 663 } 664 } 665 666 // Try to cancel the remote operation. 667 err := b.client.Runs.Cancel(cancelCtx, r.ID, tfe.RunCancelOptions{}) 668 if err != nil { 669 return generalError("error cancelling run", err) 670 } 671 if b.CLI != nil { 672 b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationCanceled))) 673 } 674 } 675 676 return nil 677 } 678 679 // Colorize returns the Colorize structure that can be used for colorizing 680 // output. This is guaranteed to always return a non-nil value and so useful 681 // as a helper to wrap any potentially colored strings. 682 // 683 // TODO SvH: Rename this back to Colorize as soon as we can pass -no-color. 684 func (b *Remote) cliColorize() *colorstring.Colorize { 685 if b.CLIColor != nil { 686 return b.CLIColor 687 } 688 689 return &colorstring.Colorize{ 690 Colors: colorstring.DefaultColors, 691 Disable: true, 692 } 693 } 694 695 func generalError(msg string, err error) error { 696 if urlErr, ok := err.(*url.Error); ok { 697 err = urlErr.Err 698 } 699 switch err { 700 case context.Canceled: 701 return err 702 case tfe.ErrResourceNotFound: 703 return fmt.Errorf(strings.TrimSpace(fmt.Sprintf(notFoundErr, msg, err))) 704 default: 705 return fmt.Errorf(strings.TrimSpace(fmt.Sprintf(generalErr, msg, err))) 706 } 707 } 708 709 func checkConstraintsWarning(err error) error { 710 return fmt.Errorf( 711 "Failed to check version constraints: %v\n\n"+ 712 "Checking version constraints is considered optional, but this is an\n"+ 713 "unexpected error which should be reported.", 714 err, 715 ) 716 } 717 718 const generalErr = ` 719 %s: %v 720 721 The configured "remote" backend encountered an unexpected error. Sometimes 722 this is caused by network connection problems, in which case you could retry 723 the command. If the issue persists please open a support ticket to get help 724 resolving the problem. 725 ` 726 727 const notFoundErr = ` 728 %s: %v 729 730 The configured "remote" backend returns '404 Not Found' errors for resources 731 that do not exist, as well as for resources that a user doesn't have access 732 to. When the resource does exists, please check the rights for the used token. 733 ` 734 735 const operationCanceled = ` 736 [reset][red]The remote operation was successfully cancelled.[reset] 737 ` 738 739 const operationNotCanceled = ` 740 [reset][red]The remote operation was not cancelled.[reset] 741 ` 742 743 var schemaDescriptions = map[string]string{ 744 "hostname": "The remote backend hostname to connect to (defaults to app.terraform.io).", 745 "organization": "The name of the organization containing the targeted workspace(s).", 746 "token": "The token used to authenticate with the remote backend. If credentials for the\n" + 747 "host are configured in the CLI Config File, then those will be used instead.", 748 "workspaces": "Workspaces contains arguments used to filter down to a set of workspaces\n" + 749 "to work on.", 750 "name": "A workspace name used to map the default workspace to a named remote workspace.\n" + 751 "When configured only the default workspace can be used. This option conflicts\n" + 752 "with \"prefix\"", 753 "prefix": "A prefix used to filter workspaces using a single configuration. New workspaces\n" + 754 "will automatically be prefixed with this prefix. If omitted only the default\n" + 755 "workspace can be used. This option conflicts with \"name\"", 756 }