getproviders: move protocol compatibility functions into registry client (#24846)

* internal/registry source: return error if requested provider version protocols are not supported

* getproviders: move responsibility for protocol compatibility checks into the registry client

The original implementation had the providercache checking the provider
metadata for protocol compatibility, but this is only relevant for the
registry source so it made more sense to move the logic into
getproviders.

This also addresses an issue where we were pulling the metadata for
every provider version until we found one that was supported. I've
extended the registry client to unmarshal the protocols in
`ProviderVersions` so we can filter through that list, instead of
pulling each version's metadata.
This commit is contained in:
Kristin Laemmert 2020-05-11 13:49:12 -04:00 committed by GitHub
parent 1d834fb1d0
commit 60321b41e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 703 additions and 229 deletions

View File

@ -328,6 +328,16 @@ func ParseProviderSourceString(str string) (Provider, tfdiags.Diagnostics) {
return ret, diags
}
// MustParseProviderSourceString is a wrapper around ParseProviderSourceString that panics if
// it returns an error.
func MustParseProviderSourceString(str string) Provider {
result, diags := ParseProviderSourceString(str)
if diags.HasErrors() {
panic(diags.Err().Error())
}
return result
}
// ParseProviderPart processes an addrs.Provider namespace or type string
// provided by an end-user, producing a normalized version if possible or
// an error if the string contains invalid characters.

View File

@ -23,6 +23,7 @@ import (
"github.com/hashicorp/terraform/internal/providercache"
"github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/tfdiags"
tfversion "github.com/hashicorp/terraform/version"
)
// InitCommand is a Command implementation that takes a Terraform
@ -502,11 +503,42 @@ func (c *InitCommand) getProviders(earlyConfig *earlyconfig.Config, state *state
))
},
FetchPackageFailure: func(provider addrs.Provider, version getproviders.Version, err error) {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to install provider",
fmt.Sprintf("Error while installing %s v%s: %s", provider.ForDisplay(), version, err),
))
switch err := err.(type) {
case getproviders.ErrProtocolNotSupported:
closestAvailable := err.Suggestion
switch {
case closestAvailable == getproviders.UnspecifiedVersion:
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Incompatible provider version",
fmt.Sprintf(errProviderVersionIncompatible, provider.String()),
))
case version.GreaterThan(closestAvailable):
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Incompatible provider version",
fmt.Sprintf(providerProtocolTooNew, provider.ForDisplay(),
version, tfversion.String(), closestAvailable, closestAvailable,
getproviders.VersionConstraintsString(reqs[provider]),
),
))
default: // version is less than closestAvailable
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Incompatible provider version",
fmt.Sprintf(providerProtocolTooOld, provider.ForDisplay(),
version, tfversion.String(), closestAvailable, closestAvailable,
getproviders.VersionConstraintsString(reqs[provider]),
),
))
}
default:
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to install provider",
fmt.Sprintf("Error while installing %s v%s: %s", provider.ForDisplay(), version, err),
))
}
},
FetchPackageSuccess: func(provider addrs.Provider, version getproviders.Version, localDir string, authResult *getproviders.PackageAuthenticationResult) {
var warning string
@ -905,3 +937,34 @@ A later version of Terraform may have introduced other signing keys that would
accept this provider. Alternatively, an earlier version of this provider may
be compatible with Terraform v%[2]s.
`
// providerProtocolTooOld is a message sent to the CLI UI if the provider's
// supported protocol versions are too old for the user's version of terraform,
// but a newer version of the provider is compatible.
const providerProtocolTooOld = `Provider %q v%s is not compatible with Terraform %s.
Provider version %s is the latest compatible version. Select it with the following version constraint:
version = %q
Terraform checked all of the plugin versions matching the given constraint:
%s
Consult the documentation for this provider for more information on compatibility between provider and Terraform versions.
`
// providerProtocolTooNew is a message sent to the CLI UI if the provider's
// supported protocol versions are too new for the user's version of terraform,
// and the user could either upgrade terraform or choose an older version of the
// provider.
const providerProtocolTooNew = `Provider %q v%s is not compatible with Terraform %s.
You need to downgrade to v%s or earlier. Select it with the following constraint:
version = %q
Terraform checked all of the plugin versions matching the given constraint:
%s
Consult the documentation for this provider for more information on compatibility between provider and Terraform versions.
Alternatively, upgrade to the latest version of Terraform for compatibility with newer provider releases.
`
// No version of the provider is compatible.
const errProviderVersionIncompatible = `No compatible versions of provider %s were found.`

View File

@ -119,6 +119,27 @@ func (err ErrPlatformNotSupported) Error() string {
)
}
// ErrProtocolNotSupported is an error type used to indicate that a particular
// version of a provider is not supported by the current version of Terraform.
//
// Specfically, this is returned when the version's plugin protocol is not supported.
//
// When available, the error will include a suggested version that can be displayed to
// the user. Otherwise it will return UnspecifiedVersion
type ErrProtocolNotSupported struct {
Provider addrs.Provider
Version Version
Suggestion Version
}
func (err ErrProtocolNotSupported) Error() string {
return fmt.Sprintf(
"provider %s %s is not supported by this version of terraform",
err.Provider,
err.Version,
)
}
// ErrQueryFailed is an error type used to indicate that the hostname given
// in a provider address does appear to be a provider registry but that when
// we queried it for metadata for the given provider the server returned an

View File

@ -12,7 +12,6 @@ import (
"path"
"time"
"github.com/apparentlymart/go-versions/versions"
svchost "github.com/hashicorp/terraform-svchost"
svcauth "github.com/hashicorp/terraform-svchost/auth"
@ -23,6 +22,8 @@ import (
const terraformVersionHeader = "X-Terraform-Version"
var SupportedPluginProtocols = MustParseVersionConstraints("~> 5")
// 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.
@ -44,14 +45,14 @@ func newRegistryClient(baseURL *url.URL, creds svcauth.HostCredentials) *registr
}
}
// ProviderVersions returns the raw version strings produced by the registry
// for the given provider.
// ProviderVersions returns the raw version and protocol strings produced by the
// registry for the given provider.
//
// The returned error will be ErrProviderNotKnown 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(addr addrs.Provider) ([]string, error) {
// The returned error will be ErrProviderNotKnown 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(addr addrs.Provider) (map[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
@ -85,23 +86,13 @@ func (c *registryClient) ProviderVersions(addr addrs.Provider) ([]string, error)
return nil, c.errQueryFailed(addr, errors.New(resp.Status))
}
// We ignore everything except the version numbers here because our goal
// is to find out which versions are available _at all_. Which ones are
// compatible with the current Terraform becomes relevant only once we've
// selected one, at which point we'll return an error if the selected one
// is incompatible.
//
// We intentionally produce an error on incompatibility, rather than
// silently ignoring an incompatible version, in order to give the user
// explicit feedback about why their selection wasn't valid and allow them
// to decide whether to fix that by changing the selection or by some other
// action such as upgrading Terraform, using a different OS to run
// Terraform, etc. Changes that affect compatibility are considered
// breaking changes from a provider API standpoint, so provider teams
// should change compatibility only in new major versions.
// 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"`
Version string `json:"version"`
Protocols []string `json:"protocols"`
} `json:"versions"`
}
var body ResponseBody
@ -115,21 +106,24 @@ func (c *registryClient) ProviderVersions(addr addrs.Provider) ([]string, error)
return nil, nil
}
ret := make([]string, len(body.Versions))
for i, v := range body.Versions {
ret[i] = v.Version
ret := make(map[string][]string, len(body.Versions))
for _, v := range body.Versions {
ret[v.Version] = v.Protocols
}
return ret, nil
}
// PackageMeta returns metadata about a distribution package for a
// provider.
// PackageMeta returns metadata about a distribution package for a provider.
//
// The returned error will be ErrPlatformNotSupported if the registry responds
// with 404 Not Found, under the assumption that the caller previously checked
// that the provider and version are valid. It will return ErrUnauthorized if
// the registry responds with 401 or 403 status codes, or ErrQueryFailed for
// any other protocol or operational problem.
// 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(provider addrs.Provider, version Version, target Platform) (PackageMeta, error) {
endpointPath, err := url.Parse(path.Join(
provider.Namespace,
@ -198,7 +192,7 @@ func (c *registryClient) PackageMeta(provider addrs.Provider, version Version, t
var protoVersions VersionList
for _, versionStr := range body.Protocols {
v, err := versions.ParseVersion(versionStr)
v, err := ParseVersion(versionStr)
if err != nil {
return PackageMeta{}, c.errQueryFailed(
provider,
@ -209,6 +203,32 @@ func (c *registryClient) PackageMeta(provider addrs.Provider, version Version, t
}
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 == false {
// If the protocol version is not supported, try to find the closest
// matching version.
closest, err := c.findClosestProtocolCompatibleVersion(provider, version)
if err != nil {
return PackageMeta{}, err
}
protoErr.Suggestion = closest
return PackageMeta{}, protoErr
}
}
downloadURL, err := url.Parse(body.DownloadURL)
if err != nil {
return PackageMeta{}, fmt.Errorf("registry response includes invalid download URL: %s", err)
@ -293,6 +313,50 @@ func (c *registryClient) PackageMeta(provider addrs.Provider, version Version, t
}
// LegacyProviderDefaultNamespace returns the raw address strings produced by
// findClosestProtocolCompatibleVersion searches for the provider version with the closest protocol match.
func (c *registryClient) findClosestProtocolCompatibleVersion(provider addrs.Provider, version Version) (Version, error) {
var match Version
available, err := c.ProviderVersions(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
}
// LegacyProviderCanonicalAddress returns the raw address strings produced by
// the registry when asked about the given unqualified provider type name.
// The returned namespace string is taken verbatim from the registry's response.
//

View File

@ -2,14 +2,18 @@ package getproviders
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/apparentlymart/go-versions/versions"
"github.com/google/go-cmp/cmp"
svchost "github.com/hashicorp/terraform-svchost"
disco "github.com/hashicorp/terraform-svchost/disco"
"github.com/hashicorp/terraform/addrs"
)
// testServices starts up a local HTTP server running a fake provider registry
@ -135,7 +139,11 @@ func fakeRegistryHandler(resp http.ResponseWriter, req *http.Request) {
// Note that these version numbers are intentionally misordered
// so we can test that the client-side code places them in the
// correct order (lowest precedence first).
resp.Write([]byte(`{"versions":[{"version":"1.2.0"}, {"version":"1.0.0"}]}`))
resp.Write([]byte(`{"versions":[{"version":"0.1.0","protocols":["1.0"]},{"version":"2.0.0","protocols":["99.0"]},{"version":"1.2.0","protocols":["5.0"]}, {"version":"1.0.0","protocols":["5.0"]}]}`))
case "weaksauce/unsupported-protocol":
resp.Header().Set("Content-Type", "application/json")
resp.WriteHeader(200)
resp.Write([]byte(`{"versions":[{"version":"1.0.0","protocols":["0.1"]}]}`))
case "weaksauce/no-versions":
resp.Header().Set("Content-Type", "application/json")
resp.WriteHeader(200)
@ -170,15 +178,26 @@ func fakeRegistryHandler(resp http.ResponseWriter, req *http.Request) {
resp.Write([]byte(`unsupported OS`))
return
}
var protocols []string
version := pathParts[2]
switch version {
case "0.1.0":
protocols = []string{"1.0"}
case "2.0.0":
protocols = []string{"99.0"}
default:
protocols = []string{"5.0"}
}
body := map[string]interface{}{
"protocols": []string{"5.0"},
"protocols": protocols,
"os": pathParts[4],
"arch": pathParts[5],
"filename": "happycloud_" + pathParts[2] + ".zip",
"filename": "happycloud_" + version + ".zip",
"shasum": "000000000000000000000000000000000000000000000000000000000000f00d",
"download_url": "/pkg/awesomesauce/happycloud_" + pathParts[2] + ".zip",
"shasums_url": "/pkg/awesomesauce/happycloud_" + pathParts[2] + "_SHA256SUMS",
"shasums_signature_url": "/pkg/awesomesauce/happycloud_" + pathParts[2] + "_SHA256SUMS.sig",
"download_url": "/pkg/awesomesauce/happycloud_" + version + ".zip",
"shasums_url": "/pkg/awesomesauce/happycloud_" + version + "_SHA256SUMS",
"shasums_signature_url": "/pkg/awesomesauce/happycloud_" + version + "_SHA256SUMS.sig",
"signing_keys": map[string]interface{}{
"gpg_public_keys": []map[string]interface{}{
{
@ -205,3 +224,144 @@ func fakeRegistryHandler(resp http.ResponseWriter, req *http.Request) {
resp.WriteHeader(404)
resp.Write([]byte(`unrecognized path scheme`))
}
func TestProviderVersions(t *testing.T) {
source, _, close := testRegistrySource(t)
defer close()
tests := []struct {
provider addrs.Provider
wantVersions map[string][]string
wantErr string
}{
{
addrs.MustParseProviderSourceString("example.com/awesomesauce/happycloud"),
map[string][]string{
"0.1.0": {"1.0"},
"1.0.0": {"5.0"},
"1.2.0": {"5.0"},
"2.0.0": {"99.0"},
},
``,
},
{
addrs.MustParseProviderSourceString("example.com/weaksauce/no-versions"),
nil,
``,
},
{
addrs.MustParseProviderSourceString("example.com/nonexist/nonexist"),
nil,
`provider registry example.com does not have a provider named example.com/nonexist/nonexist`,
},
}
for _, test := range tests {
t.Run(test.provider.String(), func(t *testing.T) {
client, err := source.registryClient(test.provider.Hostname)
if err != nil {
t.Fatal(err)
}
gotVersions, err := client.ProviderVersions(test.provider)
if err != nil {
if test.wantErr == "" {
t.Fatalf("wrong error\ngot: %s\nwant: <nil>", err.Error())
}
if got, want := err.Error(), test.wantErr; got != want {
t.Fatalf("wrong error\ngot: %s\nwant: %s", got, want)
}
return
}
if test.wantErr != "" {
t.Fatalf("wrong error\ngot: <nil>\nwant: %s", test.wantErr)
}
if diff := cmp.Diff(test.wantVersions, gotVersions); diff != "" {
t.Errorf("wrong result\n%s", diff)
}
})
}
}
func TestFindClosestProtocolCompatibleVersion(t *testing.T) {
source, _, close := testRegistrySource(t)
defer close()
tests := map[string]struct {
provider addrs.Provider
version Version
wantSuggestion Version
wantErr string
}{
"pinned version too old": {
addrs.MustParseProviderSourceString("example.com/awesomesauce/happycloud"),
MustParseVersion("0.1.0"),
MustParseVersion("1.2.0"),
``,
},
"pinned version too new": {
addrs.MustParseProviderSourceString("example.com/awesomesauce/happycloud"),
MustParseVersion("2.0.0"),
MustParseVersion("1.2.0"),
``,
},
// This should not actually happen, the function is only meant to be
// called when the requested provider version is not supported
"pinned version just right": {
addrs.MustParseProviderSourceString("example.com/awesomesauce/happycloud"),
MustParseVersion("1.2.0"),
MustParseVersion("1.2.0"),
``,
},
"nonexisting provider": {
addrs.MustParseProviderSourceString("example.com/nonexist/nonexist"),
MustParseVersion("1.2.0"),
versions.Unspecified,
`provider registry example.com does not have a provider named example.com/nonexist/nonexist`,
},
"versionless provider": {
addrs.MustParseProviderSourceString("example.com/weaksauce/no-versions"),
MustParseVersion("1.2.0"),
versions.Unspecified,
``,
},
"unsupported provider protocol": {
addrs.MustParseProviderSourceString("example.com/weaksauce/unsupported-protocol"),
MustParseVersion("1.0.0"),
versions.Unspecified,
``,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
client, err := source.registryClient(test.provider.Hostname)
if err != nil {
t.Fatal(err)
}
got, err := client.findClosestProtocolCompatibleVersion(test.provider, test.version)
if err != nil {
if test.wantErr == "" {
t.Fatalf("wrong error\ngot: %s\nwant: <nil>", err.Error())
}
if got, want := err.Error(), test.wantErr; got != want {
t.Fatalf("wrong error\ngot: %s\nwant: %s", got, want)
}
return
}
if test.wantErr != "" {
t.Fatalf("wrong error\ngot: <nil>\nwant: %s", test.wantErr)
}
fmt.Printf("Got: %s, Want: %s\n", got, test.wantSuggestion)
if !got.Same(test.wantSuggestion) {
t.Fatalf("wrong result\ngot: %s\nwant: %s", got.String(), test.wantSuggestion.String())
}
})
}
}

View File

@ -38,17 +38,31 @@ func (s *RegistrySource) AvailableVersions(provider addrs.Provider) (VersionList
return nil, err
}
versionStrs, err := client.ProviderVersions(provider)
versionProtosMap, err := client.ProviderVersions(provider)
if err != nil {
return nil, err
}
if len(versionStrs) == 0 {
if len(versionProtosMap) == 0 {
return nil, nil
}
ret := make(VersionList, len(versionStrs))
for i, str := range versionStrs {
// We ignore everything except the version numbers here because our goal
// is to find out which versions are available _at all_. Which ones are
// compatible with the current Terraform becomes relevant only once we've
// selected one, at which point we'll return an error if the selected one
// is incompatible.
//
// We intentionally produce an error on incompatibility, rather than
// silently ignoring an incompatible version, in order to give the user
// explicit feedback about why their selection wasn't valid and allow them
// to decide whether to fix that by changing the selection or by some other
// action such as upgrading Terraform, using a different OS to run
// Terraform, etc. Changes that affect compatibility are considered
// breaking changes from a provider API standpoint, so provider teams
// should change compatibility only in new major versions.
ret := make(VersionList, 0, len(versionProtosMap))
for str := range versionProtosMap {
v, err := ParseVersion(str)
if err != nil {
return nil, ErrQueryFailed{
@ -56,7 +70,7 @@ func (s *RegistrySource) AvailableVersions(provider addrs.Provider) (VersionList
Wrapped: fmt.Errorf("registry response includes invalid version string %q: %s", str, err),
}
}
ret[i] = v
ret = append(ret, v)
}
ret.Sort() // lowest precedence first, preserving order when equal precedence
return ret, nil

View File

@ -26,7 +26,7 @@ func TestSourceAvailableVersions(t *testing.T) {
// registry server implemented in registry_client_test.go.
{
"example.com/awesomesauce/happycloud",
[]string{"1.0.0", "1.2.0"},
[]string{"0.1.0", "1.0.0", "1.2.0", "2.0.0"},
``,
},
{

View File

@ -15,6 +15,10 @@ import (
// Version represents a particular single version of a provider.
type Version = versions.Version
// UnspecifiedVersion is the zero value of Version, representing the absense
// of a version number.
var UnspecifiedVersion Version = versions.Unspecified
// VersionList represents a list of versions. It is a []Version with some
// extra methods for convenient filtering.
type VersionList = versions.List
@ -94,6 +98,13 @@ func MustParseVersionConstraints(str string) VersionConstraints {
return ret
}
// MeetingConstraints returns a version set that contains all of the versions
// that meet the given constraints, specified using the Spec type from the
// constraints package.
func MeetingConstraints(vc VersionConstraints) VersionSet {
return versions.MeetingConstraints(vc)
}
// Platform represents a target platform that a provider is or might be
// available for.
type Platform struct {

View File

@ -12,7 +12,6 @@ import (
"github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/internal/copydir"
"github.com/hashicorp/terraform/internal/getproviders"
tfversion "github.com/hashicorp/terraform/version"
)
// Installer is the main type in this package, representing a provider installer
@ -42,17 +41,8 @@ type Installer struct {
// namespace, which we use for providers that are built in to Terraform
// and thus do not need any separate installation step.
builtInProviderTypes []string
// pluginProtocolVersion is the protocol version terrafrom core supports to
// communicate with servers, and is used to resolve plugin discovery with
// terraform registry, in addition to any specified plugin version
// constraints.
pluginProtocolVersion getproviders.VersionConstraints
}
// The currently-supported plugin protocol version.
var SupportedPluginProtocols = getproviders.MustParseVersionConstraints("~> 5")
// NewInstaller constructs and returns a new installer with the given target
// directory and provider source.
//
@ -64,9 +54,8 @@ var SupportedPluginProtocols = getproviders.MustParseVersionConstraints("~> 5")
// or the result is undefined.
func NewInstaller(targetDir *Dir, source getproviders.Source) *Installer {
return &Installer{
targetDir: targetDir,
source: source,
pluginProtocolVersion: SupportedPluginProtocols,
targetDir: targetDir,
source: source,
}
}
@ -312,46 +301,6 @@ NeedProvider:
continue
}
// if the package meta includes provider protocol versions, verify that terraform supports it.
if len(meta.ProtocolVersions) > 0 {
protoVersions := versions.MeetingConstraints(i.pluginProtocolVersion)
match := false
for _, version := range meta.ProtocolVersions {
if protoVersions.Has(version) {
match = true
}
}
if match == false {
// Find the closest matching version
closestAvailable := i.findClosestProtocolCompatibleVersion(provider, version)
if closestAvailable == versions.Unspecified {
err := fmt.Errorf(errProviderVersionIncompatible, provider)
errs[provider] = err
if cb := evts.FetchPackageFailure; cb != nil {
cb(provider, version, err)
}
continue
}
// Determine if the closest matching provider is newer or older
// than the requirement in order to send the appropriate error
// message.
var protoErr string
if version.GreaterThan(closestAvailable) {
protoErr = providerProtocolTooNew
} else {
protoErr = providerProtocolTooOld
}
err := fmt.Errorf(protoErr, provider, version, tfversion.String(), closestAvailable.String(), closestAvailable.String(), getproviders.VersionConstraintsString(reqs[provider]))
errs[provider] = err
if cb := evts.FetchPackageFailure; cb != nil {
cb(provider, version, err)
}
continue
}
}
// Step 3c: Retrieve the package indicated by the metadata we received,
// either directly into our target directory or via the global cache
// directory.
@ -549,59 +498,3 @@ func (err InstallerError) Error() string {
}
return b.String()
}
// findClosestProtocolCompatibleVersion searches for the provider version with the closest protocol match.
func (i *Installer) findClosestProtocolCompatibleVersion(provider addrs.Provider, version versions.Version) versions.Version {
var match versions.Version
available, _ := i.source.AvailableVersions(provider)
available.Sort()
// put the versions in increasing order of precedence
FindMatch:
for index := len(available) - 1; index >= 0; index-- { // walk backwards to consider newer versions first
meta, _ := i.source.PackageMeta(provider, available[index], i.targetDir.targetPlatform)
if len(meta.ProtocolVersions) > 0 {
protoVersions := versions.MeetingConstraints(i.pluginProtocolVersion)
for _, version := range meta.ProtocolVersions {
if protoVersions.Has(version) {
match = available[index]
break FindMatch // we will only consider the newest matching version
}
}
}
}
return match
}
// providerProtocolTooOld is a message sent to the CLI UI if the provider's
// supported protocol versions are too old for the user's version of terraform,
// but an older version of the provider is compatible.
const providerProtocolTooOld = `
Provider %q v%s is not compatible with Terraform %s.
Provider version %s is the earliest compatible version. Select it with
the following version constraint:
version = %q
Terraform checked all of the plugin versions matching the given constraint:
%s
Consult the documentation for this provider for more information on
compatibility between provider and Terraform versions.
`
// providerProtocolTooNew is a message sent to the CLI UI if the provider's
// supported protocol versions are too new for the user's version of terraform,
// and the user could either upgrade terraform or choose an older version of the
// provider
const providerProtocolTooNew = `
Provider %q v%s is not compatible with Terraform %s.
Provider version %s is the latest compatible version. Select it with
the following constraint:
version = %q
Terraform checked all of the plugin versions matching the given constraint:
%s
Consult the documentation for this provider for more information on
compatibility between provider and Terraform versions.
Alternatively, upgrade to the latest version of Terraform for compatibility with newer provider releases.
`
// there does exist a version outside of the constaints that is compatible.
const errProviderVersionIncompatible = `No compatible versions of provider %s were found.`

View File

@ -2,43 +2,27 @@ package providercache
import (
"context"
"encoding/json"
"io/ioutil"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
svchost "github.com/hashicorp/terraform-svchost"
"github.com/hashicorp/terraform-svchost/disco"
"github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/internal/getproviders"
)
func TestEnsureProviderVersions(t *testing.T) {
// Set up a test provider "foo" with two versions which support different protocols
// used by both package metas
provider := addrs.NewDefaultProvider("foo")
platform := getproviders.Platform{OS: "gameboy", Arch: "lr35902"}
// foo version 1.0 supports protocol 4
version1 := getproviders.MustParseVersion("1.0.0")
protocols1 := getproviders.VersionList{getproviders.MustParseVersion("4.0")}
meta1, close1, _ := getproviders.FakeInstallablePackageMeta(provider, version1, protocols1, platform)
defer close1()
// foo version 2.0 supports protocols 4 and 5.2
version2 := getproviders.MustParseVersion("2.0.0")
protocols2 := getproviders.VersionList{getproviders.MustParseVersion("4.0"), getproviders.MustParseVersion("5.2")}
meta2, close2, _ := getproviders.FakeInstallablePackageMeta(provider, version2, protocols2, platform)
defer close2()
// foo version 3.0 supports protocol 6
version3 := getproviders.MustParseVersion("3.0.0")
protocols3 := getproviders.VersionList{getproviders.MustParseVersion("6.0")}
meta3, close3, _ := getproviders.FakeInstallablePackageMeta(provider, version3, protocols3, platform)
defer close3()
// set up the mock source
source := getproviders.NewMockSource(
[]getproviders.PackageMeta{meta1, meta2, meta3},
)
// This test only verifies protocol errors and does not try for successfull
// installation (at the time of writing, the test files aren't signed so the
// signature verification fails); that's left to the e2e tests.
func TestEnsureProviderVersions_protocol_errors(t *testing.T) {
source, _, close := testRegistrySource(t)
defer close()
// create a temporary workdir
tmpDirPath, err := ioutil.TempDir("", "terraform-test-providercache")
@ -47,54 +31,308 @@ func TestEnsureProviderVersions(t *testing.T) {
}
defer os.RemoveAll(tmpDirPath)
version0 := getproviders.MustParseVersionConstraints("0.1.0") // supports protocol version 1.0
version1 := getproviders.MustParseVersion("1.2.0") // this is the expected result in tests with a match
version2 := getproviders.MustParseVersionConstraints("2.0") // supports protocol version 99
// set up the installer using the temporary directory and mock source
platform := getproviders.Platform{OS: "gameboy", Arch: "lr35902"}
dir := NewDirWithPlatform(tmpDirPath, platform)
installer := NewInstaller(dir, source)
// First test: easy case. The requested version supports the current plugin protocol version
reqs := getproviders.Requirements{
provider: getproviders.MustParseVersionConstraints("2.0"),
}
ctx := context.TODO()
selections, err := installer.EnsureProviderVersions(ctx, reqs, InstallNewProvidersOnly)
if err != nil {
t.Fatalf("expected sucess, got error: %s", err)
}
if len(selections) != 1 {
t.Fatalf("wrong number of results. Got %d, expected 1", len(selections))
}
got := selections[provider]
if !got.Same(version2) {
t.Fatalf("wrong result. Expected provider version %s, got %s", version2, got)
tests := map[string]struct {
provider addrs.Provider
inputVersion getproviders.VersionConstraints
wantVersion getproviders.Version
}{
"too old": {
addrs.MustParseProviderSourceString("example.com/awesomesauce/happycloud"),
version0,
version1,
},
"too new": {
addrs.MustParseProviderSourceString("example.com/awesomesauce/happycloud"),
version2,
version1,
},
"unsupported": {
addrs.MustParseProviderSourceString("example.com/weaksauce/unsupported-protocol"),
version0,
getproviders.UnspecifiedVersion,
},
}
// For the second test, set the requirement to something later than the
// version that supports the current plugin protocol version 5.0
reqs[provider] = getproviders.MustParseVersionConstraints("3.0")
for name, test := range tests {
t.Run(name, func(t *testing.T) {
reqs := getproviders.Requirements{
test.provider: test.inputVersion,
}
ctx := context.TODO()
_, err := installer.EnsureProviderVersions(ctx, reqs, InstallNewProvidersOnly)
selections, err = installer.EnsureProviderVersions(ctx, reqs, InstallNewProvidersOnly)
if err == nil {
t.Fatalf("expected error, got success")
}
if len(selections) != 0 {
t.Errorf("wrong number of results. Got %d, expected 0", len(selections))
}
if !strings.Contains(err.Error(), "Provider version 2.0.0 is the latest compatible version.") {
t.Fatalf("wrong error: %s", err)
}
switch err := err.(type) {
case nil:
t.Fatalf("expected error, got success")
case InstallerError:
providerError, ok := err.ProviderErrors[test.provider]
if !ok {
t.Fatalf("did not get error for provider %s", test.provider)
}
// For the third test, set the requirement to something earlier than the
// version that supports the current plugin protocol version 5.0
reqs[provider] = getproviders.MustParseVersionConstraints("1.0")
selections, err = installer.EnsureProviderVersions(ctx, reqs, InstallNewProvidersOnly)
if err == nil {
t.Fatalf("expected error, got success")
}
if len(selections) != 0 {
t.Errorf("wrong number of results. Got %d, expected 0", len(selections))
}
if !strings.Contains(err.Error(), "Provider version 2.0.0 is the earliest compatible version.") {
t.Fatalf("wrong error: %s", err)
switch providerError := providerError.(type) {
case getproviders.ErrProtocolNotSupported:
if !providerError.Suggestion.Same(test.wantVersion) {
t.Fatalf("wrong result\ngot: %s\nwant: %s\n", providerError.Suggestion, test.wantVersion)
}
default:
t.Fatalf("wrong error type. Expected ErrProtocolNotSupported, got %T", err)
}
default:
t.Fatalf("wrong error type. Expected InstallerError, got %T", err)
}
})
}
}
// testServices starts up a local HTTP server running a fake provider registry
// service and returns a service discovery object pre-configured to consider
// the host "example.com" to be served by the fake registry service.
//
// The returned discovery object also knows the hostname "not.example.com"
// which does not have a provider registry at all and "too-new.example.com"
// which has a "providers.v99" service that is inoperable but could be useful
// to test the error reporting for detecting an unsupported protocol version.
// It also knows fails.example.com but it refers to an endpoint that doesn't
// correctly speak HTTP, to simulate a protocol error.
//
// 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
// object becomes useless.
func testServices(t *testing.T) (services *disco.Disco, baseURL string, cleanup func()) {
server := httptest.NewServer(http.HandlerFunc(fakeRegistryHandler))
services = disco.New()
services.ForceHostServices(svchost.Hostname("example.com"), map[string]interface{}{
"providers.v1": server.URL + "/providers/v1/",
})
services.ForceHostServices(svchost.Hostname("not.example.com"), map[string]interface{}{})
services.ForceHostServices(svchost.Hostname("too-new.example.com"), map[string]interface{}{
// This service doesn't actually work; it's here only to be
// detected as "too new" by the discovery logic.
"providers.v99": server.URL + "/providers/v99/",
})
services.ForceHostServices(svchost.Hostname("fails.example.com"), map[string]interface{}{
"providers.v1": server.URL + "/fails-immediately/",
})
// We'll also permit registry.terraform.io here just because it's our
// default and has some unique features that are not allowed on any other
// hostname. It behaves the same as example.com, which should be preferred
// if you're not testing something specific to the default registry in order
// to ensure that most things are hostname-agnostic.
services.ForceHostServices(svchost.Hostname("registry.terraform.io"), map[string]interface{}{
"providers.v1": server.URL + "/providers/v1/",
})
return services, server.URL, func() {
server.Close()
}
}
// testRegistrySource is a wrapper around testServices that uses the created
// discovery object to produce a Source instance that is ready to use with the
// fake registry services.
//
// 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.
func testRegistrySource(t *testing.T) (source *getproviders.RegistrySource, baseURL string, cleanup func()) {
services, baseURL, close := testServices(t)
source = getproviders.NewRegistrySource(services)
return source, baseURL, close
}
func fakeRegistryHandler(resp http.ResponseWriter, req *http.Request) {
path := req.URL.EscapedPath()
if strings.HasPrefix(path, "/fails-immediately/") {
// Here we take over the socket and just close it immediately, to
// simulate one possible way a server might not be an HTTP server.
hijacker, ok := resp.(http.Hijacker)
if !ok {
// Not hijackable, so we'll just fail normally.
// If this happens, tests relying on this will fail.
resp.WriteHeader(500)
resp.Write([]byte(`cannot hijack`))
return
}
conn, _, err := hijacker.Hijack()
if err != nil {
resp.WriteHeader(500)
resp.Write([]byte(`hijack failed`))
return
}
conn.Close()
return
}
if strings.HasPrefix(path, "/pkg/") {
switch path {
case "/pkg/awesomesauce/happycloud_1.2.0.zip":
resp.Write([]byte("some zip file"))
case "/pkg/awesomesauce/happycloud_1.2.0_SHA256SUMS":
resp.Write([]byte("000000000000000000000000000000000000000000000000000000000000f00d happycloud_1.2.0.zip\n"))
case "/pkg/awesomesauce/happycloud_1.2.0_SHA256SUMS.sig":
resp.Write([]byte("GPG signature"))
default:
resp.WriteHeader(404)
resp.Write([]byte("unknown package file download"))
}
return
}
if !strings.HasPrefix(path, "/providers/v1/") {
resp.WriteHeader(404)
resp.Write([]byte(`not a provider registry endpoint`))
return
}
pathParts := strings.Split(path, "/")[3:]
if len(pathParts) < 2 {
resp.WriteHeader(404)
resp.Write([]byte(`unexpected number of path parts`))
return
}
log.Printf("[TRACE] fake provider registry request for %#v", pathParts)
if len(pathParts) == 2 {
switch pathParts[0] + "/" + pathParts[1] {
case "-/legacy":
// NOTE: This legacy lookup endpoint is specific to
// registry.terraform.io and not expected to work on any other
// registry host.
resp.Header().Set("Content-Type", "application/json")
resp.WriteHeader(200)
resp.Write([]byte(`{"namespace":"legacycorp"}`))
default:
resp.WriteHeader(404)
resp.Write([]byte(`unknown namespace or provider type for direct lookup`))
}
}
if len(pathParts) < 3 {
resp.WriteHeader(404)
resp.Write([]byte(`unexpected number of path parts`))
return
}
if pathParts[2] == "versions" {
if len(pathParts) != 3 {
resp.WriteHeader(404)
resp.Write([]byte(`extraneous path parts`))
return
}
switch pathParts[0] + "/" + pathParts[1] {
case "awesomesauce/happycloud":
resp.Header().Set("Content-Type", "application/json")
resp.WriteHeader(200)
// Note that these version numbers are intentionally misordered
// so we can test that the client-side code places them in the
// correct order (lowest precedence first).
resp.Write([]byte(`{"versions":[{"version":"0.1.0","protocols":["1.0"]},{"version":"2.0.0","protocols":["99.0"]},{"version":"1.2.0","protocols":["5.0"]}, {"version":"1.0.0","protocols":["5.0"]}]}`))
case "weaksauce/unsupported-protocol":
resp.Header().Set("Content-Type", "application/json")
resp.WriteHeader(200)
resp.Write([]byte(`{"versions":[{"version":"0.1.0","protocols":["0.1"]}]}`))
case "weaksauce/no-versions":
resp.Header().Set("Content-Type", "application/json")
resp.WriteHeader(200)
resp.Write([]byte(`{"versions":[]}`))
default:
resp.WriteHeader(404)
resp.Write([]byte(`unknown namespace or provider type`))
}
return
}
if len(pathParts) == 6 && pathParts[3] == "download" {
switch pathParts[0] + "/" + pathParts[1] {
case "awesomesauce/happycloud":
if pathParts[4] == "nonexist" {
resp.WriteHeader(404)
resp.Write([]byte(`unsupported OS`))
return
}
version := pathParts[2]
body := map[string]interface{}{
"protocols": []string{"99.0"},
"os": pathParts[4],
"arch": pathParts[5],
"filename": "happycloud_" + version + ".zip",
"shasum": "000000000000000000000000000000000000000000000000000000000000f00d",
"download_url": "/pkg/awesomesauce/happycloud_" + version + ".zip",
"shasums_url": "/pkg/awesomesauce/happycloud_" + version + "_SHA256SUMS",
"shasums_signature_url": "/pkg/awesomesauce/happycloud_" + version + "_SHA256SUMS.sig",
"signing_keys": map[string]interface{}{
"gpg_public_keys": []map[string]interface{}{
{
"ascii_armor": getproviders.HashicorpPublicKey,
},
},
},
}
enc, err := json.Marshal(body)
if err != nil {
resp.WriteHeader(500)
resp.Write([]byte("failed to encode body"))
}
resp.Header().Set("Content-Type", "application/json")
resp.WriteHeader(200)
resp.Write(enc)
case "weaksauce/unsupported-protocol":
var protocols []string
version := pathParts[2]
switch version {
case "0.1.0":
protocols = []string{"1.0"}
case "2.0.0":
protocols = []string{"99.0"}
default:
protocols = []string{"5.0"}
}
body := map[string]interface{}{
"protocols": protocols,
"os": pathParts[4],
"arch": pathParts[5],
"filename": "happycloud_" + version + ".zip",
"shasum": "000000000000000000000000000000000000000000000000000000000000f00d",
"download_url": "/pkg/awesomesauce/happycloud_" + version + ".zip",
"shasums_url": "/pkg/awesomesauce/happycloud_" + version + "_SHA256SUMS",
"shasums_signature_url": "/pkg/awesomesauce/happycloud_" + version + "_SHA256SUMS.sig",
"signing_keys": map[string]interface{}{
"gpg_public_keys": []map[string]interface{}{
{
"ascii_armor": getproviders.HashicorpPublicKey,
},
},
},
}
enc, err := json.Marshal(body)
if err != nil {
resp.WriteHeader(500)
resp.Write([]byte("failed to encode body"))
}
resp.Header().Set("Content-Type", "application/json")
resp.WriteHeader(200)
resp.Write(enc)
default:
resp.WriteHeader(404)
resp.Write([]byte(`unknown namespace/provider/version/architecture`))
}
return
}
resp.WriteHeader(404)
resp.Write([]byte(`unrecognized path scheme`))
}