github.com/opentofu/opentofu@v1.7.1/internal/cloud/backend_common.go (about)

     1  // Copyright (c) The OpenTofu Authors
     2  // SPDX-License-Identifier: MPL-2.0
     3  // Copyright (c) 2023 HashiCorp, Inc.
     4  // SPDX-License-Identifier: MPL-2.0
     5  
     6  package cloud
     7  
     8  import (
     9  	"bufio"
    10  	"bytes"
    11  	"context"
    12  	"encoding/json"
    13  	"errors"
    14  	"fmt"
    15  	"io"
    16  	"math"
    17  	"net/http"
    18  	"net/url"
    19  	"strconv"
    20  	"strings"
    21  	"time"
    22  
    23  	"github.com/hashicorp/go-retryablehttp"
    24  	tfe "github.com/hashicorp/go-tfe"
    25  	"github.com/hashicorp/jsonapi"
    26  	"github.com/opentofu/opentofu/internal/backend"
    27  	"github.com/opentofu/opentofu/internal/command/jsonformat"
    28  	"github.com/opentofu/opentofu/internal/logging"
    29  	"github.com/opentofu/opentofu/internal/plans"
    30  	"github.com/opentofu/opentofu/internal/tofu"
    31  )
    32  
    33  var (
    34  	backoffMin = 1000.0
    35  	backoffMax = 3000.0
    36  
    37  	runPollInterval = 3 * time.Second
    38  )
    39  
    40  // backoff will perform exponential backoff based on the iteration and
    41  // limited by the provided min and max (in milliseconds) durations.
    42  func backoff(min, max float64, iter int) time.Duration {
    43  	backoff := math.Pow(2, float64(iter)/5) * min
    44  	if backoff > max {
    45  		backoff = max
    46  	}
    47  	return time.Duration(backoff) * time.Millisecond
    48  }
    49  
    50  func (b *Cloud) waitForRun(stopCtx, cancelCtx context.Context, op *backend.Operation, opType string, r *tfe.Run, w *tfe.Workspace) (*tfe.Run, error) {
    51  	started := time.Now()
    52  	updated := started
    53  	for i := 0; ; i++ {
    54  		select {
    55  		case <-stopCtx.Done():
    56  			return r, stopCtx.Err()
    57  		case <-cancelCtx.Done():
    58  			return r, cancelCtx.Err()
    59  		case <-time.After(backoff(backoffMin, backoffMax, i)):
    60  			// Timer up, show status
    61  		}
    62  
    63  		// Retrieve the run to get its current status.
    64  		r, err := b.client.Runs.Read(stopCtx, r.ID)
    65  		if err != nil {
    66  			return r, generalError("Failed to retrieve run", err)
    67  		}
    68  
    69  		// Return if the run is no longer pending.
    70  		if r.Status != tfe.RunPending && r.Status != tfe.RunConfirmed {
    71  			if i == 0 && opType == "plan" && b.CLI != nil {
    72  				b.CLI.Output(b.Colorize().Color(fmt.Sprintf("Waiting for the %s to start...\n", opType)))
    73  			}
    74  			if i > 0 && b.CLI != nil {
    75  				// Insert a blank line to separate the ouputs.
    76  				b.CLI.Output("")
    77  			}
    78  			return r, nil
    79  		}
    80  
    81  		// Check if 30 seconds have passed since the last update.
    82  		current := time.Now()
    83  		if b.CLI != nil && (i == 0 || current.Sub(updated).Seconds() > 30) {
    84  			updated = current
    85  			position := 0
    86  			elapsed := ""
    87  
    88  			// Calculate and set the elapsed time.
    89  			if i > 0 {
    90  				elapsed = fmt.Sprintf(
    91  					" (%s elapsed)", current.Sub(started).Truncate(30*time.Second))
    92  			}
    93  
    94  			// Retrieve the workspace used to run this operation in.
    95  			w, err = b.client.Workspaces.Read(stopCtx, b.organization, w.Name)
    96  			if err != nil {
    97  				return nil, generalError("Failed to retrieve workspace", err)
    98  			}
    99  
   100  			// If the workspace is locked the run will not be queued and we can
   101  			// update the status without making any expensive calls.
   102  			if w.Locked && w.CurrentRun != nil {
   103  				cr, err := b.client.Runs.Read(stopCtx, w.CurrentRun.ID)
   104  				if err != nil {
   105  					return r, generalError("Failed to retrieve current run", err)
   106  				}
   107  				if cr.Status == tfe.RunPending {
   108  					b.CLI.Output(b.Colorize().Color(
   109  						"Waiting for the manually locked workspace to be unlocked..." + elapsed))
   110  					continue
   111  				}
   112  			}
   113  
   114  			// Skip checking the workspace queue when we are the current run.
   115  			if w.CurrentRun == nil || w.CurrentRun.ID != r.ID {
   116  				found := false
   117  				options := &tfe.RunListOptions{}
   118  			runlist:
   119  				for {
   120  					rl, err := b.client.Runs.List(stopCtx, w.ID, options)
   121  					if err != nil {
   122  						return r, generalError("Failed to retrieve run list", err)
   123  					}
   124  
   125  					// Loop through all runs to calculate the workspace queue position.
   126  					for _, item := range rl.Items {
   127  						if !found {
   128  							if r.ID == item.ID {
   129  								found = true
   130  							}
   131  							continue
   132  						}
   133  
   134  						// If the run is in a final state, ignore it and continue.
   135  						switch item.Status {
   136  						case tfe.RunApplied, tfe.RunCanceled, tfe.RunDiscarded, tfe.RunErrored:
   137  							continue
   138  						case tfe.RunPlanned:
   139  							if op.Type == backend.OperationTypePlan {
   140  								continue
   141  							}
   142  						}
   143  
   144  						// Increase the workspace queue position.
   145  						position++
   146  
   147  						// Stop searching when we reached the current run.
   148  						if w.CurrentRun != nil && w.CurrentRun.ID == item.ID {
   149  							break runlist
   150  						}
   151  					}
   152  
   153  					// Exit the loop when we've seen all pages.
   154  					if rl.CurrentPage >= rl.TotalPages {
   155  						break
   156  					}
   157  
   158  					// Update the page number to get the next page.
   159  					options.PageNumber = rl.NextPage
   160  				}
   161  
   162  				if position > 0 {
   163  					b.CLI.Output(b.Colorize().Color(fmt.Sprintf(
   164  						"Waiting for %d run(s) to finish before being queued...%s",
   165  						position,
   166  						elapsed,
   167  					)))
   168  					continue
   169  				}
   170  			}
   171  
   172  			options := tfe.ReadRunQueueOptions{}
   173  		search:
   174  			for {
   175  				rq, err := b.client.Organizations.ReadRunQueue(stopCtx, b.organization, options)
   176  				if err != nil {
   177  					return r, generalError("Failed to retrieve queue", err)
   178  				}
   179  
   180  				// Search through all queued items to find our run.
   181  				for _, item := range rq.Items {
   182  					if r.ID == item.ID {
   183  						position = item.PositionInQueue
   184  						break search
   185  					}
   186  				}
   187  
   188  				// Exit the loop when we've seen all pages.
   189  				if rq.CurrentPage >= rq.TotalPages {
   190  					break
   191  				}
   192  
   193  				// Update the page number to get the next page.
   194  				options.PageNumber = rq.NextPage
   195  			}
   196  
   197  			if position > 0 {
   198  				c, err := b.client.Organizations.ReadCapacity(stopCtx, b.organization)
   199  				if err != nil {
   200  					return r, generalError("Failed to retrieve capacity", err)
   201  				}
   202  				b.CLI.Output(b.Colorize().Color(fmt.Sprintf(
   203  					"Waiting for %d queued run(s) to finish before starting...%s",
   204  					position-c.Running,
   205  					elapsed,
   206  				)))
   207  				continue
   208  			}
   209  
   210  			b.CLI.Output(b.Colorize().Color(fmt.Sprintf(
   211  				"Waiting for the %s to start...%s", opType, elapsed)))
   212  		}
   213  	}
   214  }
   215  
   216  func (b *Cloud) waitTaskStage(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run, stageID string, outputTitle string) error {
   217  	integration := &IntegrationContext{
   218  		B:             b,
   219  		StopContext:   stopCtx,
   220  		CancelContext: cancelCtx,
   221  		Op:            op,
   222  		Run:           r,
   223  	}
   224  	return b.runTaskStage(integration, integration.BeginOutput(outputTitle), stageID)
   225  }
   226  
   227  func (b *Cloud) costEstimate(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error {
   228  	if r.CostEstimate == nil {
   229  		return nil
   230  	}
   231  
   232  	msgPrefix := "Cost Estimation"
   233  	started := time.Now()
   234  	updated := started
   235  	for i := 0; ; i++ {
   236  		select {
   237  		case <-stopCtx.Done():
   238  			return stopCtx.Err()
   239  		case <-cancelCtx.Done():
   240  			return cancelCtx.Err()
   241  		case <-time.After(backoff(backoffMin, backoffMax, i)):
   242  		}
   243  
   244  		// Retrieve the cost estimate to get its current status.
   245  		ce, err := b.client.CostEstimates.Read(stopCtx, r.CostEstimate.ID)
   246  		if err != nil {
   247  			return generalError("Failed to retrieve cost estimate", err)
   248  		}
   249  
   250  		// If the run is canceled or errored, but the cost-estimate still has
   251  		// no result, there is nothing further to render.
   252  		if ce.Status != tfe.CostEstimateFinished {
   253  			if r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored {
   254  				return nil
   255  			}
   256  		}
   257  
   258  		// checking if i == 0 so as to avoid printing this starting horizontal-rule
   259  		// every retry, and that it only prints it on the first (i=0) attempt.
   260  		if b.CLI != nil && i == 0 {
   261  			b.CLI.Output("\n------------------------------------------------------------------------\n")
   262  		}
   263  
   264  		switch ce.Status {
   265  		case tfe.CostEstimateFinished:
   266  			delta, err := strconv.ParseFloat(ce.DeltaMonthlyCost, 64)
   267  			if err != nil {
   268  				return generalError("Unexpected error", err)
   269  			}
   270  
   271  			sign := "+"
   272  			if delta < 0 {
   273  				sign = "-"
   274  			}
   275  
   276  			deltaRepr := strings.Replace(ce.DeltaMonthlyCost, "-", "", 1)
   277  
   278  			if b.CLI != nil {
   279  				b.CLI.Output(b.Colorize().Color("[bold]" + msgPrefix + ":\n"))
   280  				b.CLI.Output(b.Colorize().Color(fmt.Sprintf("Resources: %d of %d estimated", ce.MatchedResourcesCount, ce.ResourcesCount)))
   281  				b.CLI.Output(b.Colorize().Color(fmt.Sprintf("           $%s/mo %s$%s", ce.ProposedMonthlyCost, sign, deltaRepr)))
   282  
   283  				if len(r.PolicyChecks) == 0 && r.HasChanges && op.Type == backend.OperationTypeApply {
   284  					b.CLI.Output("\n------------------------------------------------------------------------")
   285  				}
   286  			}
   287  
   288  			return nil
   289  		case tfe.CostEstimatePending, tfe.CostEstimateQueued:
   290  			// Check if 30 seconds have passed since the last update.
   291  			current := time.Now()
   292  			if b.CLI != nil && (i == 0 || current.Sub(updated).Seconds() > 30) {
   293  				updated = current
   294  				elapsed := ""
   295  
   296  				// Calculate and set the elapsed time.
   297  				if i > 0 {
   298  					elapsed = fmt.Sprintf(
   299  						" (%s elapsed)", current.Sub(started).Truncate(30*time.Second))
   300  				}
   301  				b.CLI.Output(b.Colorize().Color("[bold]" + msgPrefix + ":\n"))
   302  				b.CLI.Output(b.Colorize().Color("Waiting for cost estimate to complete..." + elapsed + "\n"))
   303  			}
   304  			continue
   305  		case tfe.CostEstimateSkippedDueToTargeting:
   306  			b.CLI.Output(b.Colorize().Color("[bold]" + msgPrefix + ":\n"))
   307  			b.CLI.Output("Not available for this plan, because it was created with the -target option.")
   308  			b.CLI.Output("\n------------------------------------------------------------------------")
   309  			return nil
   310  		case tfe.CostEstimateErrored:
   311  			b.CLI.Output(msgPrefix + " errored.\n")
   312  			b.CLI.Output("\n------------------------------------------------------------------------")
   313  			return nil
   314  		case tfe.CostEstimateCanceled:
   315  			return fmt.Errorf(msgPrefix + " canceled.")
   316  		default:
   317  			return fmt.Errorf("Unknown or unexpected cost estimate state: %s", ce.Status)
   318  		}
   319  	}
   320  }
   321  
   322  func (b *Cloud) checkPolicy(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error {
   323  	if b.CLI != nil {
   324  		b.CLI.Output("\n------------------------------------------------------------------------\n")
   325  	}
   326  	for i, pc := range r.PolicyChecks {
   327  		// Read the policy check logs. This is a blocking call that will only
   328  		// return once the policy check is complete.
   329  		logs, err := b.client.PolicyChecks.Logs(stopCtx, pc.ID)
   330  		if err != nil {
   331  			return generalError("Failed to retrieve policy check logs", err)
   332  		}
   333  		reader := bufio.NewReaderSize(logs, 64*1024)
   334  
   335  		// Retrieve the policy check to get its current status.
   336  		pc, err := b.client.PolicyChecks.Read(stopCtx, pc.ID)
   337  		if err != nil {
   338  			return generalError("Failed to retrieve policy check", err)
   339  		}
   340  
   341  		// If the run is canceled or errored, but the policy check still has
   342  		// no result, there is nothing further to render.
   343  		if r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored {
   344  			switch pc.Status {
   345  			case tfe.PolicyPending, tfe.PolicyQueued, tfe.PolicyUnreachable:
   346  				continue
   347  			}
   348  		}
   349  
   350  		var msgPrefix string
   351  		switch pc.Scope {
   352  		case tfe.PolicyScopeOrganization:
   353  			msgPrefix = "Organization Policy Check"
   354  		case tfe.PolicyScopeWorkspace:
   355  			msgPrefix = "Workspace Policy Check"
   356  		default:
   357  			msgPrefix = fmt.Sprintf("Unknown policy check (%s)", pc.Scope)
   358  		}
   359  
   360  		if b.CLI != nil {
   361  			b.CLI.Output(b.Colorize().Color("[bold]" + msgPrefix + ":\n"))
   362  		}
   363  
   364  		if b.CLI != nil {
   365  			for next := true; next; {
   366  				var l, line []byte
   367  
   368  				for isPrefix := true; isPrefix; {
   369  					l, isPrefix, err = reader.ReadLine()
   370  					if err != nil {
   371  						if err != io.EOF {
   372  							return generalError("Failed to read logs", err)
   373  						}
   374  						next = false
   375  					}
   376  					line = append(line, l...)
   377  				}
   378  
   379  				if next || len(line) > 0 {
   380  					b.CLI.Output(b.Colorize().Color(string(line)))
   381  				}
   382  			}
   383  		}
   384  
   385  		switch pc.Status {
   386  		case tfe.PolicyPasses:
   387  			if (r.HasChanges && op.Type == backend.OperationTypeApply || i < len(r.PolicyChecks)-1) && b.CLI != nil {
   388  				b.CLI.Output("\n------------------------------------------------------------------------")
   389  			}
   390  			continue
   391  		case tfe.PolicyErrored:
   392  			return fmt.Errorf(msgPrefix + " errored.")
   393  		case tfe.PolicyHardFailed:
   394  			return fmt.Errorf(msgPrefix + " hard failed.")
   395  		case tfe.PolicySoftFailed:
   396  			runUrl := fmt.Sprintf(runHeader, b.hostname, b.organization, op.Workspace, r.ID)
   397  
   398  			if op.Type == backend.OperationTypePlan || op.UIOut == nil || op.UIIn == nil ||
   399  				!pc.Actions.IsOverridable || !pc.Permissions.CanOverride {
   400  				return fmt.Errorf(msgPrefix + " soft failed.\n" + runUrl)
   401  			}
   402  
   403  			if op.AutoApprove {
   404  				if _, err = b.client.PolicyChecks.Override(stopCtx, pc.ID); err != nil {
   405  					return generalError(fmt.Sprintf("Failed to override policy check.\n%s", runUrl), err)
   406  				}
   407  			} else if !b.input {
   408  				return errPolicyOverrideNeedsUIConfirmation
   409  			} else {
   410  				opts := &tofu.InputOpts{
   411  					Id:          "override",
   412  					Query:       "\nDo you want to override the soft failed policy check?",
   413  					Description: "Only 'override' will be accepted to override.",
   414  				}
   415  				err = b.confirm(stopCtx, op, opts, r, "override")
   416  				if err != nil && err != errRunOverridden {
   417  					return fmt.Errorf(
   418  						fmt.Sprintf("Failed to override: %s\n%s\n", err.Error(), runUrl),
   419  					)
   420  				}
   421  
   422  				if err != errRunOverridden {
   423  					if _, err = b.client.PolicyChecks.Override(stopCtx, pc.ID); err != nil {
   424  						return generalError(fmt.Sprintf("Failed to override policy check.\n%s", runUrl), err)
   425  					}
   426  				} else {
   427  					b.CLI.Output(fmt.Sprintf("The run needs to be manually overridden or discarded.\n%s\n", runUrl))
   428  				}
   429  			}
   430  
   431  			if b.CLI != nil {
   432  				b.CLI.Output("------------------------------------------------------------------------")
   433  			}
   434  		default:
   435  			return fmt.Errorf("Unknown or unexpected policy state: %s", pc.Status)
   436  		}
   437  	}
   438  
   439  	return nil
   440  }
   441  
   442  func (b *Cloud) confirm(stopCtx context.Context, op *backend.Operation, opts *tofu.InputOpts, r *tfe.Run, keyword string) error {
   443  	doneCtx, cancel := context.WithCancel(stopCtx)
   444  	result := make(chan error, 2)
   445  
   446  	go func() {
   447  		// Make sure we cancel doneCtx before we return
   448  		// so the input command is also canceled.
   449  		defer cancel()
   450  
   451  		for {
   452  			select {
   453  			case <-doneCtx.Done():
   454  				return
   455  			case <-stopCtx.Done():
   456  				return
   457  			case <-time.After(runPollInterval):
   458  				// Retrieve the run again to get its current status.
   459  				r, err := b.client.Runs.Read(stopCtx, r.ID)
   460  				if err != nil {
   461  					result <- generalError("Failed to retrieve run", err)
   462  					return
   463  				}
   464  
   465  				switch keyword {
   466  				case "override":
   467  					if r.Status != tfe.RunPolicyOverride && r.Status != tfe.RunPostPlanAwaitingDecision {
   468  						if r.Status == tfe.RunDiscarded {
   469  							err = errRunDiscarded
   470  						} else {
   471  							err = errRunOverridden
   472  						}
   473  					}
   474  				case "yes":
   475  					if !r.Actions.IsConfirmable {
   476  						if r.Status == tfe.RunDiscarded {
   477  							err = errRunDiscarded
   478  						} else {
   479  							err = errRunApproved
   480  						}
   481  					}
   482  				}
   483  
   484  				if err != nil {
   485  					if b.CLI != nil {
   486  						b.CLI.Output(b.Colorize().Color(
   487  							fmt.Sprintf("[reset][yellow]%s[reset]", err.Error())))
   488  					}
   489  
   490  					if err == errRunDiscarded {
   491  						err = errApplyDiscarded
   492  						if op.PlanMode == plans.DestroyMode {
   493  							err = errDestroyDiscarded
   494  						}
   495  					}
   496  
   497  					result <- err
   498  					return
   499  				}
   500  			}
   501  		}
   502  	}()
   503  
   504  	result <- func() error {
   505  		v, err := op.UIIn.Input(doneCtx, opts)
   506  		if err != nil && err != context.Canceled && stopCtx.Err() != context.Canceled {
   507  			return fmt.Errorf("Error asking %s: %w", opts.Id, err)
   508  		}
   509  
   510  		// We return the error of our parent channel as we don't
   511  		// care about the error of the doneCtx which is only used
   512  		// within this function. So if the doneCtx was canceled
   513  		// because stopCtx was canceled, this will properly return
   514  		// a context.Canceled error and otherwise it returns nil.
   515  		if doneCtx.Err() == context.Canceled || stopCtx.Err() == context.Canceled {
   516  			return stopCtx.Err()
   517  		}
   518  
   519  		// Make sure we cancel the context here so the loop that
   520  		// checks for external changes to the run is ended before
   521  		// we start to make changes ourselves.
   522  		cancel()
   523  
   524  		if v != keyword {
   525  			// Retrieve the run again to get its current status.
   526  			r, err = b.client.Runs.Read(stopCtx, r.ID)
   527  			if err != nil {
   528  				return generalError("Failed to retrieve run", err)
   529  			}
   530  
   531  			// Make sure we discard the run if possible.
   532  			if r.Actions.IsDiscardable {
   533  				err = b.client.Runs.Discard(stopCtx, r.ID, tfe.RunDiscardOptions{})
   534  				if err != nil {
   535  					if op.PlanMode == plans.DestroyMode {
   536  						return generalError("Failed to discard destroy", err)
   537  					}
   538  					return generalError("Failed to discard apply", err)
   539  				}
   540  			}
   541  
   542  			// Even if the run was discarded successfully, we still
   543  			// return an error as the apply command was canceled.
   544  			if op.PlanMode == plans.DestroyMode {
   545  				return errDestroyDiscarded
   546  			}
   547  			return errApplyDiscarded
   548  		}
   549  
   550  		return nil
   551  	}()
   552  
   553  	return <-result
   554  }
   555  
   556  // This method will fetch the redacted plan output as a byte slice, mirroring
   557  // the behavior of the similar client.Plans.ReadJSONOutput method.
   558  //
   559  // Note: Apologies for the lengthy definition, this is a result of not being
   560  // able to mock receiver methods
   561  var readRedactedPlan func(context.Context, url.URL, string, string) ([]byte, error) = func(ctx context.Context, baseURL url.URL, token string, planID string) ([]byte, error) {
   562  	client := retryablehttp.NewClient()
   563  	client.RetryMax = 10
   564  	client.RetryWaitMin = 100 * time.Millisecond
   565  	client.RetryWaitMax = 400 * time.Millisecond
   566  	client.Logger = logging.HCLogger()
   567  
   568  	u, err := baseURL.Parse(fmt.Sprintf(
   569  		"plans/%s/json-output-redacted", url.QueryEscape(planID)))
   570  	if err != nil {
   571  		return nil, err
   572  	}
   573  
   574  	req, err := retryablehttp.NewRequest("GET", u.String(), nil)
   575  	if err != nil {
   576  		return nil, err
   577  	}
   578  
   579  	req.Header.Set("Authorization", "Bearer "+token)
   580  	req.Header.Set("Accept", "application/json")
   581  
   582  	resp, err := client.Do(req)
   583  	if err != nil {
   584  		return nil, err
   585  	}
   586  	defer resp.Body.Close()
   587  
   588  	if err = checkResponseCode(resp); err != nil {
   589  		return nil, err
   590  	}
   591  
   592  	return io.ReadAll(resp.Body)
   593  }
   594  
   595  // decodeRedactedPlan unmarshals a downloaded redacted plan into a struct the
   596  // jsonformat.Renderer expects.
   597  func decodeRedactedPlan(jsonBytes []byte) (*jsonformat.Plan, error) {
   598  	r := bytes.NewReader(jsonBytes)
   599  	p := &jsonformat.Plan{}
   600  	if err := json.NewDecoder(r).Decode(p); err != nil {
   601  		return nil, err
   602  	}
   603  	return p, nil
   604  }
   605  
   606  func checkResponseCode(r *http.Response) error {
   607  	if r.StatusCode >= 200 && r.StatusCode <= 299 {
   608  		return nil
   609  	}
   610  
   611  	var errs []string
   612  	var err error
   613  
   614  	switch r.StatusCode {
   615  	case 401:
   616  		return tfe.ErrUnauthorized
   617  	case 404:
   618  		return tfe.ErrResourceNotFound
   619  	}
   620  
   621  	errs, err = decodeErrorPayload(r)
   622  	if err != nil {
   623  		return err
   624  	}
   625  
   626  	return errors.New(strings.Join(errs, "\n"))
   627  }
   628  
   629  func decodeErrorPayload(r *http.Response) ([]string, error) {
   630  	// Decode the error payload.
   631  	var errs []string
   632  	errPayload := &jsonapi.ErrorsPayload{}
   633  	err := json.NewDecoder(r.Body).Decode(errPayload)
   634  	if err != nil || len(errPayload.Errors) == 0 {
   635  		return errs, errors.New(r.Status)
   636  	}
   637  
   638  	// Parse and format the errors.
   639  	for _, e := range errPayload.Errors {
   640  		if e.Detail == "" {
   641  			errs = append(errs, e.Title)
   642  		} else {
   643  			errs = append(errs, fmt.Sprintf("%s\n\n%s", e.Title, e.Detail))
   644  		}
   645  	}
   646  
   647  	return errs, nil
   648  }