mirror of
https://github.com/opentofu/opentofu.git
synced 2024-12-30 10:47:14 -06:00
523 lines
16 KiB
Go
523 lines
16 KiB
Go
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 {
|
|
return UnspecifiedVersion, ErrQueryFailed{
|
|
Provider: provider,
|
|
Wrapped: fmt.Errorf("registry response includes invalid version string %q: %s", versionStr, err),
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|