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 }