github.com/sl1pm4t/terraform@v0.6.4-0.20170725213156-870617d22df3/plugin/discovery/get.go (about)

     1  package discovery
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"io/ioutil"
     7  	"log"
     8  	"net/http"
     9  	"os"
    10  	"runtime"
    11  	"strconv"
    12  	"strings"
    13  
    14  	"golang.org/x/net/html"
    15  
    16  	cleanhttp "github.com/hashicorp/go-cleanhttp"
    17  	getter "github.com/hashicorp/go-getter"
    18  	multierror "github.com/hashicorp/go-multierror"
    19  )
    20  
    21  // Releases are located by parsing the html listing from releases.hashicorp.com.
    22  //
    23  // The URL for releases follows the pattern:
    24  //    https://releases.hashicorp.com/terraform-provider-name/<x.y.z>/terraform-provider-name_<x.y.z>_<os>_<arch>.<ext>
    25  //
    26  // The plugin protocol version will be saved with the release and returned in
    27  // the header X-TERRAFORM_PROTOCOL_VERSION.
    28  
    29  const protocolVersionHeader = "x-terraform-protocol-version"
    30  
    31  var releaseHost = "https://releases.hashicorp.com"
    32  
    33  var httpClient = cleanhttp.DefaultClient()
    34  
    35  // An Installer maintains a local cache of plugins by downloading plugins
    36  // from an online repository.
    37  type Installer interface {
    38  	Get(name string, req Constraints) (PluginMeta, error)
    39  	PurgeUnused(used map[string]PluginMeta) (removed PluginMetaSet, err error)
    40  }
    41  
    42  // ProviderInstaller is an Installer implementation that knows how to
    43  // download Terraform providers from the official HashiCorp releases service
    44  // into a local directory. The files downloaded are compliant with the
    45  // naming scheme expected by FindPlugins, so the target directory of a
    46  // provider installer can be used as one of several plugin discovery sources.
    47  type ProviderInstaller struct {
    48  	Dir string
    49  
    50  	PluginProtocolVersion uint
    51  
    52  	// OS and Arch specify the OS and architecture that should be used when
    53  	// installing plugins. These use the same labels as the runtime.GOOS and
    54  	// runtime.GOARCH variables respectively, and indeed the values of these
    55  	// are used as defaults if either of these is the empty string.
    56  	OS   string
    57  	Arch string
    58  
    59  	// Skip checksum and signature verification
    60  	SkipVerify bool
    61  }
    62  
    63  // Get is part of an implementation of type Installer, and attempts to download
    64  // and install a Terraform provider matching the given constraints.
    65  //
    66  // This method may return one of a number of sentinel errors from this
    67  // package to indicate issues that are likely to be resolvable via user action:
    68  //
    69  //     ErrorNoSuchProvider: no provider with the given name exists in the repository.
    70  //     ErrorNoSuitableVersion: the provider exists but no available version matches constraints.
    71  //     ErrorNoVersionCompatible: a plugin was found within the constraints but it is
    72  //                               incompatible with the current Terraform version.
    73  //
    74  // These errors should be recognized and handled as special cases by the caller
    75  // to present a suitable user-oriented error message.
    76  //
    77  // All other errors indicate an internal problem that is likely _not_ solvable
    78  // through user action, or at least not within Terraform's scope. Error messages
    79  // are produced under the assumption that if presented to the user they will
    80  // be presented alongside context about what is being installed, and thus the
    81  // error messages do not redundantly include such information.
    82  func (i *ProviderInstaller) Get(provider string, req Constraints) (PluginMeta, error) {
    83  	versions, err := i.listProviderVersions(provider)
    84  	// TODO: return multiple errors
    85  	if err != nil {
    86  		return PluginMeta{}, err
    87  	}
    88  
    89  	if len(versions) == 0 {
    90  		return PluginMeta{}, ErrorNoSuitableVersion
    91  	}
    92  
    93  	versions = allowedVersions(versions, req)
    94  	if len(versions) == 0 {
    95  		return PluginMeta{}, ErrorNoSuitableVersion
    96  	}
    97  
    98  	// sort them newest to oldest
    99  	Versions(versions).Sort()
   100  
   101  	// take the first matching plugin we find
   102  	for _, v := range versions {
   103  		url := i.providerURL(provider, v.String())
   104  
   105  		if !i.SkipVerify {
   106  			sha256, err := i.getProviderChecksum(provider, v.String())
   107  			if err != nil {
   108  				return PluginMeta{}, err
   109  			}
   110  
   111  			// add the checksum parameter for go-getter to verify the download for us.
   112  			if sha256 != "" {
   113  				url = url + "?checksum=sha256:" + sha256
   114  			}
   115  		}
   116  
   117  		log.Printf("[DEBUG] fetching provider info for %s version %s", provider, v)
   118  		if checkPlugin(url, i.PluginProtocolVersion) {
   119  			log.Printf("[DEBUG] getting provider %q version %q at %s", provider, v, url)
   120  			err := getter.Get(i.Dir, url)
   121  			if err != nil {
   122  				return PluginMeta{}, err
   123  			}
   124  
   125  			// Find what we just installed
   126  			// (This is weird, because go-getter doesn't directly return
   127  			//  information about what was extracted, and we just extracted
   128  			//  the archive directly into a shared dir here.)
   129  			log.Printf("[DEBUG] looking for the %s %s plugin we just installed", provider, v)
   130  			metas := FindPlugins("provider", []string{i.Dir})
   131  			log.Printf("[DEBUG] all plugins found %#v", metas)
   132  			metas, _ = metas.ValidateVersions()
   133  			metas = metas.WithName(provider).WithVersion(v)
   134  			log.Printf("[DEBUG] filtered plugins %#v", metas)
   135  			if metas.Count() == 0 {
   136  				// This should never happen. Suggests that the release archive
   137  				// contains an executable file whose name doesn't match the
   138  				// expected convention.
   139  				return PluginMeta{}, fmt.Errorf(
   140  					"failed to find installed plugin version %s; this is a bug in Terraform and should be reported",
   141  					v,
   142  				)
   143  			}
   144  
   145  			if metas.Count() > 1 {
   146  				// This should also never happen, and suggests that a
   147  				// particular version was re-released with a different
   148  				// executable filename. We consider releases as immutable, so
   149  				// this is an error.
   150  				return PluginMeta{}, fmt.Errorf(
   151  					"multiple plugins installed for version %s; this is a bug in Terraform and should be reported",
   152  					v,
   153  				)
   154  			}
   155  
   156  			// By now we know we have exactly one meta, and so "Newest" will
   157  			// return that one.
   158  			return metas.Newest(), nil
   159  		}
   160  
   161  		log.Printf("[INFO] incompatible ProtocolVersion for %s version %s", provider, v)
   162  	}
   163  
   164  	return PluginMeta{}, ErrorNoVersionCompatible
   165  }
   166  
   167  func (i *ProviderInstaller) PurgeUnused(used map[string]PluginMeta) (PluginMetaSet, error) {
   168  	purge := make(PluginMetaSet)
   169  
   170  	present := FindPlugins("provider", []string{i.Dir})
   171  	for meta := range present {
   172  		chosen, ok := used[meta.Name]
   173  		if !ok {
   174  			purge.Add(meta)
   175  		}
   176  		if chosen.Path != meta.Path {
   177  			purge.Add(meta)
   178  		}
   179  	}
   180  
   181  	removed := make(PluginMetaSet)
   182  	var errs error
   183  	for meta := range purge {
   184  		path := meta.Path
   185  		err := os.Remove(path)
   186  		if err != nil {
   187  			errs = multierror.Append(errs, fmt.Errorf(
   188  				"failed to remove unused provider plugin %s: %s",
   189  				path, err,
   190  			))
   191  		} else {
   192  			removed.Add(meta)
   193  		}
   194  	}
   195  
   196  	return removed, errs
   197  }
   198  
   199  // Plugins are referred to by the short name, but all URLs and files will use
   200  // the full name prefixed with terraform-<plugin_type>-
   201  func (i *ProviderInstaller) providerName(name string) string {
   202  	return "terraform-provider-" + name
   203  }
   204  
   205  func (i *ProviderInstaller) providerFileName(name, version string) string {
   206  	os := i.OS
   207  	arch := i.Arch
   208  	if os == "" {
   209  		os = runtime.GOOS
   210  	}
   211  	if arch == "" {
   212  		arch = runtime.GOARCH
   213  	}
   214  	return fmt.Sprintf("%s_%s_%s_%s.zip", i.providerName(name), version, os, arch)
   215  }
   216  
   217  // providerVersionsURL returns the path to the released versions directory for the provider:
   218  // https://releases.hashicorp.com/terraform-provider-name/
   219  func (i *ProviderInstaller) providerVersionsURL(name string) string {
   220  	return releaseHost + "/" + i.providerName(name) + "/"
   221  }
   222  
   223  // providerURL returns the full path to the provider file, using the current OS
   224  // and ARCH:
   225  // .../terraform-provider-name_<x.y.z>/terraform-provider-name_<x.y.z>_<os>_<arch>.<ext>
   226  func (i *ProviderInstaller) providerURL(name, version string) string {
   227  	return fmt.Sprintf("%s%s/%s", i.providerVersionsURL(name), version, i.providerFileName(name, version))
   228  }
   229  
   230  func (i *ProviderInstaller) providerChecksumURL(name, version string) string {
   231  	fileName := fmt.Sprintf("%s_%s_SHA256SUMS", i.providerName(name), version)
   232  	u := fmt.Sprintf("%s%s/%s", i.providerVersionsURL(name), version, fileName)
   233  	return u
   234  }
   235  
   236  func (i *ProviderInstaller) getProviderChecksum(name, version string) (string, error) {
   237  	checksums, err := getPluginSHA256SUMs(i.providerChecksumURL(name, version))
   238  	if err != nil {
   239  		return "", err
   240  	}
   241  
   242  	return checksumForFile(checksums, i.providerFileName(name, version)), nil
   243  }
   244  
   245  // Return the plugin version by making a HEAD request to the provided url.
   246  // If the header is not present, we assume the latest version will be
   247  // compatible, and leave the check for discovery or execution.
   248  func checkPlugin(url string, pluginProtocolVersion uint) bool {
   249  	resp, err := httpClient.Head(url)
   250  	if err != nil {
   251  		log.Printf("[ERROR] error fetching plugin headers: %s", err)
   252  		return false
   253  	}
   254  
   255  	if resp.StatusCode != http.StatusOK {
   256  		log.Println("[ERROR] non-200 status fetching plugin headers:", resp.Status)
   257  		return false
   258  	}
   259  
   260  	proto := resp.Header.Get(protocolVersionHeader)
   261  	if proto == "" {
   262  		// The header isn't present, but we don't make this error fatal since
   263  		// the latest version will probably work.
   264  		log.Printf("[WARNING] missing %s from: %s", protocolVersionHeader, url)
   265  		return true
   266  	}
   267  
   268  	protoVersion, err := strconv.Atoi(proto)
   269  	if err != nil {
   270  		log.Printf("[ERROR] invalid ProtocolVersion: %s", proto)
   271  		return false
   272  	}
   273  
   274  	return protoVersion == int(pluginProtocolVersion)
   275  }
   276  
   277  // list the version available for the named plugin
   278  func (i *ProviderInstaller) listProviderVersions(name string) ([]Version, error) {
   279  	versions, err := listPluginVersions(i.providerVersionsURL(name))
   280  	if err != nil {
   281  		// listPluginVersions returns a verbose error message indicating
   282  		// what was being accessed and what failed
   283  		return nil, err
   284  	}
   285  	return versions, nil
   286  }
   287  
   288  var errVersionNotFound = errors.New("version not found")
   289  
   290  // take the list of available versions for a plugin, and filter out those that
   291  // don't fit the constraints.
   292  func allowedVersions(available []Version, required Constraints) []Version {
   293  	var allowed []Version
   294  
   295  	for _, v := range available {
   296  		if required.Allows(v) {
   297  			allowed = append(allowed, v)
   298  		}
   299  	}
   300  
   301  	return allowed
   302  }
   303  
   304  // return a list of the plugin versions at the given URL
   305  func listPluginVersions(url string) ([]Version, error) {
   306  	resp, err := httpClient.Get(url)
   307  	if err != nil {
   308  		// http library produces a verbose error message that includes the
   309  		// URL being accessed, etc.
   310  		return nil, err
   311  	}
   312  	defer resp.Body.Close()
   313  
   314  	if resp.StatusCode != http.StatusOK {
   315  		body, _ := ioutil.ReadAll(resp.Body)
   316  		log.Printf("[ERROR] failed to fetch plugin versions from %s\n%s\n%s", url, resp.Status, body)
   317  
   318  		switch resp.StatusCode {
   319  		case http.StatusNotFound, http.StatusForbidden:
   320  			// These are treated as indicative of the given name not being
   321  			// a valid provider name at all.
   322  			return nil, ErrorNoSuchProvider
   323  
   324  		default:
   325  			// All other errors are assumed to be operational problems.
   326  			return nil, fmt.Errorf("error accessing %s: %s", url, resp.Status)
   327  		}
   328  
   329  	}
   330  
   331  	body, err := html.Parse(resp.Body)
   332  	if err != nil {
   333  		log.Fatal(err)
   334  	}
   335  
   336  	names := []string{}
   337  
   338  	// all we need to do is list links on the directory listing page that look like plugins
   339  	var f func(*html.Node)
   340  	f = func(n *html.Node) {
   341  		if n.Type == html.ElementNode && n.Data == "a" {
   342  			c := n.FirstChild
   343  			if c != nil && c.Type == html.TextNode && strings.HasPrefix(c.Data, "terraform-") {
   344  				names = append(names, c.Data)
   345  				return
   346  			}
   347  		}
   348  		for c := n.FirstChild; c != nil; c = c.NextSibling {
   349  			f(c)
   350  		}
   351  	}
   352  	f(body)
   353  
   354  	return versionsFromNames(names), nil
   355  }
   356  
   357  // parse the list of directory names into a sorted list of available versions
   358  func versionsFromNames(names []string) []Version {
   359  	var versions []Version
   360  	for _, name := range names {
   361  		parts := strings.SplitN(name, "_", 2)
   362  		if len(parts) == 2 && parts[1] != "" {
   363  			v, err := VersionStr(parts[1]).Parse()
   364  			if err != nil {
   365  				// filter invalid versions scraped from the page
   366  				log.Printf("[WARN] invalid version found for %q: %s", name, err)
   367  				continue
   368  			}
   369  
   370  			versions = append(versions, v)
   371  		}
   372  	}
   373  
   374  	return versions
   375  }
   376  
   377  func checksumForFile(sums []byte, name string) string {
   378  	for _, line := range strings.Split(string(sums), "\n") {
   379  		parts := strings.Fields(line)
   380  		if len(parts) > 1 && parts[1] == name {
   381  			return parts[0]
   382  		}
   383  	}
   384  	return ""
   385  }
   386  
   387  // fetch the SHA256SUMS file provided, and verify its signature.
   388  func getPluginSHA256SUMs(sumsURL string) ([]byte, error) {
   389  	sigURL := sumsURL + ".sig"
   390  
   391  	sums, err := getFile(sumsURL)
   392  	if err != nil {
   393  		return nil, fmt.Errorf("error fetching checksums: %s", err)
   394  	}
   395  
   396  	sig, err := getFile(sigURL)
   397  	if err != nil {
   398  		return nil, fmt.Errorf("error fetching checksums signature: %s", err)
   399  	}
   400  
   401  	if err := verifySig(sums, sig); err != nil {
   402  		return nil, err
   403  	}
   404  
   405  	return sums, nil
   406  }
   407  
   408  func getFile(url string) ([]byte, error) {
   409  	resp, err := httpClient.Get(url)
   410  	if err != nil {
   411  		return nil, err
   412  	}
   413  	defer resp.Body.Close()
   414  
   415  	if resp.StatusCode != http.StatusOK {
   416  		return nil, fmt.Errorf("%s", resp.Status)
   417  	}
   418  
   419  	data, err := ioutil.ReadAll(resp.Body)
   420  	if err != nil {
   421  		return data, err
   422  	}
   423  	return data, nil
   424  }