terraform/vendor/github.com/hashicorp/go-tfe/tfe.go

589 lines
17 KiB
Go
Raw Normal View History

package tfe
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
2018-11-13 11:17:39 +01:00
"math/rand"
"net/http"
"net/url"
"os"
"reflect"
2018-11-13 11:17:39 +01:00
"strconv"
"strings"
2018-11-13 11:17:39 +01:00
"time"
"github.com/google/go-querystring/query"
"github.com/hashicorp/go-cleanhttp"
2018-11-13 11:17:39 +01:00
retryablehttp "github.com/hashicorp/go-retryablehttp"
"github.com/svanharmelen/jsonapi"
2018-11-13 11:17:39 +01:00
"golang.org/x/time/rate"
)
const (
2018-11-13 11:17:39 +01:00
userAgent = "go-tfe"
headerRateLimit = "X-RateLimit-Limit"
headerRateReset = "X-RateLimit-Reset"
// DefaultAddress of Terraform Enterprise.
DefaultAddress = "https://app.terraform.io"
// DefaultBasePath on which the API is served.
DefaultBasePath = "/api/v2/"
// No-op API endpoint used to configure the rate limiter
PingEndpoint = "ping"
)
var (
2018-11-30 19:36:32 +01:00
// ErrWorkspaceLocked is returned when trying to lock a
// locked workspace.
ErrWorkspaceLocked = errors.New("workspace already locked")
// ErrWorkspaceNotLocked is returned when trying to unlock
// a unlocked workspace.
ErrWorkspaceNotLocked = errors.New("workspace already unlocked")
// ErrUnauthorized is returned when a receiving a 401.
ErrUnauthorized = errors.New("unauthorized")
// ErrResourceNotFound is returned when a receiving a 404.
ErrResourceNotFound = errors.New("resource not found")
)
2019-03-06 13:34:14 +01:00
// RetryLogHook allows a function to run before each retry.
type RetryLogHook func(attemptNum int, resp *http.Response)
// Config provides configuration details to the API client.
type Config struct {
// The address of the Terraform Enterprise API.
Address string
// The base path on which the API is served.
BasePath string
// API token used to access the Terraform Enterprise API.
Token string
// Headers that will be added to every request.
Headers http.Header
// A custom HTTP client to use.
HTTPClient *http.Client
2019-03-06 13:34:14 +01:00
// RetryLogHook is invoked each time a request is retried.
RetryLogHook RetryLogHook
}
// DefaultConfig returns a default config structure.
func DefaultConfig() *Config {
config := &Config{
Address: os.Getenv("TFE_ADDRESS"),
BasePath: DefaultBasePath,
Token: os.Getenv("TFE_TOKEN"),
Headers: make(http.Header),
HTTPClient: cleanhttp.DefaultPooledClient(),
}
// Set the default address if none is given.
if config.Address == "" {
config.Address = DefaultAddress
}
// Set the default user agent.
config.Headers.Set("User-Agent", userAgent)
return config
}
// Client is the Terraform Enterprise API client. It provides the basic
// connectivity and configuration for accessing the TFE API.
type Client struct {
2019-03-06 13:34:14 +01:00
baseURL *url.URL
token string
headers http.Header
http *retryablehttp.Client
limiter *rate.Limiter
retryLogHook RetryLogHook
retryServerErrors bool
2019-04-08 11:13:14 +02:00
Applies Applies
ConfigurationVersions ConfigurationVersions
CostEstimates CostEstimates
2019-04-08 11:13:14 +02:00
NotificationConfigurations NotificationConfigurations
OAuthClients OAuthClients
OAuthTokens OAuthTokens
Organizations Organizations
OrganizationTokens OrganizationTokens
Plans Plans
PlanExports PlanExports
2019-04-08 11:13:14 +02:00
Policies Policies
PolicyChecks PolicyChecks
PolicySets PolicySets
Runs Runs
SSHKeys SSHKeys
StateVersions StateVersions
Teams Teams
TeamAccess TeamAccesses
TeamMembers TeamMembers
TeamTokens TeamTokens
Users Users
Variables Variables
Workspaces Workspaces
}
// NewClient creates a new Terraform Enterprise API client.
func NewClient(cfg *Config) (*Client, error) {
config := DefaultConfig()
// Layer in the provided config for any non-blank values.
if cfg != nil {
if cfg.Address != "" {
config.Address = cfg.Address
}
if cfg.BasePath != "" {
config.BasePath = cfg.BasePath
}
if cfg.Token != "" {
config.Token = cfg.Token
}
for k, v := range cfg.Headers {
config.Headers[k] = v
}
if cfg.HTTPClient != nil {
config.HTTPClient = cfg.HTTPClient
}
2019-03-06 13:34:14 +01:00
if cfg.RetryLogHook != nil {
config.RetryLogHook = cfg.RetryLogHook
}
}
// Parse the address to make sure its a valid URL.
baseURL, err := url.Parse(config.Address)
if err != nil {
2018-11-13 11:17:39 +01:00
return nil, fmt.Errorf("invalid address: %v", err)
}
baseURL.Path = config.BasePath
if !strings.HasSuffix(baseURL.Path, "/") {
baseURL.Path += "/"
}
// This value must be provided by the user.
if config.Token == "" {
2018-11-13 11:17:39 +01:00
return nil, fmt.Errorf("missing API token")
}
// Create the client.
client := &Client{
2019-03-06 13:34:14 +01:00
baseURL: baseURL,
token: config.Token,
headers: config.Headers,
retryLogHook: config.RetryLogHook,
}
client.http = &retryablehttp.Client{
Backoff: client.retryHTTPBackoff,
CheckRetry: client.retryHTTPCheck,
ErrorHandler: retryablehttp.PassthroughErrorHandler,
HTTPClient: config.HTTPClient,
RetryWaitMin: 100 * time.Millisecond,
RetryWaitMax: 400 * time.Millisecond,
RetryMax: 30,
2018-11-13 11:17:39 +01:00
}
// Configure the rate limiter.
if err := client.configureLimiter(); err != nil {
return nil, err
}
// Create the services.
client.Applies = &applies{client: client}
client.ConfigurationVersions = &configurationVersions{client: client}
client.CostEstimates = &costEstimates{client: client}
2019-04-08 11:13:14 +02:00
client.NotificationConfigurations = &notificationConfigurations{client: client}
client.OAuthClients = &oAuthClients{client: client}
client.OAuthTokens = &oAuthTokens{client: client}
client.Organizations = &organizations{client: client}
client.OrganizationTokens = &organizationTokens{client: client}
client.Plans = &plans{client: client}
client.PlanExports = &planExports{client: client}
client.Policies = &policies{client: client}
client.PolicyChecks = &policyChecks{client: client}
2018-11-13 11:17:39 +01:00
client.PolicySets = &policySets{client: client}
client.Runs = &runs{client: client}
client.SSHKeys = &sshKeys{client: client}
client.StateVersions = &stateVersions{client: client}
client.Teams = &teams{client: client}
client.TeamAccess = &teamAccesses{client: client}
client.TeamMembers = &teamMembers{client: client}
client.TeamTokens = &teamTokens{client: client}
client.Users = &users{client: client}
client.Variables = &variables{client: client}
client.Workspaces = &workspaces{client: client}
return client, nil
}
2019-03-06 13:34:14 +01:00
// RetryServerErrors configures the retry HTTP check to also retry
// unexpected errors or requests that failed with a server error.
func (c *Client) RetryServerErrors(retry bool) {
c.retryServerErrors = retry
}
// retryHTTPCheck provides a callback for Client.CheckRetry which
// will retry both rate limit (429) and server (>= 500) errors.
func (c *Client) retryHTTPCheck(ctx context.Context, resp *http.Response, err error) (bool, error) {
2018-11-13 11:17:39 +01:00
if ctx.Err() != nil {
return false, ctx.Err()
}
if err != nil {
2019-03-06 13:34:14 +01:00
return c.retryServerErrors, err
2018-11-13 11:17:39 +01:00
}
2019-03-06 13:34:14 +01:00
if resp.StatusCode == 429 || (c.retryServerErrors && resp.StatusCode >= 500) {
2018-11-13 11:17:39 +01:00
return true, nil
}
return false, nil
}
2019-03-06 13:34:14 +01:00
// retryHTTPBackoff provides a generic callback for Client.Backoff which
// will pass through all calls based on the status code of the response.
func (c *Client) retryHTTPBackoff(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
if c.retryLogHook != nil {
c.retryLogHook(attemptNum, resp)
}
// Use the rate limit backoff function when we are rate limited.
if resp != nil && resp.StatusCode == 429 {
2019-03-06 13:34:14 +01:00
return rateLimitBackoff(min, max, attemptNum, resp)
}
// Set custom duration's when we experience a service interruption.
min = 700 * time.Millisecond
max = 900 * time.Millisecond
return retryablehttp.LinearJitterBackoff(min, max, attemptNum, resp)
}
2018-11-13 11:17:39 +01:00
// rateLimitBackoff provides a callback for Client.Backoff which will use the
// X-RateLimit_Reset header to determine the time to wait. We add some jitter
// to prevent a thundering herd.
//
// min and max are mainly used for bounding the jitter that will be added to
// the reset time retrieved from the headers. But if the final wait time is
// less then min, min will be used instead.
func rateLimitBackoff(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
2019-02-25 14:32:55 +01:00
// rnd is used to generate pseudo-random numbers.
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
2018-11-13 11:17:39 +01:00
// First create some jitter bounded by the min and max durations.
2019-02-25 14:32:55 +01:00
jitter := time.Duration(rnd.Float64() * float64(max-min))
2018-11-13 11:17:39 +01:00
if resp != nil {
if v := resp.Header.Get(headerRateReset); v != "" {
if reset, _ := strconv.ParseFloat(v, 64); reset > 0 {
// Only update min if the given time to wait is longer.
if wait := time.Duration(reset * 1e9); wait > min {
min = wait
}
}
}
}
return min + jitter
}
// configureLimiter configures the rate limiter.
func (c *Client) configureLimiter() error {
// Create a new request.
u, err := c.baseURL.Parse(PingEndpoint)
if err != nil {
return err
}
req, err := http.NewRequest("GET", u.String(), nil)
2018-11-13 11:17:39 +01:00
if err != nil {
return err
}
// Attach the default headers.
for k, v := range c.headers {
req.Header[k] = v
}
req.Header.Set("Accept", "application/vnd.api+json")
2019-04-08 11:13:14 +02:00
req.Header.Set("Authorization", "Bearer "+c.token)
2018-11-13 11:17:39 +01:00
// Make a single request to retrieve the rate limit headers.
resp, err := c.http.HTTPClient.Do(req)
if err != nil {
return err
}
resp.Body.Close()
// Set default values for when rate limiting is disabled.
limit := rate.Inf
burst := 0
if v := resp.Header.Get(headerRateLimit); v != "" {
if rateLimit, _ := strconv.ParseFloat(v, 64); rateLimit > 0 {
// Configure the limit and burst using a split of 2/3 for the limit and
// 1/3 for the burst. This enables clients to burst 1/3 of the allowed
// calls before the limiter kicks in. The remaining calls will then be
// spread out evenly using intervals of time.Second / limit which should
// prevent hitting the rate limit.
limit = rate.Limit(rateLimit * 0.66)
burst = int(rateLimit * 0.33)
}
}
// Create a new limiter using the calculated values.
c.limiter = rate.NewLimiter(limit, burst)
return nil
}
// newRequest creates an API request. A relative URL path can be provided in
// path, in which case it is resolved relative to the apiVersionPath of the
// Client. Relative URL paths should always be specified without a preceding
// slash.
// If v is supplied, the value will be JSONAPI encoded and included as the
// request body. If the method is GET, the value will be parsed and added as
// query parameters.
2018-11-13 11:17:39 +01:00
func (c *Client) newRequest(method, path string, v interface{}) (*retryablehttp.Request, error) {
u, err := c.baseURL.Parse(path)
if err != nil {
return nil, err
}
2018-11-13 11:17:39 +01:00
// Create a request specific headers map.
reqHeaders := make(http.Header)
reqHeaders.Set("Authorization", "Bearer "+c.token)
2018-11-13 11:17:39 +01:00
var body interface{}
switch method {
case "GET":
2018-11-13 11:17:39 +01:00
reqHeaders.Set("Accept", "application/vnd.api+json")
if v != nil {
q, err := query.Values(v)
if err != nil {
return nil, err
}
u.RawQuery = q.Encode()
}
case "DELETE", "PATCH", "POST":
2018-11-13 11:17:39 +01:00
reqHeaders.Set("Accept", "application/vnd.api+json")
reqHeaders.Set("Content-Type", "application/vnd.api+json")
if v != nil {
2018-11-13 11:17:39 +01:00
buf := bytes.NewBuffer(nil)
if err := jsonapi.MarshalPayloadWithoutIncluded(buf, v); err != nil {
return nil, err
}
2018-11-13 11:17:39 +01:00
body = buf
}
case "PUT":
2018-11-13 11:17:39 +01:00
reqHeaders.Set("Accept", "application/json")
reqHeaders.Set("Content-Type", "application/octet-stream")
body = v
}
2018-11-13 11:17:39 +01:00
req, err := retryablehttp.NewRequest(method, u.String(), body)
if err != nil {
return nil, err
}
// Set the default headers.
for k, v := range c.headers {
req.Header[k] = v
}
2018-11-13 11:17:39 +01:00
// Set the request specific headers.
for k, v := range reqHeaders {
req.Header[k] = v
}
return req, nil
}
// do sends an API request and returns the API response. The API response
// is JSONAPI decoded and the document's primary data is stored in the value
// pointed to by v, or returned as an error if an API error has occurred.
// If v implements the io.Writer interface, the raw response body will be
// written to v, without attempting to first decode it.
//
// The provided ctx must be non-nil. If it is canceled or times out, ctx.Err()
// will be returned.
2018-11-13 11:17:39 +01:00
func (c *Client) do(ctx context.Context, req *retryablehttp.Request, v interface{}) error {
// Wait will block until the limiter can obtain a new token
// or returns an error if the given context is canceled.
if err := c.limiter.Wait(ctx); err != nil {
return err
}
// Add the context to the request.
req = req.WithContext(ctx)
// Execute the request and check the response.
resp, err := c.http.Do(req)
if err != nil {
// If we got an error, and the context has been canceled,
// the context's error is probably more useful.
select {
case <-ctx.Done():
return ctx.Err()
default:
return err
}
}
defer resp.Body.Close()
// Basic response checking.
if err := checkResponseCode(resp); err != nil {
return err
}
// Return here if decoding the response isn't needed.
if v == nil {
return nil
}
// If v implements io.Writer, write the raw response body.
if w, ok := v.(io.Writer); ok {
_, err = io.Copy(w, resp.Body)
return err
}
// Get the value of v so we can test if it's a struct.
dst := reflect.Indirect(reflect.ValueOf(v))
// Return an error if v is not a struct or an io.Writer.
if dst.Kind() != reflect.Struct {
return fmt.Errorf("v must be a struct or an io.Writer")
}
// Try to get the Items and Pagination struct fields.
items := dst.FieldByName("Items")
pagination := dst.FieldByName("Pagination")
// Unmarshal a single value if v does not contain the
// Items and Pagination struct fields.
if !items.IsValid() || !pagination.IsValid() {
return jsonapi.UnmarshalPayload(resp.Body, v)
}
// Return an error if v.Items is not a slice.
if items.Type().Kind() != reflect.Slice {
return fmt.Errorf("v.Items must be a slice")
}
// Create a temporary buffer and copy all the read data into it.
body := bytes.NewBuffer(nil)
reader := io.TeeReader(resp.Body, body)
// Unmarshal as a list of values as v.Items is a slice.
raw, err := jsonapi.UnmarshalManyPayload(reader, items.Type().Elem())
if err != nil {
return err
}
// Make a new slice to hold the results.
sliceType := reflect.SliceOf(items.Type().Elem())
result := reflect.MakeSlice(sliceType, 0, len(raw))
// Add all of the results to the new slice.
for _, v := range raw {
result = reflect.Append(result, reflect.ValueOf(v))
}
// Pointer-swap the result.
items.Set(result)
// As we are getting a list of values, we need to decode
// the pagination details out of the response body.
p, err := parsePagination(body)
if err != nil {
return err
}
// Pointer-swap the decoded pagination details.
pagination.Set(reflect.ValueOf(p))
return nil
}
2018-11-20 17:03:51 +01:00
// ListOptions is used to specify pagination options when making API requests.
// Pagination allows breaking up large result sets into chunks, or "pages".
type ListOptions struct {
// The page number to request. The results vary based on the PageSize.
PageNumber int `url:"page[number],omitempty"`
// The number of elements returned in a single page.
PageSize int `url:"page[size],omitempty"`
}
// Pagination is used to return the pagination details of an API request.
type Pagination struct {
CurrentPage int `json:"current-page"`
PreviousPage int `json:"prev-page"`
NextPage int `json:"next-page"`
TotalPages int `json:"total-pages"`
TotalCount int `json:"total-count"`
}
func parsePagination(body io.Reader) (*Pagination, error) {
var raw struct {
Meta struct {
Pagination Pagination `json:"pagination"`
} `json:"meta"`
}
// JSON decode the raw response.
if err := json.NewDecoder(body).Decode(&raw); err != nil {
return &Pagination{}, err
}
return &raw.Meta.Pagination, nil
}
// checkResponseCode can be used to check the status code of an HTTP request.
func checkResponseCode(r *http.Response) error {
if r.StatusCode >= 200 && r.StatusCode <= 299 {
return nil
}
switch r.StatusCode {
case 401:
return ErrUnauthorized
case 404:
return ErrResourceNotFound
2018-11-30 19:36:32 +01:00
case 409:
switch {
case strings.HasSuffix(r.Request.URL.Path, "actions/lock"):
return ErrWorkspaceLocked
case strings.HasSuffix(r.Request.URL.Path, "actions/unlock"):
return ErrWorkspaceNotLocked
case strings.HasSuffix(r.Request.URL.Path, "actions/force-unlock"):
return ErrWorkspaceNotLocked
}
}
// Decode the error payload.
errPayload := &jsonapi.ErrorsPayload{}
err := json.NewDecoder(r.Body).Decode(errPayload)
if err != nil || len(errPayload.Errors) == 0 {
return fmt.Errorf(r.Status)
}
// Parse and format the errors.
var errs []string
for _, e := range errPayload.Errors {
if e.Detail == "" {
errs = append(errs, e.Title)
} else {
2019-01-15 15:52:37 +01:00
errs = append(errs, fmt.Sprintf("%s\n\n%s", e.Title, e.Detail))
}
}
return fmt.Errorf(strings.Join(errs, "\n"))
}