github.com/myhau/pulumi/pkg/v3@v3.70.2-0.20221116134521-f2775972e587/backend/httpstate/client/api.go (about)

     1  // Copyright 2016-2018, Pulumi Corporation.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package client
    16  
    17  import (
    18  	"bytes"
    19  	"compress/gzip"
    20  	"context"
    21  	"encoding/json"
    22  	"errors"
    23  	"fmt"
    24  	"io"
    25  	"io/ioutil"
    26  	"net/http"
    27  	"reflect"
    28  	"runtime"
    29  	"strings"
    30  	"time"
    31  
    32  	"github.com/google/go-querystring/query"
    33  	"github.com/opentracing/opentracing-go"
    34  
    35  	"github.com/pulumi/pulumi/pkg/v3/util/tracing"
    36  	"github.com/pulumi/pulumi/pkg/v3/version"
    37  	"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
    38  	"github.com/pulumi/pulumi/sdk/v3/go/common/diag"
    39  	"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
    40  	"github.com/pulumi/pulumi/sdk/v3/go/common/util/httputil"
    41  	"github.com/pulumi/pulumi/sdk/v3/go/common/util/logging"
    42  )
    43  
    44  const (
    45  	apiRequestLogLevel       = 10 // log level for logging API requests and responses
    46  	apiRequestDetailLogLevel = 11 // log level for logging extra details about API requests and responses
    47  )
    48  
    49  // StackIdentifier is the set of data needed to identify a Pulumi Cloud stack.
    50  type StackIdentifier struct {
    51  	Owner   string
    52  	Project string
    53  	Stack   string
    54  }
    55  
    56  func (s StackIdentifier) String() string {
    57  	return fmt.Sprintf("%s/%s/%s", s.Owner, s.Project, s.Stack)
    58  }
    59  
    60  // UpdateIdentifier is the set of data needed to identify an update to a Pulumi Cloud stack.
    61  type UpdateIdentifier struct {
    62  	StackIdentifier
    63  
    64  	UpdateKind apitype.UpdateKind
    65  	UpdateID   string
    66  }
    67  
    68  // accessTokenKind is enumerates the various types of access token used with the Pulumi API. These kinds correspond
    69  // directly to the "method" piece of an HTTP `Authorization` header.
    70  type accessTokenKind string
    71  
    72  const (
    73  	// accessTokenKindAPIToken denotes a standard Pulumi API token.
    74  	accessTokenKindAPIToken accessTokenKind = "token"
    75  	// accessTokenKindUpdateToken denotes an update lease token.
    76  	accessTokenKindUpdateToken accessTokenKind = "update-token"
    77  )
    78  
    79  // accessToken is an abstraction over the two different kinds of access tokens used by the Pulumi API.
    80  type accessToken interface {
    81  	Kind() accessTokenKind
    82  	String() string
    83  }
    84  
    85  type httpCallOptions struct {
    86  	// RetryAllMethods allows non-GET calls to be retried if the server fails to return a response.
    87  	RetryAllMethods bool
    88  
    89  	// GzipCompress compresses the request using gzip before sending it.
    90  	GzipCompress bool
    91  }
    92  
    93  // apiAccessToken is an implementation of accessToken for Pulumi API tokens (i.e. tokens of kind
    94  // accessTokenKindAPIToken)
    95  type apiAccessToken string
    96  
    97  func (apiAccessToken) Kind() accessTokenKind {
    98  	return accessTokenKindAPIToken
    99  }
   100  
   101  func (t apiAccessToken) String() string {
   102  	return string(t)
   103  }
   104  
   105  // updateAccessToken is an implementation of accessToken for update lease tokens (i.e. tokens of kind
   106  // accessTokenKindUpdateToken)
   107  type updateAccessToken string
   108  
   109  func (updateAccessToken) Kind() accessTokenKind {
   110  	return accessTokenKindUpdateToken
   111  }
   112  
   113  func (t updateAccessToken) String() string {
   114  	return string(t)
   115  }
   116  
   117  func float64Ptr(f float64) *float64 {
   118  	return &f
   119  }
   120  
   121  func durationPtr(d time.Duration) *time.Duration {
   122  	return &d
   123  }
   124  
   125  func intPtr(i int) *int {
   126  	return &i
   127  }
   128  
   129  // httpClient is an HTTP client abstraction, used by defaultRESTClient.
   130  type httpClient interface {
   131  	Do(req *http.Request, retryAllMethods bool) (*http.Response, error)
   132  }
   133  
   134  // defaultHTTPClient is an implementation of httpClient that provides a basic implementation of Do
   135  // using the specified *http.Client, with retry support.
   136  type defaultHTTPClient struct {
   137  	client *http.Client
   138  }
   139  
   140  func (c *defaultHTTPClient) Do(req *http.Request, retryAllMethods bool) (*http.Response, error) {
   141  	if req.Method == "GET" || retryAllMethods {
   142  		// Wait 1s before retrying on failure. Then increase by 2x until the
   143  		// maximum delay is reached. Stop after maxRetryCount requests have
   144  		// been made.
   145  		opts := httputil.RetryOpts{
   146  			Delay:    durationPtr(time.Second),
   147  			Backoff:  float64Ptr(2.0),
   148  			MaxDelay: durationPtr(30 * time.Second),
   149  
   150  			MaxRetryCount: intPtr(4),
   151  		}
   152  		return httputil.DoWithRetryOpts(req, c.client, opts)
   153  	}
   154  	return c.client.Do(req)
   155  }
   156  
   157  // pulumiAPICall makes an HTTP request to the Pulumi API.
   158  func pulumiAPICall(ctx context.Context,
   159  	requestSpan opentracing.Span,
   160  	d diag.Sink, client httpClient, cloudAPI, method, path string, body []byte,
   161  	tok accessToken, opts httpCallOptions) (string, *http.Response, error) {
   162  
   163  	// Normalize URL components
   164  	cloudAPI = strings.TrimSuffix(cloudAPI, "/")
   165  	path = cleanPath(path)
   166  
   167  	url := fmt.Sprintf("%s%s", cloudAPI, path)
   168  	var bodyReader io.Reader
   169  	if opts.GzipCompress {
   170  		// If we're being asked to compress the payload, go ahead and do it here to an intermediate buffer.
   171  		//
   172  		// If this becomes a performance bottleneck, we may want to consider marshaling json directly to this
   173  		// gzip.Writer instead of marshaling to a byte array and compressing it to another buffer.
   174  		logging.V(apiRequestDetailLogLevel).Infoln("compressing payload using gzip")
   175  		var buf bytes.Buffer
   176  		writer := gzip.NewWriter(&buf)
   177  		defer contract.IgnoreClose(writer)
   178  		if _, err := writer.Write(body); err != nil {
   179  			return "", nil, fmt.Errorf("compressing payload: %w", err)
   180  		}
   181  
   182  		// gzip.Writer will not actually write anything unless it is flushed,
   183  		//  and it will not actually write the GZip footer unless it is closed. (Close also flushes)
   184  		// Without this, the compressed bytes do not decompress properly e.g. in python.
   185  		if err := writer.Close(); err != nil {
   186  			return "", nil, fmt.Errorf("closing compressed payload: %w", err)
   187  		}
   188  
   189  		logging.V(apiRequestDetailLogLevel).Infof("gzip compression ratio: %f, original size: %d bytes",
   190  			float64(len(body))/float64(len(buf.Bytes())), len(body))
   191  		bodyReader = &buf
   192  	} else {
   193  		bodyReader = bytes.NewReader(body)
   194  	}
   195  
   196  	req, err := http.NewRequest(method, url, bodyReader)
   197  	if err != nil {
   198  		return "", nil, fmt.Errorf("creating new HTTP request: %w", err)
   199  	}
   200  
   201  	req = req.WithContext(ctx)
   202  	req.Header.Set("Content-Type", "application/json")
   203  
   204  	// Add a User-Agent header to allow for the backend to make breaking API changes while preserving
   205  	// backwards compatibility.
   206  	userAgent := fmt.Sprintf("pulumi-cli/1 (%s; %s)", version.Version, runtime.GOOS)
   207  	req.Header.Set("User-Agent", userAgent)
   208  	// Specify the specific API version we accept.
   209  	req.Header.Set("Accept", "application/vnd.pulumi+8")
   210  
   211  	// Apply credentials if provided.
   212  	if tok.String() != "" {
   213  		req.Header.Set("Authorization", fmt.Sprintf("%s %s", tok.Kind(), tok.String()))
   214  	}
   215  
   216  	tracingOptions := tracing.OptionsFromContext(ctx)
   217  	if tracingOptions.PropagateSpans {
   218  		carrier := opentracing.HTTPHeadersCarrier(req.Header)
   219  		if err = requestSpan.Tracer().Inject(requestSpan.Context(), opentracing.HTTPHeaders, carrier); err != nil {
   220  			logging.Errorf("injecting tracing headers: %v", err)
   221  		}
   222  	}
   223  	if tracingOptions.TracingHeader != "" {
   224  		req.Header.Set("X-Pulumi-Tracing", tracingOptions.TracingHeader)
   225  	}
   226  
   227  	// Opt-in to accepting gzip-encoded responses from the service.
   228  	req.Header.Set("Accept-Encoding", "gzip")
   229  	if opts.GzipCompress {
   230  		// If we're sending something that's gzipped, set that header too.
   231  		req.Header.Set("Content-Encoding", "gzip")
   232  	}
   233  
   234  	logging.V(apiRequestLogLevel).Infof("Making Pulumi API call: %s", url)
   235  	if logging.V(apiRequestDetailLogLevel) {
   236  		logging.V(apiRequestDetailLogLevel).Infof(
   237  			"Pulumi API call details (%s): headers=%v; body=%v", url, req.Header, string(body))
   238  	}
   239  
   240  	resp, err := client.Do(req, opts.RetryAllMethods)
   241  	if err != nil {
   242  		// Don't wrap *apitype.ErrorResponse.
   243  		if _, ok := err.(*apitype.ErrorResponse); ok {
   244  			return "", nil, err
   245  		}
   246  		return "", nil, fmt.Errorf("performing HTTP request: %w", err)
   247  	}
   248  	logging.V(apiRequestLogLevel).Infof("Pulumi API call response code (%s): %v", url, resp.Status)
   249  
   250  	requestSpan.SetTag("responseCode", resp.Status)
   251  
   252  	if warningHeader, ok := resp.Header["X-Pulumi-Warning"]; ok {
   253  		for _, warning := range warningHeader {
   254  			d.Warningf(diag.RawMessage("", warning))
   255  		}
   256  	}
   257  
   258  	// Provide a better error if using an authenticated call without having logged in first.
   259  	if resp.StatusCode == 401 && tok.Kind() == accessTokenKindAPIToken && tok.String() == "" {
   260  		return "", nil, errors.New("this command requires logging in; try running `pulumi login` first")
   261  	}
   262  
   263  	// Provide a better error if rate-limit is exceeded(429: Too Many Requests)
   264  	if resp.StatusCode == 429 {
   265  		return "", nil, errors.New("pulumi service: request rate-limit exceeded")
   266  	}
   267  
   268  	// For 4xx and 5xx failures, attempt to provide better diagnostics about what may have gone wrong.
   269  	if resp.StatusCode >= 400 && resp.StatusCode <= 599 {
   270  		// 4xx and 5xx responses should be of type ErrorResponse. See if we can unmarshal as that
   271  		// type, and if not just return the raw response text.
   272  		respBody, err := readBody(resp)
   273  		if err != nil {
   274  			return "", nil, fmt.Errorf("API call failed (%s), could not read response: %w", resp.Status, err)
   275  		}
   276  
   277  		var errResp apitype.ErrorResponse
   278  		if err = json.Unmarshal(respBody, &errResp); err != nil {
   279  			errResp.Code = resp.StatusCode
   280  			errResp.Message = strings.TrimSpace(string(respBody))
   281  		}
   282  		return "", nil, &errResp
   283  	}
   284  
   285  	return url, resp, nil
   286  }
   287  
   288  // restClient is an abstraction for calling the Pulumi REST API.
   289  type restClient interface {
   290  	Call(ctx context.Context, diag diag.Sink, cloudAPI, method, path string, queryObj, reqObj,
   291  		respObj interface{}, tok accessToken, opts httpCallOptions) error
   292  }
   293  
   294  // defaultRESTClient is the default implementation for calling the Pulumi REST API.
   295  type defaultRESTClient struct {
   296  	client httpClient
   297  }
   298  
   299  // Call calls the Pulumi REST API marshalling reqObj to JSON and using that as
   300  // the request body (use nil for GETs), and if successful, marshalling the responseObj
   301  // as JSON and storing it in respObj (use nil for NoContent). The error return type might
   302  // be an instance of apitype.ErrorResponse, in which case will have the response code.
   303  func (c *defaultRESTClient) Call(ctx context.Context, diag diag.Sink, cloudAPI, method, path string, queryObj, reqObj,
   304  	respObj interface{}, tok accessToken, opts httpCallOptions) error {
   305  
   306  	requestSpan, ctx := opentracing.StartSpanFromContext(ctx, getEndpointName(method, path),
   307  		opentracing.Tag{Key: "method", Value: method},
   308  		opentracing.Tag{Key: "path", Value: path},
   309  		opentracing.Tag{Key: "api", Value: cloudAPI},
   310  		opentracing.Tag{Key: "retry", Value: opts.RetryAllMethods})
   311  	defer requestSpan.Finish()
   312  
   313  	// Compute query string from query object
   314  	querystring := ""
   315  	if queryObj != nil {
   316  		queryValues, err := query.Values(queryObj)
   317  		if err != nil {
   318  			return fmt.Errorf("marshalling query object as JSON: %w", err)
   319  		}
   320  		query := queryValues.Encode()
   321  		if len(query) > 0 {
   322  			querystring = "?" + query
   323  		}
   324  	}
   325  
   326  	// Compute request body from request object
   327  	var reqBody []byte
   328  	var err error
   329  	if reqObj != nil {
   330  		// Send verbatim if already marshalled. This is
   331  		// important when sending indented JSON is needed.
   332  		if raw, ok := reqObj.(json.RawMessage); ok {
   333  			reqBody = []byte(raw)
   334  		} else {
   335  			reqBody, err = json.Marshal(reqObj)
   336  			if err != nil {
   337  				return fmt.Errorf("marshalling request object as JSON: %w", err)
   338  			}
   339  		}
   340  	}
   341  
   342  	// Make API call
   343  	url, resp, err := pulumiAPICall(
   344  		ctx, requestSpan, diag, c.client, cloudAPI, method, path+querystring, reqBody, tok, opts)
   345  	if err != nil {
   346  		return err
   347  	}
   348  
   349  	// Read API response
   350  	respBody, err := readBody(resp)
   351  	if err != nil {
   352  		return fmt.Errorf("reading response from API: %w", err)
   353  	}
   354  	if logging.V(apiRequestDetailLogLevel) {
   355  		logging.V(apiRequestDetailLogLevel).Infof("Pulumi API call response body (%s): %v", url, string(respBody))
   356  	}
   357  
   358  	if respObj != nil {
   359  		bytes := reflect.TypeOf([]byte(nil))
   360  		if typ := reflect.TypeOf(respObj); typ == reflect.PtrTo(bytes) {
   361  			// Return the raw bytes of the response body.
   362  			*respObj.(*[]byte) = respBody
   363  		} else if typ == bytes {
   364  			return fmt.Errorf("Can't unmarshal response body to []byte. Try *[]byte")
   365  		} else {
   366  			// Else, unmarshal as JSON.
   367  			if err = json.Unmarshal(respBody, respObj); err != nil {
   368  				return fmt.Errorf("unmarshalling response object: %w", err)
   369  			}
   370  		}
   371  	}
   372  
   373  	return nil
   374  }
   375  
   376  // readBody reads the contents of an http.Response into a byte array, returning an error if one occurred while in the
   377  // process of doing so. readBody uses the Content-Encoding of the response to pick the correct reader to use.
   378  func readBody(resp *http.Response) ([]byte, error) {
   379  	contentEncoding, ok := resp.Header["Content-Encoding"]
   380  	defer contract.IgnoreClose(resp.Body)
   381  	if !ok {
   382  		// No header implies that there's no additional encoding on this response.
   383  		return ioutil.ReadAll(resp.Body)
   384  	}
   385  
   386  	if len(contentEncoding) > 1 {
   387  		// We only know how to deal with gzip. We can't handle additional encodings layered on top of it.
   388  		return nil, fmt.Errorf("can't handle content encodings %v", contentEncoding)
   389  	}
   390  
   391  	switch contentEncoding[0] {
   392  	case "x-gzip":
   393  		// The HTTP/1.1 spec recommends we treat x-gzip as an alias of gzip.
   394  		fallthrough
   395  	case "gzip":
   396  		logging.V(apiRequestDetailLogLevel).Infoln("decompressing gzipped response from service")
   397  		reader, err := gzip.NewReader(resp.Body)
   398  		if reader != nil {
   399  			defer contract.IgnoreClose(reader)
   400  		}
   401  		if err != nil {
   402  			return nil, fmt.Errorf("reading gzip-compressed body: %w", err)
   403  		}
   404  
   405  		return ioutil.ReadAll(reader)
   406  	default:
   407  		return nil, fmt.Errorf("unrecognized encoding %s", contentEncoding[0])
   408  	}
   409  }