diff --git a/internal/getproviders/doc.go b/internal/getproviders/doc.go new file mode 100644 index 000000000..a39aa1dda --- /dev/null +++ b/internal/getproviders/doc.go @@ -0,0 +1,11 @@ +// Package getproviders is the lowest-level provider automatic installation +// functionality. It can answer questions about what providers and provider +// versions are available in a registry, and it can retrieve the URL for +// the distribution archive for a specific version of a specific provider +// targeting a particular platform. +// +// This package is not responsible for choosing the best version to install +// from a set of available versions, or for any signature verification of the +// archives it fetches. Callers will use this package in conjunction with other +// logic elsewhere in order to construct a full provider installer. +package getproviders diff --git a/internal/getproviders/errors.go b/internal/getproviders/errors.go new file mode 100644 index 000000000..5710e5e05 --- /dev/null +++ b/internal/getproviders/errors.go @@ -0,0 +1,147 @@ +package getproviders + +import ( + "fmt" + + svchost "github.com/hashicorp/terraform-svchost" + "github.com/hashicorp/terraform/addrs" +) + +// ErrHostNoProviders is an error type used to indicate that a hostname given +// in a provider address does not support the provider registry protocol. +type ErrHostNoProviders struct { + Hostname svchost.Hostname + + // HasOtherVersionis set to true if the discovery process detected + // declarations of services named "providers" whose version numbers did not + // match any version supported by the current version of Terraform. + // + // If this is set, it's helpful to hint to the user in an error message + // that the provider host may be expecting an older or a newer version + // of Terraform, rather than that it isn't a provider registry host at all. + HasOtherVersion bool +} + +func (err ErrHostNoProviders) Error() string { + switch { + case err.HasOtherVersion: + return fmt.Sprintf("host %s does not support the provider registry protocol required by this Terraform version, but may be compatible with a different Terraform version", err.Hostname.ForDisplay()) + default: + return fmt.Sprintf("host %s does not offer a Terraform provider registry", err.Hostname.ForDisplay()) + } +} + +// ErrHostUnreachable is an error type used to indicate that a hostname +// given in a provider address did not resolve in DNS, did not respond to an +// HTTPS request for service discovery, or otherwise failed to correctly speak +// the service discovery protocol. +type ErrHostUnreachable struct { + Hostname svchost.Hostname + Wrapped error +} + +func (err ErrHostUnreachable) Error() string { + return fmt.Sprintf("could not connect to %s: %s", err.Hostname.ForDisplay(), err.Wrapped.Error()) +} + +// Unwrap returns the underlying error that occurred when trying to reach the +// indicated host. +func (err ErrHostUnreachable) Unwrap() error { + return err.Wrapped +} + +// ErrUnauthorized is an error type used to indicate that a hostname +// given in a provider address returned a "401 Unauthorized" or "403 Forbidden" +// error response when we tried to access it. +type ErrUnauthorized struct { + Hostname svchost.Hostname + + // HaveCredentials is true when the request that failed included some + // credentials, and thus it seems that those credentials were invalid. + // Conversely, HaveCredentials is false if the request did not include + // credentials at all, in which case it seems that credentials must be + // provided. + HaveCredentials bool +} + +func (err ErrUnauthorized) Error() string { + switch { + case err.HaveCredentials: + return fmt.Sprintf("host %s rejected the given authentication credentials", err.Hostname) + default: + return fmt.Sprintf("host %s requires authentication credentials", err.Hostname) + } +} + +// ErrProviderNotKnown is an error type used to indicate that the hostname +// given in a provider address does appear to be a provider registry but that +// registry does not know about the given provider namespace or type. +// +// A caller serving requests from an end-user should recognize this error type +// and use it to produce user-friendly hints for common errors such as failing +// to specify an explicit source for a provider not in the default namespace +// (one not under registry.terraform.io/hashicorp/). The default error message +// for this type is a direct description of the problem with no such hints, +// because we expect that the caller will have better context to decide what +// hints are appropriate, e.g. by looking at the configuration given by the +// user. +type ErrProviderNotKnown struct { + Provider addrs.Provider +} + +func (err ErrProviderNotKnown) Error() string { + return fmt.Sprintf( + "provider registry %s does not have a provider named %s", + err.Provider.Hostname.ForDisplay(), + err.Provider, + ) +} + +// ErrPlatformNotSupported is an error type used to indicate that a particular +// version of a provider isn't available for a particular target platform. +// +// This is returned when DownloadLocation encounters a 404 Not Found response +// from the underlying registry, because it presumes that a caller will only +// ask for the DownloadLocation for a version it already found the existence +// of via AvailableVersions. +type ErrPlatformNotSupported struct { + Provider addrs.Provider + Version Version + Platform Platform +} + +func (err ErrPlatformNotSupported) Error() string { + return fmt.Sprintf( + "provider %s %s is not available for %s", + err.Provider, + err.Version, + err.Platform, + ) +} + +// 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 +// unexpected error. +// +// This is used for any error responses other than "Not Found", which would +// indicate the absense of a provider and is thus reported using +// ErrProviderNotKnown instead. +type ErrQueryFailed struct { + Provider addrs.Provider + Wrapped error +} + +func (err ErrQueryFailed) Error() string { + return fmt.Sprintf( + "could not query provider registry for %s: %s", + err.Provider.String(), + err.Wrapped.Error(), + ) +} + +// Unwrap returns the underlying error that occurred when trying to reach the +// indicated host. +func (err ErrQueryFailed) Unwrap() error { + return err.Wrapped +} diff --git a/internal/getproviders/registry_client.go b/internal/getproviders/registry_client.go new file mode 100644 index 000000000..1ee561be6 --- /dev/null +++ b/internal/getproviders/registry_client.go @@ -0,0 +1,251 @@ +package getproviders + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "time" + + "github.com/apparentlymart/go-versions/versions" + svchost "github.com/hashicorp/terraform-svchost" + svcauth "github.com/hashicorp/terraform-svchost/auth" + + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/httpclient" + "github.com/hashicorp/terraform/version" +) + +const terraformVersionHeader = "X-Terraform-Version" + +// registryClient is a client for the provider registry protocol that is +// specialized only for the needs of this package. It's not intended as a +// general registry API client. +type registryClient struct { + baseURL *url.URL + creds svcauth.HostCredentials + + httpClient *http.Client +} + +func newRegistryClient(baseURL *url.URL, creds svcauth.HostCredentials) *registryClient { + httpClient := httpclient.New() + httpClient.Timeout = 10 * time.Second + + return ®istryClient{ + baseURL: baseURL, + creds: creds, + httpClient: httpClient, + } +} + +// ProviderVersions returns the raw version 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) { + endpointPath, err := url.Parse(path.Join(addr.Namespace, addr.Type, "versions")) + if err != nil { + // Should never happen because we're constructing this from + // already-validated components. + return nil, err + } + endpointURL := c.baseURL.ResolveReference(endpointPath) + + req, err := http.NewRequest("GET", endpointURL.String(), nil) + if err != nil { + return nil, err + } + c.addHeadersToRequest(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, c.errQueryFailed(addr, err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + // Great! + case http.StatusNotFound: + return nil, ErrProviderNotKnown{ + Provider: addr, + } + case http.StatusUnauthorized, http.StatusForbidden: + return nil, c.errUnauthorized(addr.Hostname) + default: + 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. + type ResponseBody struct { + Versions []struct { + Version string `json:"version"` + } `json:"versions"` + } + var body ResponseBody + + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&body); err != nil { + return nil, c.errQueryFailed(addr, err) + } + + if len(body.Versions) == 0 { + return nil, nil + } + + ret := make([]string, len(body.Versions)) + for i, v := range body.Versions { + ret[i] = v.Version + } + return ret, nil +} + +// 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. +func (c *registryClient) PackageMeta(provider addrs.Provider, version Version, target Platform) (PackageMeta, error) { + endpointPath, err := url.Parse(path.Join( + provider.Namespace, + provider.Type, + version.String(), + "download", + target.OS, + target.Arch, + )) + if err != nil { + // Should never happen because we're constructing this from + // already-validated components. + return PackageMeta{}, err + } + endpointURL := c.baseURL.ResolveReference(endpointPath) + + req, err := http.NewRequest("GET", endpointURL.String(), nil) + if err != nil { + return PackageMeta{}, err + } + c.addHeadersToRequest(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return PackageMeta{}, c.errQueryFailed(provider, err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + // Great! + case http.StatusNotFound: + return PackageMeta{}, ErrPlatformNotSupported{ + Provider: provider, + Version: version, + Platform: target, + } + case http.StatusUnauthorized, http.StatusForbidden: + return PackageMeta{}, c.errUnauthorized(provider.Hostname) + default: + return PackageMeta{}, c.errQueryFailed(provider, errors.New(resp.Status)) + } + + type ResponseBody struct { + Protocols []string `json:"protocols"` + OS string `json:"os"` + Arch string `json:"arch"` + Filename string `json:"filename"` + DownloadURL string `json:"download_url"` + SHA256Sum string `json:"shasum"` + + // TODO: Other metadata for signature checking + } + var body ResponseBody + + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&body); err != nil { + return PackageMeta{}, c.errQueryFailed(provider, err) + } + + var protoVersions VersionList + for _, versionStr := range body.Protocols { + v, err := versions.ParseVersion(versionStr) + if err != nil { + return PackageMeta{}, c.errQueryFailed( + provider, + fmt.Errorf("registry response includes invalid version string %q: %s", versionStr, err), + ) + } + protoVersions = append(protoVersions, v) + } + protoVersions.Sort() + + ret := PackageMeta{ + ProtocolVersions: protoVersions, + TargetPlatform: Platform{ + OS: body.OS, + Arch: body.Arch, + }, + Filename: body.Filename, + DownloadURL: body.DownloadURL, + // SHA256Sum is populated below + } + + if len(body.SHA256Sum) != len(ret.SHA256Sum)*2 { + return PackageMeta{}, c.errQueryFailed( + provider, + fmt.Errorf("registry response includes invalid SHA256 hash %q: %s", body.SHA256Sum, err), + ) + } + _, err = hex.Decode(ret.SHA256Sum[:], []byte(body.SHA256Sum)) + if err != nil { + return PackageMeta{}, c.errQueryFailed( + provider, + fmt.Errorf("registry response includes invalid SHA256 hash %q: %s", body.SHA256Sum, err), + ) + } + + return ret, nil +} + +func (c *registryClient) addHeadersToRequest(req *http.Request) { + if c.creds != nil { + c.creds.PrepareRequest(req) + } + req.Header.Set(terraformVersionHeader, version.String()) +} + +func (c *registryClient) errQueryFailed(provider addrs.Provider, err error) error { + return ErrQueryFailed{ + Provider: provider, + Wrapped: err, + } +} + +func (c *registryClient) errUnauthorized(hostname svchost.Hostname) error { + return ErrUnauthorized{ + Hostname: hostname, + HaveCredentials: c.creds != nil, + } +} diff --git a/internal/getproviders/registry_client_test.go b/internal/getproviders/registry_client_test.go new file mode 100644 index 000000000..65ddfdb00 --- /dev/null +++ b/internal/getproviders/registry_client_test.go @@ -0,0 +1,148 @@ +package getproviders + +import ( + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + + svchost "github.com/hashicorp/terraform-svchost" + disco "github.com/hashicorp/terraform-svchost/disco" +) + +// 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) (*disco.Disco, 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/", + }) + + return services, 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) (*RegistrySource, func()) { + services, close := testServices(t) + source := NewRegistrySource(services) + return source, 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, "/providers/v1/") { + resp.WriteHeader(404) + resp.Write([]byte(`not a provider registry endpoint`)) + return + } + + pathParts := strings.Split(path, "/")[3:] + if len(pathParts) < 3 { + resp.WriteHeader(404) + resp.Write([]byte(`unexpected number of path parts`)) + return + } + log.Printf("[TRACE] fake provider registry request for %#v", pathParts) + + 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":"1.2.0"}, {"version":"1.0.0"}]}`)) + 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 + } + 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(`{"protocols":["5.0"],"os":"` + pathParts[4] + `","arch":"` + pathParts[5] + `","filename":"happycloud_` + pathParts[2] + `.zip","download_url":"/pkg/happycloud_` + pathParts[2] + `.zip","shasum":"000000000000000000000000000000000000000000000000000000000000f00d"}`)) + default: + resp.WriteHeader(404) + resp.Write([]byte(`unknown namespace/provider/version/architecture`)) + } + return + } + + resp.WriteHeader(404) + resp.Write([]byte(`unrecognized path scheme`)) +} diff --git a/internal/getproviders/registry_source.go b/internal/getproviders/registry_source.go new file mode 100644 index 000000000..3e45f6aab --- /dev/null +++ b/internal/getproviders/registry_source.go @@ -0,0 +1,130 @@ +package getproviders + +import ( + "fmt" + + svchost "github.com/hashicorp/terraform-svchost" + disco "github.com/hashicorp/terraform-svchost/disco" + + "github.com/hashicorp/terraform/addrs" +) + +// RegistrySource is a Source that knows how to find and install providers from +// their originating provider registries. +type RegistrySource struct { + services *disco.Disco +} + +// NewRegistrySource creates and returns a new source that will install +// providers from their originating provider registries. +func NewRegistrySource(services *disco.Disco) *RegistrySource { + return &RegistrySource{ + services: services, + } +} + +// AvailableVersions returns all of the versions available for the provider +// with the given address, or an error if that result cannot be determined. +// +// If the request fails, the returned error might be an value of +// ErrHostNoProviders, ErrHostUnreachable, ErrUnauthenticated, +// ErrProviderNotKnown, or ErrQueryFailed. Callers must be defensive and +// expect errors of other types too, to allow for future expansion. +func (s *RegistrySource) AvailableVersions(provider addrs.Provider) (VersionList, error) { + client, err := s.registryClient(provider.Hostname) + if err != nil { + return nil, err + } + + versionStrs, err := client.ProviderVersions(provider) + if err != nil { + return nil, err + } + + if len(versionStrs) == 0 { + return nil, nil + } + + ret := make(VersionList, len(versionStrs)) + for i, str := range versionStrs { + v, err := ParseVersion(str) + if err != nil { + return nil, ErrQueryFailed{ + Provider: provider, + Wrapped: fmt.Errorf("registry response includes invalid version string %q: %s", str, err), + } + } + ret[i] = v + } + ret.Sort() // lowest precedence first, preserving order when equal precedence + return ret, nil +} + +// DownloadLocation returns metadata about the location and capabilities of +// a distribution package for a particular provider at a particular version +// targeting a particular platform. +// +// Callers of DownloadLocation should first call AvailableVersions and pass +// one of the resulting versions to this function. This function cannot +// distinguish between a version that is not available and an unsupported +// target platform, so if it encounters either case it will return an error +// suggesting that the target platform isn't supported under the assumption +// that the caller already checked that the version is available at all. +// +// To find a package suitable for the platform where the provider installation +// process is running, set the "target" argument to +// findproviders.CurrentPlatform. +// +// If the request fails, the returned error might be an value of +// ErrHostNoProviders, ErrHostUnreachable, ErrUnauthenticated, +// ErrPlatformNotSupported, or ErrQueryFailed. Callers must be defensive and +// expect errors of other types too, to allow for future expansion. +func (s *RegistrySource) DownloadLocation(provider addrs.Provider, version Version, target Platform) (PackageMeta, error) { + client, err := s.registryClient(provider.Hostname) + if err != nil { + return PackageMeta{}, err + } + + return client.PackageMeta(provider, version, target) +} + +func (s *RegistrySource) registryClient(hostname svchost.Hostname) (*registryClient, error) { + host, err := s.services.Discover(hostname) + if err != nil { + return nil, ErrHostUnreachable{ + Hostname: hostname, + Wrapped: err, + } + } + + url, err := host.ServiceURL("providers.v1") + switch err := err.(type) { + case nil: + // okay! We'll fall through and return below. + case *disco.ErrServiceNotProvided: + return nil, ErrHostNoProviders{ + Hostname: hostname, + } + case *disco.ErrVersionNotSupported: + return nil, ErrHostNoProviders{ + Hostname: hostname, + HasOtherVersion: true, + } + default: + return nil, ErrHostUnreachable{ + Hostname: hostname, + Wrapped: err, + } + } + + // Check if we have credentials configured for this hostname. + creds, err := s.services.CredentialsForHost(hostname) + if err != nil { + // This indicates that a credentials helper failed, which means we + // can't do anything better than just pass through the helper's + // own error message. + return nil, fmt.Errorf("failed to retrieve credentials for %s: %s", hostname, err) + } + + return newRegistryClient(url, creds), nil +} diff --git a/internal/getproviders/registry_source_test.go b/internal/getproviders/registry_source_test.go new file mode 100644 index 000000000..de40fd0e0 --- /dev/null +++ b/internal/getproviders/registry_source_test.go @@ -0,0 +1,207 @@ +package getproviders + +import ( + "fmt" + "regexp" + "strings" + "testing" + + "github.com/apparentlymart/go-versions/versions" + "github.com/google/go-cmp/cmp" + svchost "github.com/hashicorp/terraform-svchost" + + "github.com/hashicorp/terraform/addrs" +) + +func TestSourceAvailableVersions(t *testing.T) { + source, close := testRegistrySource(t) + defer close() + + tests := []struct { + provider string + wantVersions []string + wantErr string + }{ + // These test cases are relying on behaviors of the fake provider + // registry server implemented in client_test.go. + { + "example.com/awesomesauce/happycloud", + []string{"1.0.0", "1.2.0"}, + ``, + }, + { + "example.com/weaksauce/no-versions", + nil, + ``, // having no versions is not an error, it's just odd + }, + { + "example.com/nonexist/nonexist", + nil, + `provider registry example.com does not have a provider named example.com/nonexist/nonexist`, + }, + { + "not.example.com/foo/bar", + nil, + `host not.example.com does not offer a Terraform provider registry`, + }, + { + "too-new.example.com/foo/bar", + nil, + `host too-new.example.com does not support the provider registry protocol required by this Terraform version, but may be compatible with a different Terraform version`, + }, + { + "fails.example.com/foo/bar", + nil, + `could not query provider registry for fails.example.com/foo/bar: Get http://placeholder-origin/fails-immediately/foo/bar/versions: EOF`, + }, + } + + // Sometimes error messages contain specific HTTP endpoint URLs, but + // since our test server is on a random port we'd not be able to + // consistently match those. Instead, we'll normalize the URLs. + urlPattern := regexp.MustCompile(`http://[^/]+/`) + + for _, test := range tests { + t.Run(test.provider, func(t *testing.T) { + // TEMP: We don't yet have a function for parsing provider + // source addresses so we'll just fake it in here for now. + parts := strings.Split(test.provider, "/") + providerAddr := addrs.Provider{ + Hostname: svchost.Hostname(parts[0]), + Namespace: parts[1], + Type: parts[2], + } + + gotVersions, err := source.AvailableVersions(providerAddr) + + if err != nil { + if test.wantErr == "" { + t.Fatalf("wrong error\ngot: %s\nwant: ", err.Error()) + } + gotErr := urlPattern.ReplaceAllLiteralString(err.Error(), "http://placeholder-origin/") + if got, want := gotErr, 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) + } + + var gotVersionsStr []string + if gotVersions != nil { + gotVersionsStr = make([]string, len(gotVersions)) + for i, v := range gotVersions { + gotVersionsStr[i] = v.String() + } + } + + if diff := cmp.Diff(test.wantVersions, gotVersionsStr); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) + } + +} + +func TestSourceDownloadLocation(t *testing.T) { + source, close := testRegistrySource(t) + defer close() + + tests := []struct { + provider string + version string + os, arch string + want PackageMeta + wantErr string + }{ + // These test cases are relying on behaviors of the fake provider + // registry server implemented in client_test.go. + { + "example.com/awesomesauce/happycloud", + "1.2.0", + "linux", "amd64", + PackageMeta{ + ProtocolVersions: VersionList{versions.MustParseVersion("5.0.0")}, + TargetPlatform: Platform{"linux", "amd64"}, + Filename: "happycloud_1.2.0.zip", + DownloadURL: "/pkg/happycloud_1.2.0.zip", + SHA256Sum: [32]uint8{30: 0xf0, 31: 0x0d}, // fake registry uses a memorable sum + }, + ``, + }, + { + "example.com/awesomesauce/happycloud", + "1.2.0", + "nonexist", "amd64", + PackageMeta{}, + `provider example.com/awesomesauce/happycloud 1.2.0 is not available for nonexist_amd64`, + }, + { + "not.example.com/awesomesauce/happycloud", + "1.2.0", + "linux", "amd64", + PackageMeta{}, + `host not.example.com does not offer a Terraform provider registry`, + }, + { + "too-new.example.com/awesomesauce/happycloud", + "1.2.0", + "linux", "amd64", + PackageMeta{}, + `host too-new.example.com does not support the provider registry protocol required by this Terraform version, but may be compatible with a different Terraform version`, + }, + { + "fails.example.com/awesomesauce/happycloud", + "1.2.0", + "linux", "amd64", + PackageMeta{}, + `could not query provider registry for fails.example.com/awesomesauce/happycloud: Get http://placeholder-origin/fails-immediately/awesomesauce/happycloud/1.2.0/download/linux/amd64: EOF`, + }, + } + + // Sometimes error messages contain specific HTTP endpoint URLs, but + // since our test server is on a random port we'd not be able to + // consistently match those. Instead, we'll normalize the URLs. + urlPattern := regexp.MustCompile(`http://[^/]+/`) + + cmpOpts := cmp.Comparer(Version.Same) + + for _, test := range tests { + t.Run(fmt.Sprintf("%s for %s_%s", test.provider, test.os, test.arch), func(t *testing.T) { + // TEMP: We don't yet have a function for parsing provider + // source addresses so we'll just fake it in here for now. + parts := strings.Split(test.provider, "/") + providerAddr := addrs.Provider{ + Hostname: svchost.Hostname(parts[0]), + Namespace: parts[1], + Type: parts[2], + } + + version := versions.MustParseVersion(test.version) + + got, err := source.DownloadLocation(providerAddr, version, Platform{test.os, test.arch}) + + if err != nil { + if test.wantErr == "" { + t.Fatalf("wrong error\ngot: %s\nwant: ", err.Error()) + } + gotErr := urlPattern.ReplaceAllLiteralString(err.Error(), "http://placeholder-origin/") + if got, want := gotErr, 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.want, got, cmpOpts); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }) + } + +} diff --git a/internal/getproviders/types.go b/internal/getproviders/types.go new file mode 100644 index 000000000..2162315b0 --- /dev/null +++ b/internal/getproviders/types.go @@ -0,0 +1,59 @@ +package getproviders + +import ( + "crypto/sha256" + "runtime" + + "github.com/apparentlymart/go-versions/versions" +) + +// Version represents a particular single version of a provider. +type Version = versions.Version + +// VersionList represents a list of versions. It is a []Version with some +// extra methods for convenient filtering. +type VersionList = versions.List + +// ParseVersion parses a "semver"-style version string into a Version value, +// which is the version syntax we use for provider versions. +func ParseVersion(str string) (Version, error) { + return versions.ParseVersion(str) +} + +// Platform represents a target platform that a provider is or might be +// available for. +type Platform struct { + OS, Arch string +} + +func (p Platform) String() string { + return p.OS + "_" + p.Arch +} + +// CurrentPlatform is the platform where the current program is running. +// +// If attempting to install providers for use on the same system where the +// installation process is running, this is the right platform to use. +var CurrentPlatform = Platform{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, +} + +// PackageMeta represents the metadata related to a particular downloadable +// provider package targeting a single platform. +// +// Package findproviders does no signature verification or protocol version +// compatibility checking of its own. A caller receving a PackageMeta must +// verify that it has a correct signature and supports a protocol version +// accepted by the current version of Terraform before trying to use the +// described package. +type PackageMeta struct { + ProtocolVersions VersionList + TargetPlatform Platform + + Filename string + DownloadURL string + SHA256Sum [sha256.Size]byte + + // TODO: Extra metadata for signature verification +}