internal/getproviders: HTTPMirrorSource implementation

We previously had this just stubbed out because it was a stretch goal for
the v0.13.0 release and it ultimately didn't make it in.

Here we fill out the existing stub -- with a minor change to its interface
so it can access credentials -- with a client implementation that is
compatible with the directory structure produced by the
"terraform providers mirror" subcommand, were the result to be published
on a static file server.
This commit is contained in:
Martin Atkins 2020-08-25 16:26:21 -07:00
parent 146e983c36
commit 2bd2a9a923
10 changed files with 1048 additions and 13 deletions

View File

@ -2,6 +2,7 @@ package getproviders
import ( import (
"fmt" "fmt"
"net/url"
svchost "github.com/hashicorp/terraform-svchost" svchost "github.com/hashicorp/terraform-svchost"
"github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/addrs"
@ -123,9 +124,23 @@ type ErrPlatformNotSupported struct {
Provider addrs.Provider Provider addrs.Provider
Version Version Version Version
Platform Platform Platform Platform
// MirrorURL, if non-nil, is the base URL of the mirror that serviced
// the request in place of the provider's origin registry. MirrorURL
// is nil for a direct query.
MirrorURL *url.URL
} }
func (err ErrPlatformNotSupported) Error() string { func (err ErrPlatformNotSupported) Error() string {
if err.MirrorURL != nil {
return fmt.Sprintf(
"provider mirror %s does not have a package of %s %s for %s",
err.MirrorURL.String(),
err.Provider,
err.Version,
err.Platform,
)
}
return fmt.Sprintf( return fmt.Sprintf(
"provider %s %s is not available for %s", "provider %s %s is not available for %s",
err.Provider, err.Provider,
@ -166,9 +181,22 @@ func (err ErrProtocolNotSupported) Error() string {
type ErrQueryFailed struct { type ErrQueryFailed struct {
Provider addrs.Provider Provider addrs.Provider
Wrapped error Wrapped error
// MirrorURL, if non-nil, is the base URL of the mirror that serviced
// the request in place of the provider's origin registry. MirrorURL
// is nil for a direct query.
MirrorURL *url.URL
} }
func (err ErrQueryFailed) Error() string { func (err ErrQueryFailed) Error() string {
if err.MirrorURL != nil {
return fmt.Sprintf(
"failed to query provider mirror %s for %s: %s",
err.MirrorURL.String(),
err.Provider.String(),
err.Wrapped.Error(),
)
}
return fmt.Sprintf( return fmt.Sprintf(
"could not query provider registry for %s: %s", "could not query provider registry for %s: %s",
err.Provider.String(), err.Provider.String(),

View File

@ -51,6 +51,23 @@ func PackageMatchesHash(loc PackageLocation, want string) (bool, error) {
} }
} }
// PreferredHash examines all of the given hash strings and returns the one
// that the current version of Terraform considers to provide the strongest
// verification.
//
// Returns an empty string if none of the given hashes are of a supported
// format. If PreferredHash returns a non-empty string then it will be one
// of the hash strings in "given", and that hash is the one that must pass
// verification in order for a package to be considered valid.
func PreferredHash(given []string) string {
for _, s := range given {
if strings.HasPrefix(s, "h1:") {
return s
}
}
return ""
}
// PackageHashV1 computes a hash of the contents of the package at the given // PackageHashV1 computes a hash of the contents of the package at the given
// location using hash algorithm 1. // location using hash algorithm 1.
// //

View File

@ -1,16 +1,33 @@
package getproviders package getproviders
import ( import (
"encoding/json"
"fmt" "fmt"
"io"
"log"
"mime"
"net/http"
"net/url" "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/addrs" "github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/helper/logging"
"github.com/hashicorp/terraform/httpclient"
"github.com/hashicorp/terraform/version"
) )
// HTTPMirrorSource is a source that reads provider metadata from a provider // HTTPMirrorSource is a source that reads provider metadata from a provider
// mirror that is accessible over the HTTP provider mirror protocol. // mirror that is accessible over the HTTP provider mirror protocol.
type HTTPMirrorSource struct { type HTTPMirrorSource struct {
baseURL *url.URL baseURL *url.URL
creds svcauth.CredentialsSource
httpClient *retryablehttp.Client
} }
var _ Source = (*HTTPMirrorSource)(nil) var _ Source = (*HTTPMirrorSource)(nil)
@ -18,25 +35,380 @@ var _ Source = (*HTTPMirrorSource)(nil)
// NewHTTPMirrorSource constructs and returns a new network mirror source with // NewHTTPMirrorSource constructs and returns a new network mirror source with
// the given base URL. The relative URL offsets defined by the HTTP mirror // the given base URL. The relative URL offsets defined by the HTTP mirror
// protocol will be resolve relative to the given URL. // protocol will be resolve relative to the given URL.
func NewHTTPMirrorSource(baseURL *url.URL) *HTTPMirrorSource { //
// 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
logOutput, err := logging.LogOutput()
if err != nil {
log.Printf("[WARN] Failed to set up provider HTTP mirror logger, so continuing without client logging: %s", err)
}
retryableClient.Logger = log.New(logOutput, "", log.Flags())
return &HTTPMirrorSource{ return &HTTPMirrorSource{
baseURL: baseURL, baseURL: baseURL,
creds: creds,
httpClient: retryableClient,
} }
} }
// AvailableVersions retrieves the available versions for the given provider // AvailableVersions retrieves the available versions for the given provider
// from the object's underlying HTTP mirror service. // from the object's underlying HTTP mirror service.
func (s *HTTPMirrorSource) AvailableVersions(provider addrs.Provider) (VersionList, Warnings, error) { func (s *HTTPMirrorSource) AvailableVersions(provider addrs.Provider) (VersionList, Warnings, error) {
return nil, nil, fmt.Errorf("Network-based provider mirrors are not supported in this version of Terraform") 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(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 // PackageMeta retrieves metadata for the requested provider package
// from the object's underlying HTTP mirror service. // from the object's underlying HTTP mirror service.
func (s *HTTPMirrorSource) PackageMeta(provider addrs.Provider, version Version, target Platform) (PackageMeta, error) { func (s *HTTPMirrorSource) PackageMeta(provider addrs.Provider, version Version, target Platform) (PackageMeta, error) {
return PackageMeta{}, fmt.Errorf("Network-based provider mirrors are not supported in this version of Terraform") 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(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 {
ret.Authentication = NewPackageHashAuthentication(archiveMeta.Hashes)
}
return ret, nil
} }
// ForDisplay returns a string description of the source for user-facing output. // ForDisplay returns a string description of the source for user-facing output.
func (s *HTTPMirrorSource) ForDisplay(provider addrs.Provider) string { func (s *HTTPMirrorSource) ForDisplay(provider addrs.Provider) string {
return "Network-based provider mirrors are not supported in this version of Terraform" 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(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.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 {
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)
} }

View File

@ -0,0 +1,314 @@
package getproviders
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/google/go-cmp/cmp"
svchost "github.com/hashicorp/terraform-svchost"
svcauth "github.com/hashicorp/terraform-svchost/auth"
"github.com/hashicorp/terraform/addrs"
)
func TestHTTPMirrorSource(t *testing.T) {
// For mirrors we require a HTTPS server, so we'll use httptest to create
// one. However, that means we need to instantiate the source in an unusual
// way to force it to use the test client that is configured to trust the
// test server.
httpServer := httptest.NewTLSServer(http.HandlerFunc(testHTTPMirrorSourceHandler))
defer httpServer.Close()
httpClient := httpServer.Client()
baseURL, err := url.Parse(httpServer.URL)
if err != nil {
t.Fatalf("httptest.NewTLSServer returned a server with an invalid URL")
}
creds := svcauth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{
svchost.Hostname(baseURL.Host): {
"token": "placeholder-token",
},
})
source := newHTTPMirrorSourceWithHTTPClient(baseURL, creds, httpClient)
existingProvider := addrs.MustParseProviderSourceString("terraform.io/test/exists")
missingProvider := addrs.MustParseProviderSourceString("terraform.io/test/missing")
failingProvider := addrs.MustParseProviderSourceString("terraform.io/test/fails")
redirectingProvider := addrs.MustParseProviderSourceString("terraform.io/test/redirects")
redirectLoopProvider := addrs.MustParseProviderSourceString("terraform.io/test/redirect-loop")
tosPlatform := Platform{OS: "tos", Arch: "m68k"}
t.Run("AvailableVersions for provider that exists", func(t *testing.T) {
got, _, err := source.AvailableVersions(existingProvider)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
want := VersionList{
MustParseVersion("1.0.0"),
MustParseVersion("1.0.1"),
MustParseVersion("1.0.2-beta.1"),
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("wrong result\n%s", diff)
}
})
t.Run("AvailableVersions for provider that doesn't exist", func(t *testing.T) {
_, _, err := source.AvailableVersions(missingProvider)
switch err := err.(type) {
case ErrProviderNotFound:
if got, want := err.Provider, missingProvider; got != want {
t.Errorf("wrong provider in error\ngot: %s\nwant: %s", got, want)
}
default:
t.Fatalf("wrong error type %T; want ErrProviderNotFound", err)
}
})
t.Run("AvailableVersions without required credentials", func(t *testing.T) {
unauthSource := newHTTPMirrorSourceWithHTTPClient(baseURL, nil, httpClient)
_, _, err := unauthSource.AvailableVersions(existingProvider)
switch err := err.(type) {
case ErrUnauthorized:
if got, want := string(err.Hostname), baseURL.Host; got != want {
t.Errorf("wrong hostname in error\ngot: %s\nwant: %s", got, want)
}
default:
t.Fatalf("wrong error type %T; want ErrUnauthorized", err)
}
})
t.Run("AvailableVersions when the response is a server error", func(t *testing.T) {
_, _, err := source.AvailableVersions(failingProvider)
switch err := err.(type) {
case ErrQueryFailed:
if got, want := err.Provider, failingProvider; got != want {
t.Errorf("wrong provider in error\ngot: %s\nwant: %s", got, want)
}
if err.MirrorURL != source.baseURL {
t.Errorf("error does not refer to the mirror URL")
}
default:
t.Fatalf("wrong error type %T; want ErrQueryFailed", err)
}
})
t.Run("AvailableVersions for provider that redirects", func(t *testing.T) {
got, _, err := source.AvailableVersions(redirectingProvider)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
want := VersionList{
MustParseVersion("1.0.0"),
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("wrong result\n%s", diff)
}
})
t.Run("AvailableVersions for provider that redirects too much", func(t *testing.T) {
_, _, err := source.AvailableVersions(redirectLoopProvider)
if err == nil {
t.Fatalf("succeeded; expected error")
}
})
t.Run("PackageMeta for a version that exists and has a hash", func(t *testing.T) {
version := MustParseVersion("1.0.0")
got, err := source.PackageMeta(existingProvider, version, tosPlatform)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
want := PackageMeta{
Provider: existingProvider,
Version: version,
TargetPlatform: tosPlatform,
Filename: "terraform-provider-test_v1.0.0_tos_m68k.zip",
Location: PackageHTTPURL(httpServer.URL + "/terraform.io/test/exists/terraform-provider-test_v1.0.0_tos_m68k.zip"),
Authentication: packageHashAuthentication{
RequiredHash: "h1:placeholder-hash",
},
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("wrong result\n%s", diff)
}
})
t.Run("PackageMeta for a version that exists and has no hash", func(t *testing.T) {
version := MustParseVersion("1.0.1")
got, err := source.PackageMeta(existingProvider, version, tosPlatform)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
want := PackageMeta{
Provider: existingProvider,
Version: version,
TargetPlatform: tosPlatform,
Filename: "terraform-provider-test_v1.0.1_tos_m68k.zip",
Location: PackageHTTPURL(httpServer.URL + "/terraform.io/test/exists/terraform-provider-test_v1.0.1_tos_m68k.zip"),
Authentication: nil,
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("wrong result\n%s", diff)
}
})
t.Run("PackageMeta for a version that exists but has no archives", func(t *testing.T) {
version := MustParseVersion("1.0.2-beta.1")
_, err := source.PackageMeta(existingProvider, version, tosPlatform)
switch err := err.(type) {
case ErrPlatformNotSupported:
if got, want := err.Provider, existingProvider; got != want {
t.Errorf("wrong provider in error\ngot: %s\nwant: %s", got, want)
}
if got, want := err.Platform, tosPlatform; got != want {
t.Errorf("wrong platform in error\ngot: %s\nwant: %s", got, want)
}
if err.MirrorURL != source.baseURL {
t.Errorf("error does not contain the mirror URL")
}
default:
t.Fatalf("wrong error type %T; want ErrPlatformNotSupported", err)
}
})
t.Run("PackageMeta with redirect to a version that exists", func(t *testing.T) {
version := MustParseVersion("1.0.0")
got, err := source.PackageMeta(redirectingProvider, version, tosPlatform)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
want := PackageMeta{
Provider: redirectingProvider,
Version: version,
TargetPlatform: tosPlatform,
Filename: "terraform-provider-test.zip",
// NOTE: The final URL is interpreted relative to the redirect
// target, not relative to what we originally requested.
Location: PackageHTTPURL(httpServer.URL + "/redirect-target/terraform-provider-test.zip"),
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("wrong result\n%s", diff)
}
})
t.Run("PackageMeta when the response is a server error", func(t *testing.T) {
version := MustParseVersion("1.0.0")
_, err := source.PackageMeta(failingProvider, version, tosPlatform)
switch err := err.(type) {
case ErrQueryFailed:
if got, want := err.Provider, failingProvider; got != want {
t.Errorf("wrong provider in error\ngot: %s\nwant: %s", got, want)
}
if err.MirrorURL != source.baseURL {
t.Errorf("error does not contain the mirror URL")
}
default:
t.Fatalf("wrong error type %T; want ErrQueryFailed", err)
}
})
}
func testHTTPMirrorSourceHandler(resp http.ResponseWriter, req *http.Request) {
if auth := req.Header.Get("authorization"); auth != "Bearer placeholder-token" {
resp.WriteHeader(401)
fmt.Fprintln(resp, "incorrect auth token")
}
switch req.URL.Path {
case "/terraform.io/test/exists/index.json":
resp.Header().Add("Content-Type", "application/json; ignored=yes")
resp.WriteHeader(200)
fmt.Fprint(resp, `
{
"versions": {
"1.0.0": {},
"1.0.1": {},
"1.0.2-beta.1": {}
}
}
`)
case "/terraform.io/test/fails/index.json", "/terraform.io/test/fails/1.0.0.json":
resp.WriteHeader(500)
fmt.Fprint(resp, "server error")
case "/terraform.io/test/exists/1.0.0.json":
resp.Header().Add("Content-Type", "application/json; ignored=yes")
resp.WriteHeader(200)
fmt.Fprint(resp, `
{
"archives": {
"tos_m68k": {
"url": "terraform-provider-test_v1.0.0_tos_m68k.zip",
"hashes": [
"h1:placeholder-hash"
]
}
}
}
`)
case "/terraform.io/test/exists/1.0.1.json":
resp.Header().Add("Content-Type", "application/json; ignored=yes")
resp.WriteHeader(200)
fmt.Fprint(resp, `
{
"archives": {
"tos_m68k": {
"url": "terraform-provider-test_v1.0.1_tos_m68k.zip"
}
}
}
`)
case "/terraform.io/test/exists/1.0.2-beta.1.json":
resp.Header().Add("Content-Type", "application/json; ignored=yes")
resp.WriteHeader(200)
fmt.Fprint(resp, `
{
"archives": {}
}
`)
case "/terraform.io/test/redirects/index.json":
resp.Header().Add("location", "/redirect-target/index.json")
resp.WriteHeader(301)
fmt.Fprint(resp, "redirect")
case "/redirect-target/index.json":
resp.Header().Add("Content-Type", "application/json")
resp.WriteHeader(200)
fmt.Fprint(resp, `
{
"versions": {
"1.0.0": {}
}
}
`)
case "/terraform.io/test/redirects/1.0.0.json":
resp.Header().Add("location", "/redirect-target/1.0.0.json")
resp.WriteHeader(301)
fmt.Fprint(resp, "redirect")
case "/redirect-target/1.0.0.json":
resp.Header().Add("Content-Type", "application/json")
resp.WriteHeader(200)
fmt.Fprint(resp, `
{
"archives": {
"tos_m68k": {
"url": "terraform-provider-test.zip"
}
}
}
`)
case "/terraform.io/test/redirect-loop/index.json":
// This is intentionally redirecting to itself, to create a loop.
resp.Header().Add("location", req.URL.Path)
resp.WriteHeader(301)
fmt.Fprint(resp, "redirect loop")
default:
resp.WriteHeader(404)
fmt.Fprintln(resp, "not found")
}
}

View File

@ -89,7 +89,7 @@ func TestConfigureRegistryClientTimeout(t *testing.T) {
}) })
} }
// testServices starts up a local HTTP server running a fake provider registry // testRegistryServices starts up a local HTTP server running a fake provider registry
// service and returns a service discovery object pre-configured to consider // service and returns a service discovery object pre-configured to consider
// the host "example.com" to be served by the fake registry service. // the host "example.com" to be served by the fake registry service.
// //
@ -103,7 +103,7 @@ func TestConfigureRegistryClientTimeout(t *testing.T) {
// The second return value is a function to call at the end of a test function // The second return value is a function to call at the end of a test function
// to shut down the test server. After you call that function, the discovery // to shut down the test server. After you call that function, the discovery
// object becomes useless. // object becomes useless.
func testServices(t *testing.T) (services *disco.Disco, baseURL string, cleanup func()) { func testRegistryServices(t *testing.T) (services *disco.Disco, baseURL string, cleanup func()) {
server := httptest.NewServer(http.HandlerFunc(fakeRegistryHandler)) server := httptest.NewServer(http.HandlerFunc(fakeRegistryHandler))
services = disco.New() services = disco.New()
@ -141,7 +141,7 @@ func testServices(t *testing.T) (services *disco.Disco, baseURL string, cleanup
// As with testServices, the second return value is a function to call at the end // As with testServices, the second return value is a function to call at the end
// of your test in order to shut down the test server. // of your test in order to shut down the test server.
func testRegistrySource(t *testing.T) (source *RegistrySource, baseURL string, cleanup func()) { func testRegistrySource(t *testing.T) (source *RegistrySource, baseURL string, cleanup func()) {
services, baseURL, close := testServices(t) services, baseURL, close := testRegistryServices(t)
source = NewRegistrySource(services) source = NewRegistrySource(services)
return source, baseURL, close return source, baseURL, close
} }

View File

@ -216,7 +216,7 @@ func providerSourceForCLIConfigLocation(loc cliconfig.ProviderInstallationLocati
)) ))
return nil, diags return nil, diags
} }
return getproviders.NewHTTPMirrorSource(url), nil return getproviders.NewHTTPMirrorSource(url, services.CredentialsSource()), nil
default: default:
// We should not get here because the set of cases above should // We should not get here because the set of cases above should

View File

@ -238,6 +238,21 @@ The following are the two supported installation method types:
You can include multiple `filesystem_mirror` blocks in order to specify You can include multiple `filesystem_mirror` blocks in order to specify
several different directories to search. several different directories to search.
* `network_mirror`: consult a particular HTTPS server for copies of providers,
regardless of which registry host they belong to. This method requires the
additional argument `url` to indicate the mirror base URL, which should
use the `https:` scheme and end with a trailing slash.
Terraform expects the given URL to be a base URL for an implementation of
[the provider network mirror protocol](/docs/internals/providor-network-mirror-protocol.html),
which is designed to be relatively easy to implement using typical static
website hosting mechanisms.
~> **Warning:** Don't configure `network_mirror` URLs that you do not trust.
Provider mirror servers are subject to TLS certificate checks to verify
identity, but a network mirror with a TLS certificate can potentially serve
modified copies of upstream providers with malicious content.
Terraform will try all of the specified methods whose include and exclude Terraform will try all of the specified methods whose include and exclude
patterns match a given provider, and select the newest version available across patterns match a given provider, and select the newest version available across
all of those methods that matches the version constraint given in each all of those methods that matches the version constraint given in each

View File

@ -36,9 +36,15 @@ Usage: `terraform providers mirror [options] <target-dir>`
A single target directory is required. Terraform will create under that A single target directory is required. Terraform will create under that
directory the path structure that is expected for filesystem-based provider directory the path structure that is expected for filesystem-based provider
plugin mirrors, populating it both with `.zip` files containing the plugins plugin mirrors, populating it with `.zip` files containing the plugins
themselves and `.json` index files that describe what is available in the themselves.
directory.
Terraform will also generate various `.json` index files which contain suitable
responses to implement
[the network mirror protocol](/docs/internals/providor-network-mirror-protocol.html),
if you upload the resulting directory to a static website host. Terraform
ignores those index files when using the directory as a filesystem mirror,
because the directory entries themselves are authoritative in that case.
This command supports the following additional option: This command supports the following additional option:

View File

@ -0,0 +1,279 @@
---
layout: "docs"
page_title: "Provider Network Mirror Protocol"
sidebar_current: "docs-internals-provider-network-mirror-protocol"
description: |-
The provider network mirror protocol is implemented by a server intending
to provide a mirror or read-through caching proxy for Terraform providers,
as an alternative distribution source from the provider's origin provider
registry.
---
# Provider Network Mirror Protocol
-> Provider network mirrors are supported only in Terraform CLI v0.13.2 and later. Prior versions do not support this protocol.
The provider network mirror protocol is an optional protocol which you can
implement to provide an alternative installation source for Terraform providers,
regardless of their origin registries.
Terraform uses network mirrors only if you activate them explicitly in
[the CLI configuration's `provider_installation` block](/docs/commands/cli-config.html#provider-installation).
When enabled, a network mirror can serve providers belonging to any registry
hostname, which can allow an organization to serve all of the Terraform
providers they intend to use from an internal server, rather than from each
provider's origin registry.
This is _not_ the protocol that should be implemented by a host intending to
serve as an origin registry for Terraform Providers. To provide an origin
registry (whose hostname would then be included in the source addresses of the
providers it hosts), implement
[the provider registry protocol](./provider-registry-protocol.html)
instead.
## Provider Addresses
Each Terraform provider has an associated address which uniquely identifies it
within Terraform. A provider address has the syntax `hostname/namespace/type`,
which is described in more detail in
[the Provider Requirements documentation](/docs/configuration/provider-requirements.html).
By default, the `hostname` portion of a provider address serves both as part
of its unique identifier _and_ as the location of the registry to retrieve it
from. However, when you configure Terraform to install providers from a network
mirror, the `hostname` serves _only_ as an identifier and no longer as
an installation source. A provider mirror can therefore serve providers
belonging to a variety of different provider registry hostnames, including
providers from the public Terraform Registry at `registry.terraform.io`, from a
single server.
In the relative URL patterns later in this document, the placeholder `:hostname`
refers to the hostname from the address of the provider being requested, not
the hostname where the provider network mirror is deployed.
## Protocol Base URL
Most Terraform-native services use
[the remote service discovery protocol](./remote-service-discovery.html) so
that the physical location of the endpoints can potentially be separated from
the hostname used in identifiers. The Provider Network Mirror protocol does
_not_ use the service discovery indirection, because a network mirror location
is only a physical location and is never used as part of the identifier of a
dependency in a Terraform configuration.
Instead, the provider installation section of the CLI configuration accepts
a base URL directly. The given URL must use the scheme `https:`, and should
end with a trailing slash so that the relative URLs of the individual operation
endpoints will be resolved beneath it.
```hcl
provider_installation {
network_mirror {
url = "https://terraform.example.com/providers/"
}
}
```
Terraform uses the base URL only as a stem to resolve the operation endpoint
URLs against, and so it will never access the base URL directly. You can
therefore, if desired, publish human-readable usage documentation for your
network mirror at that URL.
The following sections describe the various operations that a provider
network mirror server must implement to be compatible with Terraform CLI's
provider installer. The indicated URLs are all relative to the given base URL,
as described above.
The URLs are shown with the convention that a path portion with a colon `:`
prefix is a placeholder for a dynamically-selected value, while all other
path portions are literal. For example, in `:hostname/:namespace/:type/index.json`,
the first three path portions are placeholders while the third is literally
the string "index.json".
The example requests in the following sections will assume the example mirror
base URL from the above CLI configuration example.
### Authentication
If the CLI configuration includes
[credentials](/docs/commands/cli-config.html#credentials) for the hostname
given in the network mirror base URL, Terraform will include those credentials
in its requests for operations described below.
If the given URL uses a non-standard port number (other than 443) then the
credentials must be associated with a hostname that includes the port number,
such as `terraform.example.com:8443`.
Terraform does _not_ send credentials when retrieving the archives whose
URLs are given in the "List Available Installation Packages" response below.
If a particular mirror considers the distribution packages themselves to be
sensitive then it must use cryptographically-secure, user-specific, and
time-limited URLs in the metadata response. Strategies for doing so are out
of scope of this protocol documentation.
## List Available Versions
This operation determines which versions are currently available for a
particular provider.
| Method | Path | Produces |
|--------|-----------------------------------------|--------------------|
| `GET` | `:hostname/:namespace/:type/index.json` | `application/json` |
### Parameters
* `hostname` (required): the hostname portion of the address of the requested
provider.
* `namespace` (required): the namespace portion of the address of the requested
provider.
* `type` (required): the type portion of the address of the requested provider.
### Sample Request
```
curl 'https://terraform.example.com/providers/registry.terraform.io/hashicorp/random/index.json'
```
### Sample Response
```json
{
"versions": {
"2.0.0": {},
"2.0.1": {}
}
}
```
### Response Properties
A successful result is a JSON object containing a single property `versions`,
which must be a JSON object.
Each of the property names of the `versions` object represents an available
version number. The property values must be objects, but no properties are
currently defined for those objects. Future versions of this protocol may
define optional per-version properties for Terraform to use as installation
hints, so implementations of the current version should leave those objects
empty.
Return `404 Not Found` to signal that the mirror does not have a provider
with the given address.
## List Available Installation Packages
This operation returns download URLs and associated metadata for the
distribution packages for a particular version of a provider.
Each distribution package is associated with a particular operating system
and architecture. A network mirror may host only a subset of the available
packages for a provider version, if the users of the mirror are known to all
use only a subset of the target platforms that Terraform supports.
Terraform CLI uses this operation after it has selected the newest available
version matching the configured version constraints, in order to find a zip
archive containing the plugin itself.
| Method | Path | Produces |
|--------|--------------------------------------------|--------------------|
| `GET` | `:hostname/:namespace/:type/:version.json` | `application/json` |
### Parameters
* `hostname` (required): the hostname portion of the address of the requested
provider.
* `namespace` (required): the namespace portion of the address of the requested
provider.
* `type` (required): the type portion of the address of the requested provider.
* `version` (required): the version selected to download. This will exactly
match one of the version strings returned from a previous call to
[List Available Versions](#list-available-versions).
### Sample Request
```
curl 'https://terraform.example.com/providers/registry.terraform.io/hashicorp/random/2.0.0.json'
```
### Sample Response
```json
{
"archives": {
"darwin_amd64": {
"url": "terraform-provider-random_2.0.0_darwin_amd64.zip",
"hashes": [
"h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs="
]
},
"linux_amd64": {
"url": "terraform-provider-random_2.0.0_linux_amd64.zip",
"hashes": [
"h1:lCJCxf/LIowc2IGS9TPjWDyXY4nOmdGdfcwwDQCOURQ="
]
}
}
}
```
### Response Properties
A successful result is a JSON object with a property called `archives`, which
must be a JSON object.
Each of the property names of the `archives` object is a target platform
identifier, which consists of an operating system and architecture concatenated
with an underscore (`_`).
Each property value in the `archives` object is itself a nested object with
the following properties:
* `url` (required): a string specifying the URL from which Terraform should
download the `.zip` archive containing the requested provider plugin version.
Terraform resolves the URL relative to the URL from which the current
JSON document was returned, so the examples above containing only a
filename would cause Terraform to construct a URL like:
```
https://terraform.example.com/providers/registry.terraform.io/hashicorp/random/terraform-provider-random_2.0.0_darwin_amd64.zip
```
* `hashes` (optional): a JSON array of strings containing one or more hash
values for the indicated archive. These hashes use Terraform's provider
package hashing algorithm. At present, the easiest way to populate these
is to construct a mirror's JSON indices using the `terraform providers mirror`
command, as described in a later section, which will include the calculated
hashes of each provider.
If the response includes at least one hash, Terraform will select the hash
whose algorithm it considers to be strongest and verify that the downloaded
package matches that hash. If the response does not include a `hashes`
property then Terraform will install the indicated archive with no
verification.
Terraform CLI will only attempt to download versions that it has previously
seen in response to [List Available Versions](#list-available-versions).
## Provider Mirror as a Static Website
The provider mirror protocol is designed so that it can potentially implemented
by placing files on typical static website hosting services. When using this
strategy, implement the JSON index responses described above as `.json` files
in the appropriate nested subdirectories, and ensure that your system is
configured to serve `.json` files with the `application/json` media type.
As a convenience, Terraform CLI includes
[the `terraform providers mirror` subcommand](https://www.terraform.io/docs/commands/providers/mirror.html),
which will analyze the current configuration for the providers it requires,
download the packages for those providers from their origin registries, and
place them into a local directory suitable for use as a mirror.
The `terraform providers mirror` subcommand also generates `index.json` and
version-specific `.json` files that can, when placed in a static website hosting
system, produce responses compatible with the provider mirror protocol.
If you wish to create a mirror with providers for a number of different
Terraform configurations, run `terraform providers mirror` in each configuration
in turn while providing the same output directory each time. Terraform will
then merge together all of the requirements into a single set of JSON indices.

View File

@ -499,6 +499,10 @@
<a href="/docs/internals/module-registry-protocol.html">Module Registry Protocol</a> <a href="/docs/internals/module-registry-protocol.html">Module Registry Protocol</a>
</li> </li>
<li<%= sidebar_current("docs-internals-provider-network-mirror-protocol") %>>
<a href="/docs/internals/provider-network-mirror-protocol.html">Provider Network Mirror Protocol</a>
</li>
<li<%= sidebar_current("docs-internals-provider-registry-protocol") %>> <li<%= sidebar_current("docs-internals-provider-registry-protocol") %>>
<a href="/docs/internals/provider-registry-protocol.html">Provider Registry Protocol</a> <a href="/docs/internals/provider-registry-protocol.html">Provider Registry Protocol</a>
</li> </li>