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 }