github.com/eliastor/durgaform@v0.0.0-20220816172711-d0ab2d17673e/internal/getproviders/registry_client.go (about) 1 package getproviders 2 3 import ( 4 "context" 5 "crypto/sha256" 6 "encoding/hex" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "io/ioutil" 11 "log" 12 "net/http" 13 "net/url" 14 "os" 15 "path" 16 "strconv" 17 "time" 18 19 "github.com/hashicorp/go-retryablehttp" 20 svchost "github.com/hashicorp/terraform-svchost" 21 svcauth "github.com/hashicorp/terraform-svchost/auth" 22 23 "github.com/eliastor/durgaform/internal/addrs" 24 "github.com/eliastor/durgaform/internal/httpclient" 25 "github.com/eliastor/durgaform/internal/logging" 26 "github.com/eliastor/durgaform/version" 27 ) 28 29 const ( 30 durgaformVersionHeader = "X-Durgaform-Version" 31 32 // registryDiscoveryRetryEnvName is the name of the environment variable that 33 // can be configured to customize number of retries for module and provider 34 // discovery requests with the remote registry. 35 registryDiscoveryRetryEnvName = "TF_REGISTRY_DISCOVERY_RETRY" 36 defaultRetry = 1 37 38 // registryClientTimeoutEnvName is the name of the environment variable that 39 // can be configured to customize the timeout duration (seconds) for module 40 // and provider discovery with the remote registry. 41 registryClientTimeoutEnvName = "TF_REGISTRY_CLIENT_TIMEOUT" 42 43 // defaultRequestTimeout is the default timeout duration for requests to the 44 // remote registry. 45 defaultRequestTimeout = 10 * time.Second 46 ) 47 48 var ( 49 discoveryRetry int 50 requestTimeout time.Duration 51 ) 52 53 func init() { 54 configureDiscoveryRetry() 55 configureRequestTimeout() 56 } 57 58 var SupportedPluginProtocols = MustParseVersionConstraints(">= 5, <7") 59 60 // registryClient is a client for the provider registry protocol that is 61 // specialized only for the needs of this package. It's not intended as a 62 // general registry API client. 63 type registryClient struct { 64 baseURL *url.URL 65 creds svcauth.HostCredentials 66 67 httpClient *retryablehttp.Client 68 } 69 70 func newRegistryClient(baseURL *url.URL, creds svcauth.HostCredentials) *registryClient { 71 httpClient := httpclient.New() 72 httpClient.Timeout = requestTimeout 73 74 retryableClient := retryablehttp.NewClient() 75 retryableClient.HTTPClient = httpClient 76 retryableClient.RetryMax = discoveryRetry 77 retryableClient.RequestLogHook = requestLogHook 78 retryableClient.ErrorHandler = maxRetryErrorHandler 79 80 retryableClient.Logger = log.New(logging.LogOutput(), "", log.Flags()) 81 82 return ®istryClient{ 83 baseURL: baseURL, 84 creds: creds, 85 httpClient: retryableClient, 86 } 87 } 88 89 // ProviderVersions returns the raw version and protocol strings produced by the 90 // registry for the given provider. 91 // 92 // The returned error will be ErrRegistryProviderNotKnown if the registry responds with 93 // 404 Not Found to indicate that the namespace or provider type are not known, 94 // ErrUnauthorized if the registry responds with 401 or 403 status codes, or 95 // ErrQueryFailed for any other protocol or operational problem. 96 func (c *registryClient) ProviderVersions(ctx context.Context, addr addrs.Provider) (map[string][]string, []string, error) { 97 endpointPath, err := url.Parse(path.Join(addr.Namespace, addr.Type, "versions")) 98 if err != nil { 99 // Should never happen because we're constructing this from 100 // already-validated components. 101 return nil, nil, err 102 } 103 endpointURL := c.baseURL.ResolveReference(endpointPath) 104 req, err := retryablehttp.NewRequest("GET", endpointURL.String(), nil) 105 if err != nil { 106 return nil, nil, err 107 } 108 req = req.WithContext(ctx) 109 c.addHeadersToRequest(req.Request) 110 111 resp, err := c.httpClient.Do(req) 112 if err != nil { 113 return nil, nil, c.errQueryFailed(addr, err) 114 } 115 defer resp.Body.Close() 116 117 switch resp.StatusCode { 118 case http.StatusOK: 119 // Great! 120 case http.StatusNotFound: 121 return nil, nil, ErrRegistryProviderNotKnown{ 122 Provider: addr, 123 } 124 case http.StatusUnauthorized, http.StatusForbidden: 125 return nil, nil, c.errUnauthorized(addr.Hostname) 126 default: 127 return nil, nil, c.errQueryFailed(addr, errors.New(resp.Status)) 128 } 129 130 // We ignore the platforms portion of the response body, because the 131 // installer verifies the platform compatibility after pulling a provider 132 // versions' metadata. 133 type ResponseBody struct { 134 Versions []struct { 135 Version string `json:"version"` 136 Protocols []string `json:"protocols"` 137 } `json:"versions"` 138 Warnings []string `json:"warnings"` 139 } 140 var body ResponseBody 141 142 dec := json.NewDecoder(resp.Body) 143 if err := dec.Decode(&body); err != nil { 144 return nil, nil, c.errQueryFailed(addr, err) 145 } 146 147 if len(body.Versions) == 0 { 148 return nil, body.Warnings, nil 149 } 150 151 ret := make(map[string][]string, len(body.Versions)) 152 for _, v := range body.Versions { 153 ret[v.Version] = v.Protocols 154 } 155 156 return ret, body.Warnings, nil 157 } 158 159 // PackageMeta returns metadata about a distribution package for a provider. 160 // 161 // The returned error will be one of the following: 162 // 163 // - ErrPlatformNotSupported if the registry responds with 404 Not Found, 164 // under the assumption that the caller previously checked that the provider 165 // and version are valid. 166 // - ErrProtocolNotSupported if the requested provider version's protocols are not 167 // supported by this version of durgaform. 168 // - ErrUnauthorized if the registry responds with 401 or 403 status codes 169 // - ErrQueryFailed for any other operational problem. 170 func (c *registryClient) PackageMeta(ctx context.Context, provider addrs.Provider, version Version, target Platform) (PackageMeta, error) { 171 endpointPath, err := url.Parse(path.Join( 172 provider.Namespace, 173 provider.Type, 174 version.String(), 175 "download", 176 target.OS, 177 target.Arch, 178 )) 179 if err != nil { 180 // Should never happen because we're constructing this from 181 // already-validated components. 182 return PackageMeta{}, err 183 } 184 endpointURL := c.baseURL.ResolveReference(endpointPath) 185 186 req, err := retryablehttp.NewRequest("GET", endpointURL.String(), nil) 187 if err != nil { 188 return PackageMeta{}, err 189 } 190 req = req.WithContext(ctx) 191 c.addHeadersToRequest(req.Request) 192 193 resp, err := c.httpClient.Do(req) 194 if err != nil { 195 return PackageMeta{}, c.errQueryFailed(provider, err) 196 } 197 defer resp.Body.Close() 198 199 switch resp.StatusCode { 200 case http.StatusOK: 201 // Great! 202 case http.StatusNotFound: 203 return PackageMeta{}, ErrPlatformNotSupported{ 204 Provider: provider, 205 Version: version, 206 Platform: target, 207 } 208 case http.StatusUnauthorized, http.StatusForbidden: 209 return PackageMeta{}, c.errUnauthorized(provider.Hostname) 210 default: 211 return PackageMeta{}, c.errQueryFailed(provider, errors.New(resp.Status)) 212 } 213 214 type SigningKeyList struct { 215 GPGPublicKeys []*SigningKey `json:"gpg_public_keys"` 216 } 217 type ResponseBody struct { 218 Protocols []string `json:"protocols"` 219 OS string `json:"os"` 220 Arch string `json:"arch"` 221 Filename string `json:"filename"` 222 DownloadURL string `json:"download_url"` 223 SHA256Sum string `json:"shasum"` 224 225 SHA256SumsURL string `json:"shasums_url"` 226 SHA256SumsSignatureURL string `json:"shasums_signature_url"` 227 228 SigningKeys SigningKeyList `json:"signing_keys"` 229 } 230 var body ResponseBody 231 232 dec := json.NewDecoder(resp.Body) 233 if err := dec.Decode(&body); err != nil { 234 return PackageMeta{}, c.errQueryFailed(provider, err) 235 } 236 237 var protoVersions VersionList 238 for _, versionStr := range body.Protocols { 239 v, err := ParseVersion(versionStr) 240 if err != nil { 241 return PackageMeta{}, c.errQueryFailed( 242 provider, 243 fmt.Errorf("registry response includes invalid version string %q: %s", versionStr, err), 244 ) 245 } 246 protoVersions = append(protoVersions, v) 247 } 248 protoVersions.Sort() 249 250 // Verify that this version of durgaform supports the providers' protocol 251 // version(s) 252 if len(protoVersions) > 0 { 253 supportedProtos := MeetingConstraints(SupportedPluginProtocols) 254 protoErr := ErrProtocolNotSupported{ 255 Provider: provider, 256 Version: version, 257 } 258 match := false 259 for _, version := range protoVersions { 260 if supportedProtos.Has(version) { 261 match = true 262 } 263 } 264 if !match { 265 // If the protocol version is not supported, try to find the closest 266 // matching version. 267 closest, err := c.findClosestProtocolCompatibleVersion(ctx, provider, version) 268 if err != nil { 269 return PackageMeta{}, err 270 } 271 protoErr.Suggestion = closest 272 return PackageMeta{}, protoErr 273 } 274 } 275 276 if body.OS != target.OS || body.Arch != target.Arch { 277 return PackageMeta{}, fmt.Errorf("registry response to request for %s archive has incorrect target %s", target, Platform{body.OS, body.Arch}) 278 } 279 280 downloadURL, err := url.Parse(body.DownloadURL) 281 if err != nil { 282 return PackageMeta{}, fmt.Errorf("registry response includes invalid download URL: %s", err) 283 } 284 downloadURL = resp.Request.URL.ResolveReference(downloadURL) 285 if downloadURL.Scheme != "http" && downloadURL.Scheme != "https" { 286 return PackageMeta{}, fmt.Errorf("registry response includes invalid download URL: must use http or https scheme") 287 } 288 289 ret := PackageMeta{ 290 Provider: provider, 291 Version: version, 292 ProtocolVersions: protoVersions, 293 TargetPlatform: Platform{ 294 OS: body.OS, 295 Arch: body.Arch, 296 }, 297 Filename: body.Filename, 298 Location: PackageHTTPURL(downloadURL.String()), 299 // "Authentication" is populated below 300 } 301 302 if len(body.SHA256Sum) != sha256.Size*2 { // *2 because it's hex-encoded 303 return PackageMeta{}, c.errQueryFailed( 304 provider, 305 fmt.Errorf("registry response includes invalid SHA256 hash %q: %s", body.SHA256Sum, err), 306 ) 307 } 308 309 var checksum [sha256.Size]byte 310 _, err = hex.Decode(checksum[:], []byte(body.SHA256Sum)) 311 if err != nil { 312 return PackageMeta{}, c.errQueryFailed( 313 provider, 314 fmt.Errorf("registry response includes invalid SHA256 hash %q: %s", body.SHA256Sum, err), 315 ) 316 } 317 318 shasumsURL, err := url.Parse(body.SHA256SumsURL) 319 if err != nil { 320 return PackageMeta{}, fmt.Errorf("registry response includes invalid SHASUMS URL: %s", err) 321 } 322 shasumsURL = resp.Request.URL.ResolveReference(shasumsURL) 323 if shasumsURL.Scheme != "http" && shasumsURL.Scheme != "https" { 324 return PackageMeta{}, fmt.Errorf("registry response includes invalid SHASUMS URL: must use http or https scheme") 325 } 326 document, err := c.getFile(shasumsURL) 327 if err != nil { 328 return PackageMeta{}, c.errQueryFailed( 329 provider, 330 fmt.Errorf("failed to retrieve authentication checksums for provider: %s", err), 331 ) 332 } 333 signatureURL, err := url.Parse(body.SHA256SumsSignatureURL) 334 if err != nil { 335 return PackageMeta{}, fmt.Errorf("registry response includes invalid SHASUMS signature URL: %s", err) 336 } 337 signatureURL = resp.Request.URL.ResolveReference(signatureURL) 338 if signatureURL.Scheme != "http" && signatureURL.Scheme != "https" { 339 return PackageMeta{}, fmt.Errorf("registry response includes invalid SHASUMS signature URL: must use http or https scheme") 340 } 341 signature, err := c.getFile(signatureURL) 342 if err != nil { 343 return PackageMeta{}, c.errQueryFailed( 344 provider, 345 fmt.Errorf("failed to retrieve cryptographic signature for provider: %s", err), 346 ) 347 } 348 349 keys := make([]SigningKey, len(body.SigningKeys.GPGPublicKeys)) 350 for i, key := range body.SigningKeys.GPGPublicKeys { 351 keys[i] = *key 352 } 353 354 ret.Authentication = PackageAuthenticationAll( 355 NewMatchingChecksumAuthentication(document, body.Filename, checksum), 356 NewArchiveChecksumAuthentication(ret.TargetPlatform, checksum), 357 NewSignatureAuthentication(document, signature, keys), 358 ) 359 360 return ret, nil 361 } 362 363 // findClosestProtocolCompatibleVersion searches for the provider version with the closest protocol match. 364 func (c *registryClient) findClosestProtocolCompatibleVersion(ctx context.Context, provider addrs.Provider, version Version) (Version, error) { 365 var match Version 366 available, _, err := c.ProviderVersions(ctx, provider) 367 if err != nil { 368 return UnspecifiedVersion, err 369 } 370 371 // extract the maps keys so we can make a sorted list of available versions. 372 versionList := make(VersionList, 0, len(available)) 373 for versionStr := range available { 374 v, err := ParseVersion(versionStr) 375 if err != nil { 376 return UnspecifiedVersion, ErrQueryFailed{ 377 Provider: provider, 378 Wrapped: fmt.Errorf("registry response includes invalid version string %q: %s", versionStr, err), 379 } 380 } 381 versionList = append(versionList, v) 382 } 383 versionList.Sort() // lowest precedence first, preserving order when equal precedence 384 385 protoVersions := MeetingConstraints(SupportedPluginProtocols) 386 FindMatch: 387 // put the versions in increasing order of precedence 388 for index := len(versionList) - 1; index >= 0; index-- { // walk backwards to consider newer versions first 389 for _, protoStr := range available[versionList[index].String()] { 390 p, err := ParseVersion(protoStr) 391 if err != nil { 392 return UnspecifiedVersion, ErrQueryFailed{ 393 Provider: provider, 394 Wrapped: fmt.Errorf("registry response includes invalid protocol string %q: %s", protoStr, err), 395 } 396 } 397 if protoVersions.Has(p) { 398 match = versionList[index] 399 break FindMatch 400 } 401 } 402 } 403 return match, nil 404 } 405 406 func (c *registryClient) addHeadersToRequest(req *http.Request) { 407 if c.creds != nil { 408 c.creds.PrepareRequest(req) 409 } 410 req.Header.Set(durgaformVersionHeader, version.String()) 411 } 412 413 func (c *registryClient) errQueryFailed(provider addrs.Provider, err error) error { 414 if err == context.Canceled { 415 // This one has a special error type so that callers can 416 // handle it in a different way. 417 return ErrRequestCanceled{} 418 } 419 return ErrQueryFailed{ 420 Provider: provider, 421 Wrapped: err, 422 } 423 } 424 425 func (c *registryClient) errUnauthorized(hostname svchost.Hostname) error { 426 return ErrUnauthorized{ 427 Hostname: hostname, 428 HaveCredentials: c.creds != nil, 429 } 430 } 431 432 func (c *registryClient) getFile(url *url.URL) ([]byte, error) { 433 resp, err := c.httpClient.Get(url.String()) 434 if err != nil { 435 return nil, err 436 } 437 defer resp.Body.Close() 438 439 if resp.StatusCode != http.StatusOK { 440 return nil, fmt.Errorf("%s returned from %s", resp.Status, HostFromRequest(resp.Request)) 441 } 442 443 data, err := ioutil.ReadAll(resp.Body) 444 if err != nil { 445 return data, err 446 } 447 448 return data, nil 449 } 450 451 // configureDiscoveryRetry configures the number of retries the registry client 452 // will attempt for requests with retryable errors, like 502 status codes 453 func configureDiscoveryRetry() { 454 discoveryRetry = defaultRetry 455 456 if v := os.Getenv(registryDiscoveryRetryEnvName); v != "" { 457 retry, err := strconv.Atoi(v) 458 if err == nil && retry > 0 { 459 discoveryRetry = retry 460 } 461 } 462 } 463 464 func requestLogHook(logger retryablehttp.Logger, req *http.Request, i int) { 465 if i > 0 { 466 logger.Printf("[INFO] Previous request to the remote registry failed, attempting retry.") 467 } 468 } 469 470 func maxRetryErrorHandler(resp *http.Response, err error, numTries int) (*http.Response, error) { 471 // Close the body per library instructions 472 if resp != nil { 473 resp.Body.Close() 474 } 475 476 // Additional error detail: if we have a response, use the status code; 477 // if we have an error, use that; otherwise nothing. We will never have 478 // both response and error. 479 var errMsg string 480 if resp != nil { 481 errMsg = fmt.Sprintf(": %s returned from %s", resp.Status, HostFromRequest(resp.Request)) 482 } else if err != nil { 483 errMsg = fmt.Sprintf(": %s", err) 484 } 485 486 // This function is always called with numTries=RetryMax+1. If we made any 487 // retry attempts, include that in the error message. 488 if numTries > 1 { 489 return resp, fmt.Errorf("the request failed after %d attempts, please try again later%s", 490 numTries, errMsg) 491 } 492 return resp, fmt.Errorf("the request failed, please try again later%s", errMsg) 493 } 494 495 // HostFromRequest extracts host the same way net/http Request.Write would, 496 // accounting for empty Request.Host 497 func HostFromRequest(req *http.Request) string { 498 if req.Host != "" { 499 return req.Host 500 } 501 if req.URL != nil { 502 return req.URL.Host 503 } 504 505 // this should never happen and if it does 506 // it will be handled as part of Request.Write() 507 // https://cs.opensource.google/go/go/+/refs/tags/go1.18.4:src/net/http/request.go;l=574 508 return "" 509 } 510 511 // configureRequestTimeout configures the registry client request timeout from 512 // environment variables 513 func configureRequestTimeout() { 514 requestTimeout = defaultRequestTimeout 515 516 if v := os.Getenv(registryClientTimeoutEnvName); v != "" { 517 timeout, err := strconv.Atoi(v) 518 if err == nil && timeout > 0 { 519 requestTimeout = time.Duration(timeout) * time.Second 520 } 521 } 522 }