mirror of
https://github.com/opentofu/opentofu.git
synced 2024-12-28 18:01:01 -06:00
b9a93a0fe7
This is part of a general effort to move all of Terraform's non-library package surface under internal in order to reinforce that these are for internal use within Terraform only. If you were previously importing packages under this prefix into an external codebase, you could pin to an earlier release tag as an interim solution until you've make a plan to achieve the same functionality some other way.
429 lines
15 KiB
Go
429 lines
15 KiB
Go
package getproviders
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/hashicorp/go-retryablehttp"
|
|
svchost "github.com/hashicorp/terraform-svchost"
|
|
svcauth "github.com/hashicorp/terraform-svchost/auth"
|
|
"golang.org/x/net/idna"
|
|
|
|
"github.com/hashicorp/terraform/internal/addrs"
|
|
"github.com/hashicorp/terraform/internal/httpclient"
|
|
"github.com/hashicorp/terraform/internal/logging"
|
|
"github.com/hashicorp/terraform/version"
|
|
)
|
|
|
|
// HTTPMirrorSource is a source that reads provider metadata from a provider
|
|
// mirror that is accessible over the HTTP provider mirror protocol.
|
|
type HTTPMirrorSource struct {
|
|
baseURL *url.URL
|
|
creds svcauth.CredentialsSource
|
|
httpClient *retryablehttp.Client
|
|
}
|
|
|
|
var _ Source = (*HTTPMirrorSource)(nil)
|
|
|
|
// NewHTTPMirrorSource constructs and returns a new network mirror source with
|
|
// the given base URL. The relative URL offsets defined by the HTTP mirror
|
|
// protocol will be resolve relative to the given URL.
|
|
//
|
|
// The given URL must use the "https" scheme, or this function will panic.
|
|
// (When the URL comes from user input, such as in the CLI config, it's the
|
|
// UI/config layer's responsibility to validate this and return a suitable
|
|
// error message for the end-user audience.)
|
|
func NewHTTPMirrorSource(baseURL *url.URL, creds svcauth.CredentialsSource) *HTTPMirrorSource {
|
|
httpClient := httpclient.New()
|
|
httpClient.Timeout = requestTimeout
|
|
httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
// If we get redirected more than five times we'll assume we're
|
|
// in a redirect loop and bail out, rather than hanging forever.
|
|
if len(via) > 5 {
|
|
return fmt.Errorf("too many redirects")
|
|
}
|
|
return nil
|
|
}
|
|
return newHTTPMirrorSourceWithHTTPClient(baseURL, creds, httpClient)
|
|
}
|
|
|
|
func newHTTPMirrorSourceWithHTTPClient(baseURL *url.URL, creds svcauth.CredentialsSource, httpClient *http.Client) *HTTPMirrorSource {
|
|
if baseURL.Scheme != "https" {
|
|
panic("non-https URL for HTTP mirror")
|
|
}
|
|
|
|
// We borrow the retry settings and behaviors from the registry client,
|
|
// because our needs here are very similar to those of the registry client.
|
|
retryableClient := retryablehttp.NewClient()
|
|
retryableClient.HTTPClient = httpClient
|
|
retryableClient.RetryMax = discoveryRetry
|
|
retryableClient.RequestLogHook = requestLogHook
|
|
retryableClient.ErrorHandler = maxRetryErrorHandler
|
|
|
|
retryableClient.Logger = log.New(logging.LogOutput(), "", log.Flags())
|
|
|
|
return &HTTPMirrorSource{
|
|
baseURL: baseURL,
|
|
creds: creds,
|
|
httpClient: retryableClient,
|
|
}
|
|
}
|
|
|
|
// AvailableVersions retrieves the available versions for the given provider
|
|
// from the object's underlying HTTP mirror service.
|
|
func (s *HTTPMirrorSource) AvailableVersions(ctx context.Context, provider addrs.Provider) (VersionList, Warnings, error) {
|
|
log.Printf("[DEBUG] Querying available versions of provider %s at network mirror %s", provider.String(), s.baseURL.String())
|
|
|
|
endpointPath := path.Join(
|
|
provider.Hostname.String(),
|
|
provider.Namespace,
|
|
provider.Type,
|
|
"index.json",
|
|
)
|
|
|
|
statusCode, body, finalURL, err := s.get(ctx, endpointPath)
|
|
defer func() {
|
|
if body != nil {
|
|
body.Close()
|
|
}
|
|
}()
|
|
if err != nil {
|
|
return nil, nil, s.errQueryFailed(provider, err)
|
|
}
|
|
|
|
switch statusCode {
|
|
case http.StatusOK:
|
|
// Great!
|
|
case http.StatusNotFound:
|
|
return nil, nil, ErrProviderNotFound{
|
|
Provider: provider,
|
|
}
|
|
case http.StatusUnauthorized, http.StatusForbidden:
|
|
return nil, nil, s.errUnauthorized(finalURL)
|
|
default:
|
|
return nil, nil, s.errQueryFailed(provider, fmt.Errorf("server returned unsuccessful status %d", statusCode))
|
|
}
|
|
|
|
// If we got here then the response had status OK and so our body
|
|
// will be non-nil and should contain some JSON for us to parse.
|
|
type ResponseBody struct {
|
|
Versions map[string]struct{} `json:"versions"`
|
|
}
|
|
var bodyContent ResponseBody
|
|
|
|
dec := json.NewDecoder(body)
|
|
if err := dec.Decode(&bodyContent); err != nil {
|
|
return nil, nil, s.errQueryFailed(provider, fmt.Errorf("invalid response content from mirror server: %s", err))
|
|
}
|
|
|
|
if len(bodyContent.Versions) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
ret := make(VersionList, 0, len(bodyContent.Versions))
|
|
for versionStr := range bodyContent.Versions {
|
|
version, err := ParseVersion(versionStr)
|
|
if err != nil {
|
|
log.Printf("[WARN] Ignoring invalid %s version string %q in provider mirror response", provider, versionStr)
|
|
continue
|
|
}
|
|
ret = append(ret, version)
|
|
}
|
|
|
|
ret.Sort()
|
|
return ret, nil, nil
|
|
}
|
|
|
|
// PackageMeta retrieves metadata for the requested provider package
|
|
// from the object's underlying HTTP mirror service.
|
|
func (s *HTTPMirrorSource) PackageMeta(ctx context.Context, provider addrs.Provider, version Version, target Platform) (PackageMeta, error) {
|
|
log.Printf("[DEBUG] Finding package URL for %s v%s on %s via network mirror %s", provider.String(), version.String(), target.String(), s.baseURL.String())
|
|
|
|
endpointPath := path.Join(
|
|
provider.Hostname.String(),
|
|
provider.Namespace,
|
|
provider.Type,
|
|
version.String()+".json",
|
|
)
|
|
|
|
statusCode, body, finalURL, err := s.get(ctx, endpointPath)
|
|
defer func() {
|
|
if body != nil {
|
|
body.Close()
|
|
}
|
|
}()
|
|
if err != nil {
|
|
return PackageMeta{}, s.errQueryFailed(provider, err)
|
|
}
|
|
|
|
switch statusCode {
|
|
case http.StatusOK:
|
|
// Great!
|
|
case http.StatusNotFound:
|
|
// A 404 Not Found for a version we previously saw in index.json is
|
|
// a protocol error, so we'll report this as "query failed.
|
|
return PackageMeta{}, s.errQueryFailed(provider, fmt.Errorf("provider mirror does not have archive index for previously-reported %s version %s", provider, version))
|
|
case http.StatusUnauthorized, http.StatusForbidden:
|
|
return PackageMeta{}, s.errUnauthorized(finalURL)
|
|
default:
|
|
return PackageMeta{}, s.errQueryFailed(provider, fmt.Errorf("server returned unsuccessful status %d", statusCode))
|
|
}
|
|
|
|
// If we got here then the response had status OK and so our body
|
|
// will be non-nil and should contain some JSON for us to parse.
|
|
type ResponseArchiveMeta struct {
|
|
RelativeURL string `json:"url"`
|
|
Hashes []string
|
|
}
|
|
type ResponseBody struct {
|
|
Archives map[string]*ResponseArchiveMeta `json:"archives"`
|
|
}
|
|
var bodyContent ResponseBody
|
|
|
|
dec := json.NewDecoder(body)
|
|
if err := dec.Decode(&bodyContent); err != nil {
|
|
return PackageMeta{}, s.errQueryFailed(provider, fmt.Errorf("invalid response content from mirror server: %s", err))
|
|
}
|
|
|
|
archiveMeta, ok := bodyContent.Archives[target.String()]
|
|
if !ok {
|
|
return PackageMeta{}, ErrPlatformNotSupported{
|
|
Provider: provider,
|
|
Version: version,
|
|
Platform: target,
|
|
MirrorURL: s.baseURL,
|
|
}
|
|
}
|
|
|
|
relURL, err := url.Parse(archiveMeta.RelativeURL)
|
|
if err != nil {
|
|
return PackageMeta{}, s.errQueryFailed(
|
|
provider,
|
|
fmt.Errorf("provider mirror returned invalid URL %q: %s", archiveMeta.RelativeURL, err),
|
|
)
|
|
}
|
|
absURL := finalURL.ResolveReference(relURL)
|
|
|
|
ret := PackageMeta{
|
|
Provider: provider,
|
|
Version: version,
|
|
TargetPlatform: target,
|
|
|
|
Location: PackageHTTPURL(absURL.String()),
|
|
Filename: path.Base(absURL.Path),
|
|
}
|
|
// A network mirror might not provide any hashes at all, in which case
|
|
// the package has no source-defined authentication whatsoever.
|
|
if len(archiveMeta.Hashes) > 0 {
|
|
hashes := make([]Hash, 0, len(archiveMeta.Hashes))
|
|
for _, hashStr := range archiveMeta.Hashes {
|
|
hash, err := ParseHash(hashStr)
|
|
if err != nil {
|
|
return PackageMeta{}, s.errQueryFailed(
|
|
provider,
|
|
fmt.Errorf("provider mirror returned invalid provider hash %q: %s", hashStr, err),
|
|
)
|
|
}
|
|
hashes = append(hashes, hash)
|
|
}
|
|
ret.Authentication = NewPackageHashAuthentication(target, hashes)
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
// ForDisplay returns a string description of the source for user-facing output.
|
|
func (s *HTTPMirrorSource) ForDisplay(provider addrs.Provider) string {
|
|
return "provider mirror at " + s.baseURL.String()
|
|
}
|
|
|
|
// mirrorHost extracts the hostname portion of the configured base URL and
|
|
// returns it as a svchost.Hostname, normalized in the usual ways.
|
|
//
|
|
// If the returned error is non-nil then the given hostname doesn't comply
|
|
// with the IETF RFC 5891 section 5.3 and 5.4 validation rules, and thus cannot
|
|
// be interpreted as a valid Terraform service host. The IDNA validation errors
|
|
// are unfortunately usually not very user-friendly, but they are also
|
|
// relatively rare because the IDNA normalization rules are quite tolerant.
|
|
func (s *HTTPMirrorSource) mirrorHost() (svchost.Hostname, error) {
|
|
return svchostFromURL(s.baseURL)
|
|
}
|
|
|
|
// mirrorHostCredentials returns the HostCredentials, if any, for the hostname
|
|
// included in the mirror base URL.
|
|
//
|
|
// It might return an error if the mirror base URL is invalid, or if the
|
|
// credentials lookup itself fails.
|
|
func (s *HTTPMirrorSource) mirrorHostCredentials() (svcauth.HostCredentials, error) {
|
|
hostname, err := s.mirrorHost()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid provider mirror base URL %s: %s", s.baseURL.String(), err)
|
|
}
|
|
|
|
if s.creds == nil {
|
|
// No host-specific credentials, then.
|
|
return nil, nil
|
|
}
|
|
|
|
return s.creds.ForHost(hostname)
|
|
}
|
|
|
|
// get is the shared functionality for querying a JSON index from a mirror.
|
|
//
|
|
// It only handles the raw HTTP request. The "body" return value is the
|
|
// reader from the response if and only if the response status code is 200 OK
|
|
// and the Content-Type is application/json. In all other cases it's nil.
|
|
// If body is non-nil then the caller must close it after reading it.
|
|
//
|
|
// If the "finalURL" return value is not empty then it's the URL that actually
|
|
// produced the returned response, possibly after following some redirects.
|
|
func (s *HTTPMirrorSource) get(ctx context.Context, relativePath string) (statusCode int, body io.ReadCloser, finalURL *url.URL, error error) {
|
|
endpointPath, err := url.Parse(relativePath)
|
|
if err != nil {
|
|
// Should never happen because the caller should validate all of the
|
|
// components it's including in the path.
|
|
return 0, nil, nil, err
|
|
}
|
|
endpointURL := s.baseURL.ResolveReference(endpointPath)
|
|
|
|
req, err := retryablehttp.NewRequest("GET", endpointURL.String(), nil)
|
|
if err != nil {
|
|
return 0, nil, endpointURL, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Request.Header.Set(terraformVersionHeader, version.String())
|
|
creds, err := s.mirrorHostCredentials()
|
|
if err != nil {
|
|
return 0, nil, endpointURL, fmt.Errorf("failed to determine request credentials: %s", err)
|
|
}
|
|
if creds != nil {
|
|
// Note that if the initial requests gets redirected elsewhere
|
|
// then the credentials will still be included in the new request,
|
|
// even if they are on a different hostname. This is intentional
|
|
// and consistent with how we handle credentials for other
|
|
// Terraform-native services, because the user model is to configure
|
|
// credentials for the "friendly hostname" they configured, not for
|
|
// whatever hostname ends up ultimately serving the request as an
|
|
// implementation detail.
|
|
creds.PrepareRequest(req.Request)
|
|
}
|
|
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return 0, nil, endpointURL, err
|
|
}
|
|
defer func() {
|
|
// If we're not returning the body then we'll close it
|
|
// before we return.
|
|
if body == nil {
|
|
resp.Body.Close()
|
|
}
|
|
}()
|
|
// After this point, our final URL return value should always be the
|
|
// one from resp.Request, because that takes into account any redirects
|
|
// we followed along the way.
|
|
finalURL = resp.Request.URL
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
// If and only if we get an OK response, we'll check that the response
|
|
// type is JSON and return the body reader.
|
|
ct := resp.Header.Get("Content-Type")
|
|
mt, params, err := mime.ParseMediaType(ct)
|
|
if err != nil {
|
|
return 0, nil, finalURL, fmt.Errorf("response has invalid Content-Type: %s", err)
|
|
}
|
|
if mt != "application/json" {
|
|
return 0, nil, finalURL, fmt.Errorf("response has invalid Content-Type: must be application/json")
|
|
}
|
|
for name := range params {
|
|
// The application/json content-type has no defined parameters,
|
|
// but some servers are configured to include a redundant "charset"
|
|
// parameter anyway, presumably out of a sense of completeness.
|
|
// We'll ignore them but warn that we're ignoring them in case the
|
|
// subsequent parsing fails due to the server trying to use an
|
|
// unsupported character encoding. (RFC 7159 defines its own
|
|
// JSON-specific character encoding rules.)
|
|
log.Printf("[WARN] Network mirror returned %q as part of its JSON content type, which is not defined. Ignoring.", name)
|
|
}
|
|
body = resp.Body
|
|
}
|
|
|
|
return resp.StatusCode, body, finalURL, nil
|
|
}
|
|
|
|
func (s *HTTPMirrorSource) 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,
|
|
MirrorURL: s.baseURL,
|
|
}
|
|
}
|
|
|
|
func (s *HTTPMirrorSource) errUnauthorized(finalURL *url.URL) error {
|
|
hostname, err := svchostFromURL(finalURL)
|
|
if err != nil {
|
|
// Again, weird but we'll tolerate it.
|
|
return fmt.Errorf("invalid credentials for %s", finalURL)
|
|
}
|
|
|
|
return ErrUnauthorized{
|
|
Hostname: hostname,
|
|
|
|
// We can't easily tell from here whether we had credentials or
|
|
// not, so for now we'll just assume we did because "host rejected
|
|
// the given credentials" is, hopefully, still understandable in
|
|
// the event that there were none. (If this ends up being confusing
|
|
// in practice then we'll need to do some refactoring of how
|
|
// we handle credentials in this source.)
|
|
HaveCredentials: true,
|
|
}
|
|
}
|
|
|
|
func svchostFromURL(u *url.URL) (svchost.Hostname, error) {
|
|
raw := u.Host
|
|
|
|
// When "friendly hostnames" appear in Terraform-specific identifiers we
|
|
// typically constrain their syntax more strictly than the
|
|
// Internationalized Domain Name specifications call for, such as
|
|
// forbidding direct use of punycode, but in this case we're just
|
|
// working with a standard http: or https: URL and so we'll first use the
|
|
// IDNA "lookup" rules directly, with no additional notational constraints,
|
|
// to effectively normalize away the differences that would normally
|
|
// produce an error.
|
|
var portPortion string
|
|
if colonPos := strings.Index(raw, ":"); colonPos != -1 {
|
|
raw, portPortion = raw[:colonPos], raw[colonPos:]
|
|
}
|
|
// HTTPMirrorSource requires all URLs to be https URLs, because running
|
|
// a network mirror over HTTP would potentially transmit any configured
|
|
// credentials in cleartext. Therefore we don't need to do any special
|
|
// handling of default ports here, because svchost.Hostname already
|
|
// considers the absense of a port to represent the standard HTTPS port
|
|
// 443, and will normalize away an explicit specification of port 443
|
|
// in svchost.ForComparison below.
|
|
|
|
normalized, err := idna.Display.ToUnicode(raw)
|
|
if err != nil {
|
|
return svchost.Hostname(""), err
|
|
}
|
|
|
|
// If ToUnicode succeeded above then "normalized" is now a hostname in the
|
|
// normalized IDNA form, with any direct punycode already interpreted and
|
|
// the case folding and other normalization rules applied. It should
|
|
// therefore now be accepted by svchost.ForComparison with no additional
|
|
// errors, but the port portion can still potentially be invalid.
|
|
return svchost.ForComparison(normalized + portPortion)
|
|
}
|