| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package getproviders | |
| import ( | |
| "context" | |
| "crypto/sha256" | |
| "encoding/hex" | |
| "encoding/json" | |
| "errors" | |
| "fmt" | |
| "io/ioutil" | |
| "log" | |
| "net/http" | |
| "net/url" | |
| "os" | |
| "path" | |
| "strconv" | |
| "time" | |
| "github.com/hashicorp/go-retryablehttp" | |
| svchost "github.com/hashicorp/terraform-svchost" | |
| svcauth "github.com/hashicorp/terraform-svchost/auth" | |
| "github.com/hashicorp/terraform/internal/addrs" | |
| "github.com/hashicorp/terraform/internal/httpclient" | |
| "github.com/hashicorp/terraform/internal/logging" | |
| "github.com/hashicorp/terraform/version" | |
| ) | |
| const ( | |
| terraformVersionHeader = "X-Terraform-Version" | |
| // registryDiscoveryRetryEnvName is the name of the environment variable that | |
| // can be configured to customize number of retries for module and provider | |
| // discovery requests with the remote registry. | |
| registryDiscoveryRetryEnvName = "TF_REGISTRY_DISCOVERY_RETRY" | |
| defaultRetry = 1 | |
| // registryClientTimeoutEnvName is the name of the environment variable that | |
| // can be configured to customize the timeout duration (seconds) for module | |
| // and provider discovery with the remote registry. | |
| registryClientTimeoutEnvName = "TF_REGISTRY_CLIENT_TIMEOUT" | |
| // defaultRequestTimeout is the default timeout duration for requests to the | |
| // remote registry. | |
| defaultRequestTimeout = 10 * time.Second | |
| ) | |
| var ( | |
| discoveryRetry int | |
| requestTimeout time.Duration | |
| ) | |
| func init() { | |
| configureDiscoveryRetry() | |
| configureRequestTimeout() | |
| } | |
| var SupportedPluginProtocols = MustParseVersionConstraints(">= 5, <7") | |
| // registryClient is a client for the provider registry protocol that is | |
| // specialized only for the needs of this package. It's not intended as a | |
| // general registry API client. | |
| type registryClient struct { | |
| baseURL *url.URL | |
| creds svcauth.HostCredentials | |
| httpClient *retryablehttp.Client | |
| } | |
| func newRegistryClient(baseURL *url.URL, creds svcauth.HostCredentials) *registryClient { | |
| httpClient := httpclient.New() | |
| httpClient.Timeout = requestTimeout | |
| retryableClient := retryablehttp.NewClient() | |
| retryableClient.HTTPClient = httpClient | |
| retryableClient.RetryMax = discoveryRetry | |
| retryableClient.RequestLogHook = requestLogHook | |
| retryableClient.ErrorHandler = maxRetryErrorHandler | |
| retryableClient.Logger = log.New(logging.LogOutput(), "", log.Flags()) | |
| return ®istryClient{ | |
| baseURL: baseURL, | |
| creds: creds, | |
| httpClient: retryableClient, | |
| } | |
| } | |
| // ProviderVersions returns the raw version and protocol strings produced by the | |
| // registry for the given provider. | |
| // | |
| // The returned error will be ErrRegistryProviderNotKnown if the registry responds with | |
| // 404 Not Found to indicate that the namespace or provider type are not known, | |
| // ErrUnauthorized if the registry responds with 401 or 403 status codes, or | |
| // ErrQueryFailed for any other protocol or operational problem. | |
| func (c *registryClient) ProviderVersions(ctx context.Context, addr addrs.Provider) (map[string][]string, []string, error) { | |
| endpointPath, err := url.Parse(path.Join(addr.Namespace, addr.Type, "versions")) | |
| if err != nil { | |
| // Should never happen because we're constructing this from | |
| // already-validated components. | |
| return nil, nil, err | |
| } | |
| endpointURL := c.baseURL.ResolveReference(endpointPath) | |
| req, err := retryablehttp.NewRequest("GET", endpointURL.String(), nil) | |
| if err != nil { | |
| return nil, nil, err | |
| } | |
| req = req.WithContext(ctx) | |
| c.addHeadersToRequest(req.Request) | |
| resp, err := c.httpClient.Do(req) | |
| if err != nil { | |
| return nil, nil, c.errQueryFailed(addr, err) | |
| } | |
| defer resp.Body.Close() | |
| switch resp.StatusCode { | |
| case http.StatusOK: | |
| // Great! | |
| case http.StatusNotFound: | |
| return nil, nil, ErrRegistryProviderNotKnown{ | |
| Provider: addr, | |
| } | |
| case http.StatusUnauthorized, http.StatusForbidden: | |
| return nil, nil, c.errUnauthorized(addr.Hostname) | |
| default: | |
| return nil, nil, c.errQueryFailed(addr, errors.New(resp.Status)) | |
| } | |
| // We ignore the platforms portion of the response body, because the | |
| // installer verifies the platform compatibility after pulling a provider | |
| // versions' metadata. | |
| type ResponseBody struct { | |
| Versions []struct { | |
| Version string `json:"version"` | |
| Protocols []string `json:"protocols"` | |
| } `json:"versions"` | |
| Warnings []string `json:"warnings"` | |
| } | |
| var body ResponseBody | |
| dec := json.NewDecoder(resp.Body) | |
| if err := dec.Decode(&body); err != nil { | |
| return nil, nil, c.errQueryFailed(addr, err) | |
| } | |
| if len(body.Versions) == 0 { | |
| return nil, body.Warnings, nil | |
| } | |
| ret := make(map[string][]string, len(body.Versions)) | |
| for _, v := range body.Versions { | |
| ret[v.Version] = v.Protocols | |
| } | |
| return ret, body.Warnings, nil | |
| } | |
| // PackageMeta returns metadata about a distribution package for a provider. | |
| // | |
| // The returned error will be one of the following: | |
| // | |
| // - ErrPlatformNotSupported if the registry responds with 404 Not Found, | |
| // under the assumption that the caller previously checked that the provider | |
| // and version are valid. | |
| // - ErrProtocolNotSupported if the requested provider version's protocols are not | |
| // supported by this version of terraform. | |
| // - ErrUnauthorized if the registry responds with 401 or 403 status codes | |
| // - ErrQueryFailed for any other operational problem. | |
| func (c *registryClient) PackageMeta(ctx context.Context, provider addrs.Provider, version Version, target Platform) (PackageMeta, error) { | |
| endpointPath, err := url.Parse(path.Join( | |
| provider.Namespace, | |
| provider.Type, | |
| version.String(), | |
| "download", | |
| target.OS, | |
| target.Arch, | |
| )) | |
| if err != nil { | |
| // Should never happen because we're constructing this from | |
| // already-validated components. | |
| return PackageMeta{}, err | |
| } | |
| endpointURL := c.baseURL.ResolveReference(endpointPath) | |
| req, err := retryablehttp.NewRequest("GET", endpointURL.String(), nil) | |
| if err != nil { | |
| return PackageMeta{}, err | |
| } | |
| req = req.WithContext(ctx) | |
| c.addHeadersToRequest(req.Request) | |
| resp, err := c.httpClient.Do(req) | |
| if err != nil { | |
| return PackageMeta{}, c.errQueryFailed(provider, err) | |
| } | |
| defer resp.Body.Close() | |
| switch resp.StatusCode { | |
| case http.StatusOK: | |
| // Great! | |
| case http.StatusNotFound: | |
| return PackageMeta{}, ErrPlatformNotSupported{ | |
| Provider: provider, | |
| Version: version, | |
| Platform: target, | |
| } | |
| case http.StatusUnauthorized, http.StatusForbidden: | |
| return PackageMeta{}, c.errUnauthorized(provider.Hostname) | |
| default: | |
| return PackageMeta{}, c.errQueryFailed(provider, errors.New(resp.Status)) | |
| } | |
| type SigningKeyList struct { | |
| GPGPublicKeys []*SigningKey `json:"gpg_public_keys"` | |
| } | |
| type ResponseBody struct { | |
| Protocols []string `json:"protocols"` | |
| OS string `json:"os"` | |
| Arch string `json:"arch"` | |
| Filename string `json:"filename"` | |
| DownloadURL string `json:"download_url"` | |
| SHA256Sum string `json:"shasum"` | |
| SHA256SumsURL string `json:"shasums_url"` | |
| SHA256SumsSignatureURL string `json:"shasums_signature_url"` | |
| SigningKeys SigningKeyList `json:"signing_keys"` | |
| } | |
| var body ResponseBody | |
| dec := json.NewDecoder(resp.Body) | |
| if err := dec.Decode(&body); err != nil { | |
| return PackageMeta{}, c.errQueryFailed(provider, err) | |
| } | |
| var protoVersions VersionList | |
| for _, versionStr := range body.Protocols { | |
| v, err := ParseVersion(versionStr) | |
| if err != nil { | |
| return PackageMeta{}, c.errQueryFailed( | |
| provider, | |
| fmt.Errorf("registry response includes invalid version string %q: %s", versionStr, err), | |
| ) | |
| } | |
| protoVersions = append(protoVersions, v) | |
| } | |
| protoVersions.Sort() | |
| // Verify that this version of terraform supports the providers' protocol | |
| // version(s) | |
| if len(protoVersions) > 0 { | |
| supportedProtos := MeetingConstraints(SupportedPluginProtocols) | |
| protoErr := ErrProtocolNotSupported{ | |
| Provider: provider, | |
| Version: version, | |
| } | |
| match := false | |
| for _, version := range protoVersions { | |
| if supportedProtos.Has(version) { | |
| match = true | |
| } | |
| } | |
| if !match { | |
| // If the protocol version is not supported, try to find the closest | |
| // matching version. | |
| closest, err := c.findClosestProtocolCompatibleVersion(ctx, provider, version) | |
| if err != nil { | |
| return PackageMeta{}, err | |
| } | |
| protoErr.Suggestion = closest | |
| return PackageMeta{}, protoErr | |
| } | |
| } | |
| if body.OS != target.OS || body.Arch != target.Arch { | |
| return PackageMeta{}, fmt.Errorf("registry response to request for %s archive has incorrect target %s", target, Platform{body.OS, body.Arch}) | |
| } | |
| downloadURL, err := url.Parse(body.DownloadURL) | |
| if err != nil { | |
| return PackageMeta{}, fmt.Errorf("registry response includes invalid download URL: %s", err) | |
| } | |
| downloadURL = resp.Request.URL.ResolveReference(downloadURL) | |
| if downloadURL.Scheme != "http" && downloadURL.Scheme != "https" { | |
| return PackageMeta{}, fmt.Errorf("registry response includes invalid download URL: must use http or https scheme") | |
| } | |
| ret := PackageMeta{ | |
| Provider: provider, | |
| Version: version, | |
| ProtocolVersions: protoVersions, | |
| TargetPlatform: Platform{ | |
| OS: body.OS, | |
| Arch: body.Arch, | |
| }, | |
| Filename: body.Filename, | |
| Location: PackageHTTPURL(downloadURL.String()), | |
| // "Authentication" is populated below | |
| } | |
| if len(body.SHA256Sum) != sha256.Size*2 { // *2 because it's hex-encoded | |
| return PackageMeta{}, c.errQueryFailed( | |
| provider, | |
| fmt.Errorf("registry response includes invalid SHA256 hash %q: %s", body.SHA256Sum, err), | |
| ) | |
| } | |
| var checksum [sha256.Size]byte | |
| _, err = hex.Decode(checksum[:], []byte(body.SHA256Sum)) | |
| if err != nil { | |
| return PackageMeta{}, c.errQueryFailed( | |
| provider, | |
| fmt.Errorf("registry response includes invalid SHA256 hash %q: %s", body.SHA256Sum, err), | |
| ) | |
| } | |
| shasumsURL, err := url.Parse(body.SHA256SumsURL) | |
| if err != nil { | |
| return PackageMeta{}, fmt.Errorf("registry response includes invalid SHASUMS URL: %s", err) | |
| } | |
| shasumsURL = resp.Request.URL.ResolveReference(shasumsURL) | |
| if shasumsURL.Scheme != "http" && shasumsURL.Scheme != "https" { | |
| return PackageMeta{}, fmt.Errorf("registry response includes invalid SHASUMS URL: must use http or https scheme") | |
| } | |
| document, err := c.getFile(shasumsURL) | |
| if err != nil { | |
| return PackageMeta{}, c.errQueryFailed( | |
| provider, | |
| fmt.Errorf("failed to retrieve authentication checksums for provider: %s", err), | |
| ) | |
| } | |
| signatureURL, err := url.Parse(body.SHA256SumsSignatureURL) | |
| if err != nil { | |
| return PackageMeta{}, fmt.Errorf("registry response includes invalid SHASUMS signature URL: %s", err) | |
| } | |
| signatureURL = resp.Request.URL.ResolveReference(signatureURL) | |
| if signatureURL.Scheme != "http" && signatureURL.Scheme != "https" { | |
| return PackageMeta{}, fmt.Errorf("registry response includes invalid SHASUMS signature URL: must use http or https scheme") | |
| } | |
| signature, err := c.getFile(signatureURL) | |
| if err != nil { | |
| return PackageMeta{}, c.errQueryFailed( | |
| provider, | |
| fmt.Errorf("failed to retrieve cryptographic signature for provider: %s", err), | |
| ) | |
| } | |
| keys := make([]SigningKey, len(body.SigningKeys.GPGPublicKeys)) | |
| for i, key := range body.SigningKeys.GPGPublicKeys { | |
| keys[i] = *key | |
| } | |
| ret.Authentication = PackageAuthenticationAll( | |
| NewMatchingChecksumAuthentication(document, body.Filename, checksum), | |
| NewArchiveChecksumAuthentication(ret.TargetPlatform, checksum), | |
| NewSignatureAuthentication(document, signature, keys), | |
| ) | |
| return ret, nil | |
| } | |
| // findClosestProtocolCompatibleVersion searches for the provider version with the closest protocol match. | |
| func (c *registryClient) findClosestProtocolCompatibleVersion(ctx context.Context, provider addrs.Provider, version Version) (Version, error) { | |
| var match Version | |
| available, _, err := c.ProviderVersions(ctx, provider) | |
| if err != nil { | |
| return UnspecifiedVersion, err | |
| } | |
| // extract the maps keys so we can make a sorted list of available versions. | |
| versionList := make(VersionList, 0, len(available)) | |
| for versionStr := range available { | |
| v, err := ParseVersion(versionStr) | |
| if err != nil { | |
| log.Printf("[WARN] registry response includes invalid version string %q. skipping: %s", versionStr, err) | |
| continue | |
| } | |
| versionList = append(versionList, v) | |
| } | |
| versionList.Sort() // lowest precedence first, preserving order when equal precedence | |
| protoVersions := MeetingConstraints(SupportedPluginProtocols) | |
| FindMatch: | |
| // put the versions in increasing order of precedence | |
| for index := len(versionList) - 1; index >= 0; index-- { // walk backwards to consider newer versions first | |
| for _, protoStr := range available[versionList[index].String()] { | |
| p, err := ParseVersion(protoStr) | |
| if err != nil { | |
| return UnspecifiedVersion, ErrQueryFailed{ | |
| Provider: provider, | |
| Wrapped: fmt.Errorf("registry response includes invalid protocol string %q: %s", protoStr, err), | |
| } | |
| } | |
| if protoVersions.Has(p) { | |
| match = versionList[index] | |
| break FindMatch | |
| } | |
| } | |
| } | |
| return match, nil | |
| } | |
| func (c *registryClient) addHeadersToRequest(req *http.Request) { | |
| if c.creds != nil { | |
| c.creds.PrepareRequest(req) | |
| } | |
| req.Header.Set(terraformVersionHeader, version.String()) | |
| } | |
| func (c *registryClient) errQueryFailed(provider addrs.Provider, err error) error { | |
| if err == context.Canceled { | |
| // This one has a special error type so that callers can | |
| // handle it in a different way. | |
| return ErrRequestCanceled{} | |
| } | |
| return ErrQueryFailed{ | |
| Provider: provider, | |
| Wrapped: err, | |
| } | |
| } | |
| func (c *registryClient) errUnauthorized(hostname svchost.Hostname) error { | |
| return ErrUnauthorized{ | |
| Hostname: hostname, | |
| HaveCredentials: c.creds != nil, | |
| } | |
| } | |
| func (c *registryClient) getFile(url *url.URL) ([]byte, error) { | |
| resp, err := c.httpClient.Get(url.String()) | |
| if err != nil { | |
| return nil, err | |
| } | |
| defer resp.Body.Close() | |
| if resp.StatusCode != http.StatusOK { | |
| return nil, fmt.Errorf("%s returned from %s", resp.Status, HostFromRequest(resp.Request)) | |
| } | |
| data, err := ioutil.ReadAll(resp.Body) | |
| if err != nil { | |
| return data, err | |
| } | |
| return data, nil | |
| } | |
| // configureDiscoveryRetry configures the number of retries the registry client | |
| // will attempt for requests with retryable errors, like 502 status codes | |
| func configureDiscoveryRetry() { | |
| discoveryRetry = defaultRetry | |
| if v := os.Getenv(registryDiscoveryRetryEnvName); v != "" { | |
| retry, err := strconv.Atoi(v) | |
| if err == nil && retry > 0 { | |
| discoveryRetry = retry | |
| } | |
| } | |
| } | |
| func requestLogHook(logger retryablehttp.Logger, req *http.Request, i int) { | |
| if i > 0 { | |
| logger.Printf("[INFO] Previous request to the remote registry failed, attempting retry.") | |
| } | |
| } | |
| func maxRetryErrorHandler(resp *http.Response, err error, numTries int) (*http.Response, error) { | |
| // Close the body per library instructions | |
| if resp != nil { | |
| resp.Body.Close() | |
| } | |
| // Additional error detail: if we have a response, use the status code; | |
| // if we have an error, use that; otherwise nothing. We will never have | |
| // both response and error. | |
| var errMsg string | |
| if resp != nil { | |
| errMsg = fmt.Sprintf(": %s returned from %s", resp.Status, HostFromRequest(resp.Request)) | |
| } else if err != nil { | |
| errMsg = fmt.Sprintf(": %s", err) | |
| } | |
| // This function is always called with numTries=RetryMax+1. If we made any | |
| // retry attempts, include that in the error message. | |
| if numTries > 1 { | |
| return resp, fmt.Errorf("the request failed after %d attempts, please try again later%s", | |
| numTries, errMsg) | |
| } | |
| return resp, fmt.Errorf("the request failed, please try again later%s", errMsg) | |
| } | |
| // HostFromRequest extracts host the same way net/http Request.Write would, | |
| // accounting for empty Request.Host | |
| func HostFromRequest(req *http.Request) string { | |
| if req.Host != "" { | |
| return req.Host | |
| } | |
| if req.URL != nil { | |
| return req.URL.Host | |
| } | |
| // this should never happen and if it does | |
| // it will be handled as part of Request.Write() | |
| // https://cs.opensource.google/go/go/+/refs/tags/go1.18.4:src/net/http/request.go;l=574 | |
| return "" | |
| } | |
| // configureRequestTimeout configures the registry client request timeout from | |
| // environment variables | |
| func configureRequestTimeout() { | |
| requestTimeout = defaultRequestTimeout | |
| if v := os.Getenv(registryClientTimeoutEnvName); v != "" { | |
| timeout, err := strconv.Atoi(v) | |
| if err == nil && timeout > 0 { | |
| requestTimeout = time.Duration(timeout) * time.Second | |
| } | |
| } | |
| } |