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 }