diff --git a/addrs/provider.go b/addrs/provider.go index 9e13957e28..c13ed80467 100644 --- a/addrs/provider.go +++ b/addrs/provider.go @@ -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. diff --git a/command/init.go b/command/init.go index 47744cc249..f0fd59a2db 100644 --- a/command/init.go +++ b/command/init.go @@ -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.` diff --git a/internal/getproviders/errors.go b/internal/getproviders/errors.go index 784ffbfd3c..96ff8d1297 100644 --- a/internal/getproviders/errors.go +++ b/internal/getproviders/errors.go @@ -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 diff --git a/internal/getproviders/registry_client.go b/internal/getproviders/registry_client.go index 7179ab08d4..907ddbd9c8 100644 --- a/internal/getproviders/registry_client.go +++ b/internal/getproviders/registry_client.go @@ -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. // diff --git a/internal/getproviders/registry_client_test.go b/internal/getproviders/registry_client_test.go index 128b884742..dbbc74ff81 100644 --- a/internal/getproviders/registry_client_test.go +++ b/internal/getproviders/registry_client_test.go @@ -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: ", 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: \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: ", 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: \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()) + } + }) + } +} diff --git a/internal/getproviders/registry_source.go b/internal/getproviders/registry_source.go index 301f431a11..52037b4f90 100644 --- a/internal/getproviders/registry_source.go +++ b/internal/getproviders/registry_source.go @@ -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 diff --git a/internal/getproviders/registry_source_test.go b/internal/getproviders/registry_source_test.go index 191f773fcd..258985d10f 100644 --- a/internal/getproviders/registry_source_test.go +++ b/internal/getproviders/registry_source_test.go @@ -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"}, ``, }, { diff --git a/internal/getproviders/types.go b/internal/getproviders/types.go index 4abbac340b..4c485adb5a 100644 --- a/internal/getproviders/types.go +++ b/internal/getproviders/types.go @@ -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 { diff --git a/internal/providercache/installer.go b/internal/providercache/installer.go index 4a3ee47c71..2c97eaa6b5 100644 --- a/internal/providercache/installer.go +++ b/internal/providercache/installer.go @@ -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.` diff --git a/internal/providercache/installer_test.go b/internal/providercache/installer_test.go index e1db6a36fe..46d9624078 100644 --- a/internal/providercache/installer_test.go +++ b/internal/providercache/installer_test.go @@ -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`)) +}