github.com/hooklift/terraform@v0.11.0-beta1.0.20171117000744-6786c1361ffe/plugin/discovery/get.go (about) 1 package discovery 2 3 import ( 4 "errors" 5 "fmt" 6 "io" 7 "io/ioutil" 8 "log" 9 "net/http" 10 "os" 11 "path/filepath" 12 "runtime" 13 "strconv" 14 "strings" 15 16 "golang.org/x/net/html" 17 18 cleanhttp "github.com/hashicorp/go-cleanhttp" 19 getter "github.com/hashicorp/go-getter" 20 multierror "github.com/hashicorp/go-multierror" 21 "github.com/mitchellh/cli" 22 ) 23 24 // Releases are located by parsing the html listing from releases.hashicorp.com. 25 // 26 // The URL for releases follows the pattern: 27 // https://releases.hashicorp.com/terraform-provider-name/<x.y.z>/terraform-provider-name_<x.y.z>_<os>_<arch>.<ext> 28 // 29 // The plugin protocol version will be saved with the release and returned in 30 // the header X-TERRAFORM_PROTOCOL_VERSION. 31 32 const protocolVersionHeader = "x-terraform-protocol-version" 33 34 var releaseHost = "https://releases.hashicorp.com" 35 36 var httpClient = cleanhttp.DefaultPooledClient() 37 38 // An Installer maintains a local cache of plugins by downloading plugins 39 // from an online repository. 40 type Installer interface { 41 Get(name string, req Constraints) (PluginMeta, error) 42 PurgeUnused(used map[string]PluginMeta) (removed PluginMetaSet, err error) 43 } 44 45 // ProviderInstaller is an Installer implementation that knows how to 46 // download Terraform providers from the official HashiCorp releases service 47 // into a local directory. The files downloaded are compliant with the 48 // naming scheme expected by FindPlugins, so the target directory of a 49 // provider installer can be used as one of several plugin discovery sources. 50 type ProviderInstaller struct { 51 Dir string 52 53 // Cache is used to access and update a local cache of plugins if non-nil. 54 // Can be nil to disable caching. 55 Cache PluginCache 56 57 PluginProtocolVersion uint 58 59 // OS and Arch specify the OS and architecture that should be used when 60 // installing plugins. These use the same labels as the runtime.GOOS and 61 // runtime.GOARCH variables respectively, and indeed the values of these 62 // are used as defaults if either of these is the empty string. 63 OS string 64 Arch string 65 66 // Skip checksum and signature verification 67 SkipVerify bool 68 69 Ui cli.Ui // Ui for output 70 } 71 72 // Get is part of an implementation of type Installer, and attempts to download 73 // and install a Terraform provider matching the given constraints. 74 // 75 // This method may return one of a number of sentinel errors from this 76 // package to indicate issues that are likely to be resolvable via user action: 77 // 78 // ErrorNoSuchProvider: no provider with the given name exists in the repository. 79 // ErrorNoSuitableVersion: the provider exists but no available version matches constraints. 80 // ErrorNoVersionCompatible: a plugin was found within the constraints but it is 81 // incompatible with the current Terraform version. 82 // 83 // These errors should be recognized and handled as special cases by the caller 84 // to present a suitable user-oriented error message. 85 // 86 // All other errors indicate an internal problem that is likely _not_ solvable 87 // through user action, or at least not within Terraform's scope. Error messages 88 // are produced under the assumption that if presented to the user they will 89 // be presented alongside context about what is being installed, and thus the 90 // error messages do not redundantly include such information. 91 func (i *ProviderInstaller) Get(provider string, req Constraints) (PluginMeta, error) { 92 versions, err := i.listProviderVersions(provider) 93 // TODO: return multiple errors 94 if err != nil { 95 return PluginMeta{}, err 96 } 97 98 if len(versions) == 0 { 99 return PluginMeta{}, ErrorNoSuitableVersion 100 } 101 102 versions = allowedVersions(versions, req) 103 if len(versions) == 0 { 104 return PluginMeta{}, ErrorNoSuitableVersion 105 } 106 107 // sort them newest to oldest 108 Versions(versions).Sort() 109 110 // Ensure that our installation directory exists 111 err = os.MkdirAll(i.Dir, os.ModePerm) 112 if err != nil { 113 return PluginMeta{}, fmt.Errorf("failed to create plugin dir %s: %s", i.Dir, err) 114 } 115 116 // take the first matching plugin we find 117 for _, v := range versions { 118 url := i.providerURL(provider, v.String()) 119 120 if !i.SkipVerify { 121 sha256, err := i.getProviderChecksum(provider, v.String()) 122 if err != nil { 123 return PluginMeta{}, err 124 } 125 126 // add the checksum parameter for go-getter to verify the download for us. 127 if sha256 != "" { 128 url = url + "?checksum=sha256:" + sha256 129 } 130 } 131 132 log.Printf("[DEBUG] fetching provider info for %s version %s", provider, v) 133 if checkPlugin(url, i.PluginProtocolVersion) { 134 i.Ui.Info(fmt.Sprintf("- Downloading plugin for provider %q (%s)...", provider, v.String())) 135 log.Printf("[DEBUG] getting provider %q version %q", provider, v) 136 err := i.install(provider, v, url) 137 if err != nil { 138 return PluginMeta{}, err 139 } 140 141 // Find what we just installed 142 // (This is weird, because go-getter doesn't directly return 143 // information about what was extracted, and we just extracted 144 // the archive directly into a shared dir here.) 145 log.Printf("[DEBUG] looking for the %s %s plugin we just installed", provider, v) 146 metas := FindPlugins("provider", []string{i.Dir}) 147 log.Printf("[DEBUG] all plugins found %#v", metas) 148 metas, _ = metas.ValidateVersions() 149 metas = metas.WithName(provider).WithVersion(v) 150 log.Printf("[DEBUG] filtered plugins %#v", metas) 151 if metas.Count() == 0 { 152 // This should never happen. Suggests that the release archive 153 // contains an executable file whose name doesn't match the 154 // expected convention. 155 return PluginMeta{}, fmt.Errorf( 156 "failed to find installed plugin version %s; this is a bug in Terraform and should be reported", 157 v, 158 ) 159 } 160 161 if metas.Count() > 1 { 162 // This should also never happen, and suggests that a 163 // particular version was re-released with a different 164 // executable filename. We consider releases as immutable, so 165 // this is an error. 166 return PluginMeta{}, fmt.Errorf( 167 "multiple plugins installed for version %s; this is a bug in Terraform and should be reported", 168 v, 169 ) 170 } 171 172 // By now we know we have exactly one meta, and so "Newest" will 173 // return that one. 174 return metas.Newest(), nil 175 } 176 177 log.Printf("[INFO] incompatible ProtocolVersion for %s version %s", provider, v) 178 } 179 180 return PluginMeta{}, ErrorNoVersionCompatible 181 } 182 183 func (i *ProviderInstaller) install(provider string, version Version, url string) error { 184 if i.Cache != nil { 185 log.Printf("[DEBUG] looking for provider %s %s in plugin cache", provider, version) 186 cached := i.Cache.CachedPluginPath("provider", provider, version) 187 if cached == "" { 188 log.Printf("[DEBUG] %s %s not yet in cache, so downloading %s", provider, version, url) 189 err := getter.Get(i.Cache.InstallDir(), url) 190 if err != nil { 191 return err 192 } 193 // should now be in cache 194 cached = i.Cache.CachedPluginPath("provider", provider, version) 195 if cached == "" { 196 // should never happen if the getter is behaving properly 197 // and the plugins are packaged properly. 198 return fmt.Errorf("failed to find downloaded plugin in cache %s", i.Cache.InstallDir()) 199 } 200 } 201 202 // Link or copy the cached binary into our install dir so the 203 // normal resolution machinery can find it. 204 filename := filepath.Base(cached) 205 targetPath := filepath.Join(i.Dir, filename) 206 207 log.Printf("[DEBUG] installing %s %s to %s from local cache %s", provider, version, targetPath, cached) 208 209 // Delete if we can. If there's nothing there already then no harm done. 210 // This is important because we can't create a link if there's 211 // already a file of the same name present. 212 // (any other error here we'll catch below when we try to write here) 213 os.Remove(targetPath) 214 215 // We don't attempt linking on Windows because links are not 216 // comprehensively supported by all tools/apps in Windows and 217 // so we choose to be conservative to avoid creating any 218 // weird issues for Windows users. 219 linkErr := errors.New("link not supported for Windows") // placeholder error, never actually returned 220 if runtime.GOOS != "windows" { 221 // Try hard linking first. Hard links are preferable because this 222 // creates a self-contained directory that doesn't depend on the 223 // cache after install. 224 linkErr = os.Link(cached, targetPath) 225 226 // If that failed, try a symlink. This _does_ depend on the cache 227 // after install, so the user must manage the cache more carefully 228 // in this case, but avoids creating redundant copies of the 229 // plugins on disk. 230 if linkErr != nil { 231 linkErr = os.Symlink(cached, targetPath) 232 } 233 } 234 235 // If we still have an error then we'll try a copy as a fallback. 236 // In this case either the OS is Windows or the target filesystem 237 // can't support symlinks. 238 if linkErr != nil { 239 srcFile, err := os.Open(cached) 240 if err != nil { 241 return fmt.Errorf("failed to open cached plugin %s: %s", cached, err) 242 } 243 defer srcFile.Close() 244 245 destFile, err := os.OpenFile(targetPath, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, os.ModePerm) 246 if err != nil { 247 return fmt.Errorf("failed to create %s: %s", targetPath, err) 248 } 249 250 _, err = io.Copy(destFile, srcFile) 251 if err != nil { 252 destFile.Close() 253 return fmt.Errorf("failed to copy cached plugin from %s to %s: %s", cached, targetPath, err) 254 } 255 256 err = destFile.Close() 257 if err != nil { 258 return fmt.Errorf("error creating %s: %s", targetPath, err) 259 } 260 } 261 262 // One way or another, by the time we get here we should have either 263 // a link or a copy of the cached plugin within i.Dir, as expected. 264 } else { 265 log.Printf("[DEBUG] plugin cache is disabled, so downloading %s %s from %s", provider, version, url) 266 err := getter.Get(i.Dir, url) 267 if err != nil { 268 return err 269 } 270 } 271 272 return nil 273 } 274 275 func (i *ProviderInstaller) PurgeUnused(used map[string]PluginMeta) (PluginMetaSet, error) { 276 purge := make(PluginMetaSet) 277 278 present := FindPlugins("provider", []string{i.Dir}) 279 for meta := range present { 280 chosen, ok := used[meta.Name] 281 if !ok { 282 purge.Add(meta) 283 } 284 if chosen.Path != meta.Path { 285 purge.Add(meta) 286 } 287 } 288 289 removed := make(PluginMetaSet) 290 var errs error 291 for meta := range purge { 292 path := meta.Path 293 err := os.Remove(path) 294 if err != nil { 295 errs = multierror.Append(errs, fmt.Errorf( 296 "failed to remove unused provider plugin %s: %s", 297 path, err, 298 )) 299 } else { 300 removed.Add(meta) 301 } 302 } 303 304 return removed, errs 305 } 306 307 // Plugins are referred to by the short name, but all URLs and files will use 308 // the full name prefixed with terraform-<plugin_type>- 309 func (i *ProviderInstaller) providerName(name string) string { 310 return "terraform-provider-" + name 311 } 312 313 func (i *ProviderInstaller) providerFileName(name, version string) string { 314 os := i.OS 315 arch := i.Arch 316 if os == "" { 317 os = runtime.GOOS 318 } 319 if arch == "" { 320 arch = runtime.GOARCH 321 } 322 return fmt.Sprintf("%s_%s_%s_%s.zip", i.providerName(name), version, os, arch) 323 } 324 325 // providerVersionsURL returns the path to the released versions directory for the provider: 326 // https://releases.hashicorp.com/terraform-provider-name/ 327 func (i *ProviderInstaller) providerVersionsURL(name string) string { 328 return releaseHost + "/" + i.providerName(name) + "/" 329 } 330 331 // providerURL returns the full path to the provider file, using the current OS 332 // and ARCH: 333 // .../terraform-provider-name_<x.y.z>/terraform-provider-name_<x.y.z>_<os>_<arch>.<ext> 334 func (i *ProviderInstaller) providerURL(name, version string) string { 335 return fmt.Sprintf("%s%s/%s", i.providerVersionsURL(name), version, i.providerFileName(name, version)) 336 } 337 338 func (i *ProviderInstaller) providerChecksumURL(name, version string) string { 339 fileName := fmt.Sprintf("%s_%s_SHA256SUMS", i.providerName(name), version) 340 u := fmt.Sprintf("%s%s/%s", i.providerVersionsURL(name), version, fileName) 341 return u 342 } 343 344 func (i *ProviderInstaller) getProviderChecksum(name, version string) (string, error) { 345 checksums, err := getPluginSHA256SUMs(i.providerChecksumURL(name, version)) 346 if err != nil { 347 return "", err 348 } 349 350 return checksumForFile(checksums, i.providerFileName(name, version)), nil 351 } 352 353 // Return the plugin version by making a HEAD request to the provided url. 354 // If the header is not present, we assume the latest version will be 355 // compatible, and leave the check for discovery or execution. 356 func checkPlugin(url string, pluginProtocolVersion uint) bool { 357 resp, err := httpClient.Head(url) 358 if err != nil { 359 log.Printf("[ERROR] error fetching plugin headers: %s", err) 360 return false 361 } 362 363 if resp.StatusCode != http.StatusOK { 364 log.Println("[ERROR] non-200 status fetching plugin headers:", resp.Status) 365 return false 366 } 367 368 proto := resp.Header.Get(protocolVersionHeader) 369 if proto == "" { 370 // The header isn't present, but we don't make this error fatal since 371 // the latest version will probably work. 372 log.Printf("[WARNING] missing %s from: %s", protocolVersionHeader, url) 373 return true 374 } 375 376 protoVersion, err := strconv.Atoi(proto) 377 if err != nil { 378 log.Printf("[ERROR] invalid ProtocolVersion: %s", proto) 379 return false 380 } 381 382 return protoVersion == int(pluginProtocolVersion) 383 } 384 385 // list the version available for the named plugin 386 func (i *ProviderInstaller) listProviderVersions(name string) ([]Version, error) { 387 versions, err := listPluginVersions(i.providerVersionsURL(name)) 388 if err != nil { 389 // listPluginVersions returns a verbose error message indicating 390 // what was being accessed and what failed 391 return nil, err 392 } 393 return versions, nil 394 } 395 396 var errVersionNotFound = errors.New("version not found") 397 398 // take the list of available versions for a plugin, and filter out those that 399 // don't fit the constraints. 400 func allowedVersions(available []Version, required Constraints) []Version { 401 var allowed []Version 402 403 for _, v := range available { 404 if required.Allows(v) { 405 allowed = append(allowed, v) 406 } 407 } 408 409 return allowed 410 } 411 412 // return a list of the plugin versions at the given URL 413 func listPluginVersions(url string) ([]Version, error) { 414 resp, err := httpClient.Get(url) 415 if err != nil { 416 // http library produces a verbose error message that includes the 417 // URL being accessed, etc. 418 return nil, err 419 } 420 defer resp.Body.Close() 421 422 if resp.StatusCode != http.StatusOK { 423 body, _ := ioutil.ReadAll(resp.Body) 424 log.Printf("[ERROR] failed to fetch plugin versions from %s\n%s\n%s", url, resp.Status, body) 425 426 switch resp.StatusCode { 427 case http.StatusNotFound, http.StatusForbidden: 428 // These are treated as indicative of the given name not being 429 // a valid provider name at all. 430 return nil, ErrorNoSuchProvider 431 432 default: 433 // All other errors are assumed to be operational problems. 434 return nil, fmt.Errorf("error accessing %s: %s", url, resp.Status) 435 } 436 437 } 438 439 body, err := html.Parse(resp.Body) 440 if err != nil { 441 log.Fatal(err) 442 } 443 444 names := []string{} 445 446 // all we need to do is list links on the directory listing page that look like plugins 447 var f func(*html.Node) 448 f = func(n *html.Node) { 449 if n.Type == html.ElementNode && n.Data == "a" { 450 c := n.FirstChild 451 if c != nil && c.Type == html.TextNode && strings.HasPrefix(c.Data, "terraform-") { 452 names = append(names, c.Data) 453 return 454 } 455 } 456 for c := n.FirstChild; c != nil; c = c.NextSibling { 457 f(c) 458 } 459 } 460 f(body) 461 462 return versionsFromNames(names), nil 463 } 464 465 // parse the list of directory names into a sorted list of available versions 466 func versionsFromNames(names []string) []Version { 467 var versions []Version 468 for _, name := range names { 469 parts := strings.SplitN(name, "_", 2) 470 if len(parts) == 2 && parts[1] != "" { 471 v, err := VersionStr(parts[1]).Parse() 472 if err != nil { 473 // filter invalid versions scraped from the page 474 log.Printf("[WARN] invalid version found for %q: %s", name, err) 475 continue 476 } 477 478 versions = append(versions, v) 479 } 480 } 481 482 return versions 483 } 484 485 func checksumForFile(sums []byte, name string) string { 486 for _, line := range strings.Split(string(sums), "\n") { 487 parts := strings.Fields(line) 488 if len(parts) > 1 && parts[1] == name { 489 return parts[0] 490 } 491 } 492 return "" 493 } 494 495 // fetch the SHA256SUMS file provided, and verify its signature. 496 func getPluginSHA256SUMs(sumsURL string) ([]byte, error) { 497 sigURL := sumsURL + ".sig" 498 499 sums, err := getFile(sumsURL) 500 if err != nil { 501 return nil, fmt.Errorf("error fetching checksums: %s", err) 502 } 503 504 sig, err := getFile(sigURL) 505 if err != nil { 506 return nil, fmt.Errorf("error fetching checksums signature: %s", err) 507 } 508 509 if err := verifySig(sums, sig); err != nil { 510 return nil, err 511 } 512 513 return sums, nil 514 } 515 516 func getFile(url string) ([]byte, error) { 517 resp, err := httpClient.Get(url) 518 if err != nil { 519 return nil, err 520 } 521 defer resp.Body.Close() 522 523 if resp.StatusCode != http.StatusOK { 524 return nil, fmt.Errorf("%s", resp.Status) 525 } 526 527 data, err := ioutil.ReadAll(resp.Body) 528 if err != nil { 529 return data, err 530 } 531 return data, nil 532 } 533 534 func GetReleaseHost() string { 535 return releaseHost 536 }