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