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