github.com/jaredpalmer/terraform@v1.1.0-alpha20210908.0.20210911170307-88705c943a03/internal/registry/client.go (about)

     1  package registry
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"io/ioutil"
     7  	"log"
     8  	"net/http"
     9  	"net/url"
    10  	"os"
    11  	"path"
    12  	"strconv"
    13  	"strings"
    14  	"time"
    15  
    16  	"github.com/hashicorp/go-retryablehttp"
    17  	svchost "github.com/hashicorp/terraform-svchost"
    18  	"github.com/hashicorp/terraform-svchost/disco"
    19  	"github.com/hashicorp/terraform/internal/httpclient"
    20  	"github.com/hashicorp/terraform/internal/logging"
    21  	"github.com/hashicorp/terraform/internal/registry/regsrc"
    22  	"github.com/hashicorp/terraform/internal/registry/response"
    23  	"github.com/hashicorp/terraform/version"
    24  )
    25  
    26  const (
    27  	xTerraformGet      = "X-Terraform-Get"
    28  	xTerraformVersion  = "X-Terraform-Version"
    29  	modulesServiceID   = "modules.v1"
    30  	providersServiceID = "providers.v1"
    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  	tfVersion = version.String()
    50  
    51  	discoveryRetry int
    52  	requestTimeout time.Duration
    53  )
    54  
    55  func init() {
    56  	configureDiscoveryRetry()
    57  	configureRequestTimeout()
    58  }
    59  
    60  // Client provides methods to query Terraform Registries.
    61  type Client struct {
    62  	// this is the client to be used for all requests.
    63  	client *retryablehttp.Client
    64  
    65  	// services is a required *disco.Disco, which may have services and
    66  	// credentials pre-loaded.
    67  	services *disco.Disco
    68  }
    69  
    70  // NewClient returns a new initialized registry client.
    71  func NewClient(services *disco.Disco, client *http.Client) *Client {
    72  	if services == nil {
    73  		services = disco.New()
    74  	}
    75  
    76  	if client == nil {
    77  		client = httpclient.New()
    78  		client.Timeout = requestTimeout
    79  	}
    80  	retryableClient := retryablehttp.NewClient()
    81  	retryableClient.HTTPClient = client
    82  	retryableClient.RetryMax = discoveryRetry
    83  	retryableClient.RequestLogHook = requestLogHook
    84  	retryableClient.ErrorHandler = maxRetryErrorHandler
    85  
    86  	logOutput := logging.LogOutput()
    87  	retryableClient.Logger = log.New(logOutput, "", log.Flags())
    88  
    89  	services.Transport = retryableClient.HTTPClient.Transport
    90  
    91  	services.SetUserAgent(httpclient.TerraformUserAgent(version.String()))
    92  
    93  	return &Client{
    94  		client:   retryableClient,
    95  		services: services,
    96  	}
    97  }
    98  
    99  // Discover queries the host, and returns the url for the registry.
   100  func (c *Client) Discover(host svchost.Hostname, serviceID string) (*url.URL, error) {
   101  	service, err := c.services.DiscoverServiceURL(host, serviceID)
   102  	if err != nil {
   103  		return nil, &ServiceUnreachableError{err}
   104  	}
   105  	if !strings.HasSuffix(service.Path, "/") {
   106  		service.Path += "/"
   107  	}
   108  	return service, nil
   109  }
   110  
   111  // ModuleVersions queries the registry for a module, and returns the available versions.
   112  func (c *Client) ModuleVersions(module *regsrc.Module) (*response.ModuleVersions, error) {
   113  	host, err := module.SvcHost()
   114  	if err != nil {
   115  		return nil, err
   116  	}
   117  
   118  	service, err := c.Discover(host, modulesServiceID)
   119  	if err != nil {
   120  		return nil, err
   121  	}
   122  
   123  	p, err := url.Parse(path.Join(module.Module(), "versions"))
   124  	if err != nil {
   125  		return nil, err
   126  	}
   127  
   128  	service = service.ResolveReference(p)
   129  
   130  	log.Printf("[DEBUG] fetching module versions from %q", service)
   131  
   132  	req, err := retryablehttp.NewRequest("GET", service.String(), nil)
   133  	if err != nil {
   134  		return nil, err
   135  	}
   136  
   137  	c.addRequestCreds(host, req.Request)
   138  	req.Header.Set(xTerraformVersion, tfVersion)
   139  
   140  	resp, err := c.client.Do(req)
   141  	if err != nil {
   142  		return nil, err
   143  	}
   144  	defer resp.Body.Close()
   145  
   146  	switch resp.StatusCode {
   147  	case http.StatusOK:
   148  		// OK
   149  	case http.StatusNotFound:
   150  		return nil, &errModuleNotFound{addr: module}
   151  	default:
   152  		return nil, fmt.Errorf("error looking up module versions: %s", resp.Status)
   153  	}
   154  
   155  	var versions response.ModuleVersions
   156  
   157  	dec := json.NewDecoder(resp.Body)
   158  	if err := dec.Decode(&versions); err != nil {
   159  		return nil, err
   160  	}
   161  
   162  	for _, mod := range versions.Modules {
   163  		for _, v := range mod.Versions {
   164  			log.Printf("[DEBUG] found available version %q for %s", v.Version, mod.Source)
   165  		}
   166  	}
   167  
   168  	return &versions, nil
   169  }
   170  
   171  func (c *Client) addRequestCreds(host svchost.Hostname, req *http.Request) {
   172  	creds, err := c.services.CredentialsForHost(host)
   173  	if err != nil {
   174  		log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", host, err)
   175  		return
   176  	}
   177  
   178  	if creds != nil {
   179  		creds.PrepareRequest(req)
   180  	}
   181  }
   182  
   183  // ModuleLocation find the download location for a specific version module.
   184  // This returns a string, because the final location may contain special go-getter syntax.
   185  func (c *Client) ModuleLocation(module *regsrc.Module, version string) (string, error) {
   186  	host, err := module.SvcHost()
   187  	if err != nil {
   188  		return "", err
   189  	}
   190  
   191  	service, err := c.Discover(host, modulesServiceID)
   192  	if err != nil {
   193  		return "", err
   194  	}
   195  
   196  	var p *url.URL
   197  	if version == "" {
   198  		p, err = url.Parse(path.Join(module.Module(), "download"))
   199  	} else {
   200  		p, err = url.Parse(path.Join(module.Module(), version, "download"))
   201  	}
   202  	if err != nil {
   203  		return "", err
   204  	}
   205  	download := service.ResolveReference(p)
   206  
   207  	log.Printf("[DEBUG] looking up module location from %q", download)
   208  
   209  	req, err := retryablehttp.NewRequest("GET", download.String(), nil)
   210  	if err != nil {
   211  		return "", err
   212  	}
   213  
   214  	c.addRequestCreds(host, req.Request)
   215  	req.Header.Set(xTerraformVersion, tfVersion)
   216  
   217  	resp, err := c.client.Do(req)
   218  	if err != nil {
   219  		return "", err
   220  	}
   221  	defer resp.Body.Close()
   222  
   223  	// there should be no body, but save it for logging
   224  	body, err := ioutil.ReadAll(resp.Body)
   225  	if err != nil {
   226  		return "", fmt.Errorf("error reading response body from registry: %s", err)
   227  	}
   228  
   229  	switch resp.StatusCode {
   230  	case http.StatusOK, http.StatusNoContent:
   231  		// OK
   232  	case http.StatusNotFound:
   233  		return "", fmt.Errorf("module %q version %q not found", module, version)
   234  	default:
   235  		// anything else is an error:
   236  		return "", fmt.Errorf("error getting download location for %q: %s resp:%s", module, resp.Status, body)
   237  	}
   238  
   239  	// the download location is in the X-Terraform-Get header
   240  	location := resp.Header.Get(xTerraformGet)
   241  	if location == "" {
   242  		return "", fmt.Errorf("failed to get download URL for %q: %s resp:%s", module, resp.Status, body)
   243  	}
   244  
   245  	// If location looks like it's trying to be a relative URL, treat it as
   246  	// one.
   247  	//
   248  	// We don't do this for just _any_ location, since the X-Terraform-Get
   249  	// header is a go-getter location rather than a URL, and so not all
   250  	// possible values will parse reasonably as URLs.)
   251  	//
   252  	// When used in conjunction with go-getter we normally require this header
   253  	// to be an absolute URL, but we are more liberal here because third-party
   254  	// registry implementations may not "know" their own absolute URLs if
   255  	// e.g. they are running behind a reverse proxy frontend, or such.
   256  	if strings.HasPrefix(location, "/") || strings.HasPrefix(location, "./") || strings.HasPrefix(location, "../") {
   257  		locationURL, err := url.Parse(location)
   258  		if err != nil {
   259  			return "", fmt.Errorf("invalid relative URL for %q: %s", module, err)
   260  		}
   261  		locationURL = download.ResolveReference(locationURL)
   262  		location = locationURL.String()
   263  	}
   264  
   265  	return location, nil
   266  }
   267  
   268  // configureDiscoveryRetry configures the number of retries the registry client
   269  // will attempt for requests with retryable errors, like 502 status codes
   270  func configureDiscoveryRetry() {
   271  	discoveryRetry = defaultRetry
   272  
   273  	if v := os.Getenv(registryDiscoveryRetryEnvName); v != "" {
   274  		retry, err := strconv.Atoi(v)
   275  		if err == nil && retry > 0 {
   276  			discoveryRetry = retry
   277  		}
   278  	}
   279  }
   280  
   281  func requestLogHook(logger retryablehttp.Logger, req *http.Request, i int) {
   282  	if i > 0 {
   283  		logger.Printf("[INFO] Previous request to the remote registry failed, attempting retry.")
   284  	}
   285  }
   286  
   287  func maxRetryErrorHandler(resp *http.Response, err error, numTries int) (*http.Response, error) {
   288  	// Close the body per library instructions
   289  	if resp != nil {
   290  		resp.Body.Close()
   291  	}
   292  
   293  	// Additional error detail: if we have a response, use the status code;
   294  	// if we have an error, use that; otherwise nothing. We will never have
   295  	// both response and error.
   296  	var errMsg string
   297  	if resp != nil {
   298  		errMsg = fmt.Sprintf(": %s returned from %s", resp.Status, resp.Request.URL)
   299  	} else if err != nil {
   300  		errMsg = fmt.Sprintf(": %s", err)
   301  	}
   302  
   303  	// This function is always called with numTries=RetryMax+1. If we made any
   304  	// retry attempts, include that in the error message.
   305  	if numTries > 1 {
   306  		return resp, fmt.Errorf("the request failed after %d attempts, please try again later%s",
   307  			numTries, errMsg)
   308  	}
   309  	return resp, fmt.Errorf("the request failed, please try again later%s", errMsg)
   310  }
   311  
   312  // configureRequestTimeout configures the registry client request timeout from
   313  // environment variables
   314  func configureRequestTimeout() {
   315  	requestTimeout = defaultRequestTimeout
   316  
   317  	if v := os.Getenv(registryClientTimeoutEnvName); v != "" {
   318  		timeout, err := strconv.Atoi(v)
   319  		if err == nil && timeout > 0 {
   320  			requestTimeout = time.Duration(timeout) * time.Second
   321  		}
   322  	}
   323  }