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 &registryClient{
    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  }