terraform/internal/providercache/installer.go

605 lines
23 KiB
Go

package providercache
import (
"context"
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/apparentlymart/go-versions/versions"
"github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/internal/copydir"
"github.com/hashicorp/terraform/internal/getproviders"
tfversion "github.com/hashicorp/terraform/version"
)
// Installer is the main type in this package, representing a provider installer
// with a particular configuration-specific cache directory and an optional
// global cache directory.
type Installer struct {
// targetDir is the cache directory we're ultimately aiming to get the
// requested providers installed into.
targetDir *Dir
// source is the provider source that the installer will use to discover
// what provider versions are available for installation and to
// find the source locations for any versions that are not already
// available via one of the cache directories.
source getproviders.Source
// globalCacheDir is an optional additional directory that will, if
// provided, be treated as a read-through cache when retrieving new
// provider versions. That is, new packages are fetched into this
// directory first and then linked into targetDir, which allows sharing
// both the disk space and the download time for a particular provider
// version between different configurations on the same system.
globalCacheDir *Dir
// builtInProviderTypes is an optional set of types that should be
// considered valid to appear in the special terraform.io/builtin/...
// namespace, which we use for providers that are built in to Terraform
// and thus do not need any separate installation step.
builtInProviderTypes []string
// pluginProtocolVersion is the protocol version terrafrom core supports to
// communicate with servers, and is used to resolve plugin discovery with
// terraform registry, in addition to any specified plugin version
// constraints.
pluginProtocolVersion getproviders.VersionConstraints
}
// The currently-supported plugin protocol version.
var SupportedPluginProtocols = getproviders.MustParseVersionConstraints("~> 5")
// NewInstaller constructs and returns a new installer with the given target
// directory and provider source.
//
// A newly-created installer does not have a global cache directory configured,
// but a caller can make a follow-up call to SetGlobalCacheDir to provide
// one prior to taking any installation actions.
//
// The target directory MUST NOT also be an input consulted by the given source,
// or the result is undefined.
func NewInstaller(targetDir *Dir, source getproviders.Source) *Installer {
return &Installer{
targetDir: targetDir,
source: source,
pluginProtocolVersion: SupportedPluginProtocols,
}
}
// SetGlobalCacheDir activates a second tier of caching for the receiving
// installer, with the given directory used as a read-through cache for
// installation operations that need to retrieve new packages.
//
// The global cache directory for an installer must never be the same as its
// target directory, and must not be used as one of its provider sources.
// If these overlap then undefined behavior will result.
func (i *Installer) SetGlobalCacheDir(cacheDir *Dir) {
// A little safety check to catch straightforward mistakes where the
// directories overlap. Better to panic early than to do
// possibly-distructive actions on the cache directory downstream.
if same, err := copydir.SameFile(i.targetDir.baseDir, cacheDir.baseDir); err == nil && same {
panic(fmt.Sprintf("global cache directory %s must not match the installation target directory %s", cacheDir.baseDir, i.targetDir.baseDir))
}
i.globalCacheDir = cacheDir
}
// SetBuiltInProviderTypes tells the receiver to consider the type names in the
// given slice to be valid as providers in the special special
// terraform.io/builtin/... namespace that we use for providers that are
// built in to Terraform and thus do not need a separate installation step.
//
// If a caller requests installation of a provider in that namespace, the
// installer will treat it as a no-op if its name exists in this list, but
// will produce an error if it does not.
//
// The default, if this method isn't called, is for there to be no valid
// builtin providers.
//
// Do not modify the buffer under the given slice after passing it to this
// method.
func (i *Installer) SetBuiltInProviderTypes(types []string) {
i.builtInProviderTypes = types
}
// EnsureProviderVersions compares the given provider requirements with what
// is already available in the installer's target directory and then takes
// appropriate installation actions to ensure that suitable packages
// are available in the target cache directory.
//
// The given mode modifies how the operation will treat providers that already
// have acceptable versions available in the target cache directory. See the
// documentation for InstallMode and the InstallMode values for more
// information.
//
// The given context can be used to cancel the overall installation operation
// (causing any operations in progress to fail with an error), and can also
// include an InstallerEvents value for optional intermediate progress
// notifications.
//
// If a given InstallerEvents subscribes to notifications about installation
// failures then those notifications will be redundant with the ones included
// in the final returned error value so callers should show either one or the
// other, and not both.
func (i *Installer) EnsureProviderVersions(ctx context.Context, reqs getproviders.Requirements, mode InstallMode) (getproviders.Selections, error) {
// FIXME: Currently the context isn't actually propagated into all of the
// other functions we call here, because they are not context-aware.
// Anything that could be making network requests here should take a
// context and ideally respond to the cancellation of that context.
errs := map[addrs.Provider]error{}
evts := installerEventsForContext(ctx)
if cb := evts.PendingProviders; cb != nil {
cb(reqs)
}
// Here we'll keep track of which exact version we've selected for each
// provider in the requirements.
selected := map[addrs.Provider]getproviders.Version{}
// Step 1: Which providers might we need to fetch a new version of?
// This produces the subset of requirements we need to ask the provider
// source about.
have := i.targetDir.AllAvailablePackages()
mightNeed := map[addrs.Provider]getproviders.VersionSet{}
MightNeedProvider:
for provider, versionConstraints := range reqs {
if provider.IsBuiltIn() {
// Built in providers do not require installation but we'll still
// verify that the requested provider name is valid.
valid := false
for _, name := range i.builtInProviderTypes {
if name == provider.Type {
valid = true
break
}
}
var err error
if valid {
if len(versionConstraints) == 0 {
// Other than reporting an event for the outcome of this
// provider, we'll do nothing else with it: it's just
// automatically available for use.
if cb := evts.BuiltInProviderAvailable; cb != nil {
cb(provider)
}
} else {
// A built-in provider is not permitted to have an explicit
// version constraint, because we can only use the version
// that is built in to the current Terraform release.
err = fmt.Errorf("built-in providers do not support explicit version constraints")
}
} else {
err = fmt.Errorf("this Terraform release has no built-in provider named %q", provider.Type)
}
if err != nil {
errs[provider] = err
if cb := evts.BuiltInProviderFailure; cb != nil {
cb(provider, err)
}
}
continue
}
acceptableVersions := versions.MeetingConstraints(versionConstraints)
if mode.forceQueryAllProviders() {
// If our mode calls for us to look for newer versions regardless
// of whether an existing version is acceptable, we "might need"
// _all_ of the requested providers.
mightNeed[provider] = acceptableVersions
continue
}
havePackages, ok := have[provider]
if !ok { // If we don't have any versions at all then we'll definitely need it
mightNeed[provider] = acceptableVersions
continue
}
// If we already have some versions installed and our mode didn't
// force us to check for new ones anyway then we'll check only if
// there isn't already at least one version in our cache that is
// in the set of acceptable versions.
for _, pkg := range havePackages {
if acceptableVersions.Has(pkg.Version) {
// We will take no further actions for this provider, because
// a version we have is already acceptable.
selected[provider] = pkg.Version
if cb := evts.ProviderAlreadyInstalled; cb != nil {
cb(provider, pkg.Version)
}
continue MightNeedProvider
}
}
// If we get here then we didn't find any cached version that is
// in our set of acceptable versions.
mightNeed[provider] = acceptableVersions
}
// Step 2: Query the provider source for each of the providers we selected
// in the first step and select the latest available version that is
// in the set of acceptable versions.
//
// This produces a set of packages to install to our cache in the next step.
need := map[addrs.Provider]getproviders.Version{}
NeedProvider:
for provider, acceptableVersions := range mightNeed {
if cb := evts.QueryPackagesBegin; cb != nil {
cb(provider, reqs[provider])
}
available, err := i.source.AvailableVersions(provider)
if err != nil {
// TODO: Consider retrying a few times for certain types of
// source errors that seem likely to be transient.
errs[provider] = err
if cb := evts.QueryPackagesFailure; cb != nil {
cb(provider, err)
}
// We will take no further actions for this provider.
continue
}
available.Sort() // put the versions in increasing order of precedence
for i := len(available) - 1; i >= 0; i-- { // walk backwards to consider newer versions first
if acceptableVersions.Has(available[i]) {
need[provider] = available[i]
if cb := evts.QueryPackagesSuccess; cb != nil {
cb(provider, available[i])
}
continue NeedProvider
}
}
// If we get here then the source has no packages that meet the given
// version constraint, which we model as a query error.
err = fmt.Errorf("no available releases match the given constraints %s", getproviders.VersionConstraintsString(reqs[provider]))
errs[provider] = err
if cb := evts.QueryPackagesFailure; cb != nil {
cb(provider, err)
}
}
// Step 3: For each provider version we've decided we need to install,
// install its package into our target cache (possibly via the global cache).
targetPlatform := i.targetDir.targetPlatform // we inherit this to behave correctly in unit tests
for provider, version := range need {
if i.globalCacheDir != nil {
// Step 3a: If our global cache already has this version available then
// we'll just link it in.
if cached := i.globalCacheDir.ProviderVersion(provider, version); cached != nil {
if cb := evts.LinkFromCacheBegin; cb != nil {
cb(provider, version, i.globalCacheDir.baseDir)
}
err := i.targetDir.LinkFromOtherCache(cached)
if err != nil {
errs[provider] = err
if cb := evts.LinkFromCacheFailure; cb != nil {
cb(provider, version, err)
}
continue
}
// We'll fetch what we just linked to make sure it actually
// did show up there.
new := i.targetDir.ProviderVersion(provider, version)
if new == nil {
err := fmt.Errorf("after linking %s from provider cache at %s it is still not detected in the target directory; this is a bug in Terraform", provider, i.globalCacheDir.baseDir)
if cb := evts.LinkFromCacheFailure; cb != nil {
cb(provider, version, err)
}
continue
}
selected[provider] = version
if cb := evts.LinkFromCacheSuccess; cb != nil {
cb(provider, version, new.PackageDir)
}
continue // Don't need to do full install, then.
}
}
// Step 3b: Get the package metadata for the selected version from our
// provider source.
//
// This is the step where we might detect and report that the provider
// isn't available for the current platform.
if cb := evts.FetchPackageMeta; cb != nil {
cb(provider, version)
}
meta, err := i.source.PackageMeta(provider, version, targetPlatform)
if err != nil {
errs[provider] = err
if cb := evts.FetchPackageFailure; cb != nil {
cb(provider, version, err)
}
continue
}
// if the package meta includes provider protocol versions, verify that terraform supports it.
if len(meta.ProtocolVersions) > 0 {
protoVersions := versions.MeetingConstraints(i.pluginProtocolVersion)
match := false
for _, version := range meta.ProtocolVersions {
if protoVersions.Has(version) {
match = true
}
}
if match == false {
// Find the closest matching version
closestAvailable := i.findClosestProtocolCompatibleVersion(provider, version)
if closestAvailable == versions.Unspecified {
err := fmt.Errorf(errProviderVersionIncompatible, provider)
errs[provider] = err
if cb := evts.FetchPackageFailure; cb != nil {
cb(provider, version, err)
}
continue
}
// Determine if the closest matching provider is newer or older
// than the requirement in order to send the appropriate error
// message.
var protoErr string
if version.GreaterThan(closestAvailable) {
protoErr = providerProtocolTooNew
} else {
protoErr = providerProtocolTooOld
}
err := fmt.Errorf(protoErr, provider, version, tfversion.String(), closestAvailable.String(), closestAvailable.String(), getproviders.VersionConstraintsString(reqs[provider]))
errs[provider] = err
if cb := evts.FetchPackageFailure; cb != nil {
cb(provider, version, err)
}
continue
}
}
// Step 3c: Retrieve the package indicated by the metadata we received,
// either directly into our target directory or via the global cache
// directory.
if cb := evts.FetchPackageBegin; cb != nil {
cb(provider, version, meta.Location)
}
var installTo, linkTo *Dir
if i.globalCacheDir != nil {
installTo = i.globalCacheDir
linkTo = i.targetDir
} else {
installTo = i.targetDir
linkTo = nil // no linking needed
}
authResult, err := installTo.InstallPackage(ctx, meta)
if err != nil {
// TODO: Consider retrying for certain kinds of error that seem
// likely to be transient. For now, we just treat all errors equally.
errs[provider] = err
if cb := evts.FetchPackageFailure; cb != nil {
cb(provider, version, err)
}
continue
}
new := installTo.ProviderVersion(provider, version)
if new == nil {
err := fmt.Errorf("after installing %s it is still not detected in the target directory; this is a bug in Terraform", provider)
errs[provider] = err
if cb := evts.FetchPackageFailure; cb != nil {
cb(provider, version, err)
}
continue
}
if linkTo != nil {
// We skip emitting the "LinkFromCache..." events here because
// it's simpler for the caller to treat them as mutually exclusive.
// We can just subsume the linking step under the "FetchPackage..."
// series here (and that's why we use FetchPackageFailure below).
err := linkTo.LinkFromOtherCache(new)
if err != nil {
errs[provider] = err
if cb := evts.FetchPackageFailure; cb != nil {
cb(provider, version, err)
}
continue
}
}
selected[provider] = version
if cb := evts.FetchPackageSuccess; cb != nil {
cb(provider, version, new.PackageDir, authResult)
}
}
// We'll remember our selections in a lock file inside the target directory,
// so callers can recover those exact selections later by calling
// SelectedPackages on the same installer.
lockEntries := map[addrs.Provider]lockFileEntry{}
for provider, version := range selected {
cached := i.targetDir.ProviderVersion(provider, version)
if cached == nil {
err := fmt.Errorf("selected package for %s is no longer present in the target directory; this is a bug in Terraform", provider)
errs[provider] = err
if cb := evts.HashPackageFailure; cb != nil {
cb(provider, version, err)
}
continue
}
hash, err := cached.Hash()
if err != nil {
errs[provider] = fmt.Errorf("failed to calculate checksum for installed provider %s package: %s", provider, err)
if cb := evts.HashPackageFailure; cb != nil {
cb(provider, version, err)
}
continue
}
lockEntries[provider] = lockFileEntry{
SelectedVersion: version,
PackageHash: hash,
}
}
err := i.lockFile().Write(lockEntries)
if err != nil {
// This is one of few cases where this function does _not_ return an
// InstallerError, because failure to write the lock file is a more
// general problem, not specific to a certain provider.
return selected, fmt.Errorf("failed to record a manifest of selected providers: %s", err)
}
if len(errs) > 0 {
return selected, InstallerError{
ProviderErrors: errs,
}
}
return selected, nil
}
func (i *Installer) lockFile() *lockFile {
return &lockFile{
filename: filepath.Join(i.targetDir.baseDir, "selections.json"),
}
}
// SelectedPackages returns the metadata about the packages chosen by the
// most recent call to EnsureProviderVersions, which are recorded in a lock
// file in the installer's target directory.
//
// If EnsureProviderVersions has never been run against the current target
// directory, the result is a successful empty response indicating that nothing
// is selected.
//
// SelectedPackages also verifies that the package contents are consistent
// with the checksums that were recorded at installation time, reporting an
// error if not.
func (i *Installer) SelectedPackages() (map[addrs.Provider]*CachedProvider, error) {
entries, err := i.lockFile().Read()
if err != nil {
// Read does not return an error for "file not found", so this should
// always be some other error.
return nil, fmt.Errorf("failed to read selections file: %s", err)
}
ret := make(map[addrs.Provider]*CachedProvider, len(entries))
errs := make(map[addrs.Provider]error)
for provider, entry := range entries {
cached := i.targetDir.ProviderVersion(provider, entry.SelectedVersion)
if cached == nil {
errs[provider] = fmt.Errorf("package for selected version %s is no longer available in the local cache directory", entry.SelectedVersion)
continue
}
ok, err := cached.MatchesHash(entry.PackageHash)
if err != nil {
errs[provider] = fmt.Errorf("failed to verify checksum for v%s package: %s", entry.SelectedVersion, err)
continue
}
if !ok {
errs[provider] = fmt.Errorf("checksum mismatch for v%s package", entry.SelectedVersion)
continue
}
ret[provider] = cached
}
if len(errs) > 0 {
return ret, InstallerError{
ProviderErrors: errs,
}
}
return ret, nil
}
// InstallMode customizes the details of how an install operation treats
// providers that have versions already cached in the target directory.
type InstallMode rune
const (
// InstallNewProvidersOnly is an InstallMode that causes the installer
// to accept any existing version of a requested provider that is already
// cached as long as it's in the given version sets, without checking
// whether new versions are available that are also in the given version
// sets.
InstallNewProvidersOnly InstallMode = 'N'
// InstallUpgrades is an InstallMode that causes the installer to check
// all requested providers to see if new versions are available that
// are also in the given version sets, even if a suitable version of
// a given provider is already available.
InstallUpgrades InstallMode = 'U'
)
func (m InstallMode) forceQueryAllProviders() bool {
return m == InstallUpgrades
}
// InstallerError is an error type that may be returned (but is not guaranteed)
// from Installer.EnsureProviderVersions to indicate potentially several
// separate failed installation outcomes for different providers included in
// the overall request.
type InstallerError struct {
ProviderErrors map[addrs.Provider]error
}
func (err InstallerError) Error() string {
addrs := make([]addrs.Provider, 0, len(err.ProviderErrors))
for addr := range err.ProviderErrors {
addrs = append(addrs, addr)
}
sort.Slice(addrs, func(i, j int) bool {
return addrs[i].LessThan(addrs[j])
})
var b strings.Builder
b.WriteString("some providers could not be installed:\n")
for _, addr := range addrs {
providerErr := err.ProviderErrors[addr]
fmt.Fprintf(&b, "- %s: %s\n", addr, providerErr)
}
return b.String()
}
// findClosestProtocolCompatibleVersion searches for the provider version with the closest protocol match.
func (i *Installer) findClosestProtocolCompatibleVersion(provider addrs.Provider, version versions.Version) versions.Version {
var match versions.Version
available, _ := i.source.AvailableVersions(provider)
available.Sort() // put the versions in increasing order of precedence
for index := len(available) - 1; index >= 0; index-- { // walk backwards to consider newer versions first
meta, _ := i.source.PackageMeta(provider, available[index], i.targetDir.targetPlatform)
if len(meta.ProtocolVersions) > 0 {
protoVersions := versions.MeetingConstraints(i.pluginProtocolVersion)
for _, version := range meta.ProtocolVersions {
if protoVersions.Has(version) {
match = available[index]
break // we will only consider the newest matching version
}
}
}
}
return match
}
// providerProtocolTooOld is a message sent to the CLI UI if the provider's
// supported protocol versions are too old for the user's version of terraform,
// but an older version of the provider is compatible.
const providerProtocolTooOld = `
Provider %q v%s is not compatible with Terraform %s.
Provider version %s is the earliest compatible version. Select it with
the following version constraint:
version = %q
Terraform checked all of the plugin versions matching the given constraint:
%s
Consult the documentation for this provider for more information on
compatibility between provider and Terraform versions.
`
// providerProtocolTooNew is a message sent to the CLI UI if the provider's
// supported protocol versions are too new for the user's version of terraform,
// and the user could either upgrade terraform or choose an older version of the
// provider
const providerProtocolTooNew = `
Provider %q v%s is not compatible with Terraform %s.
Provider version %s is the latest compatible version. Select it with
the following constraint:
version = %q
Terraform checked all of the plugin versions matching the given constraint:
%s
Consult the documentation for this provider for more information on
compatibility between provider and Terraform versions.
Alternatively, upgrade to the latest version of Terraform for compatibility with newer provider releases.
`
// there does exist a version outside of the constaints that is compatible.
const errProviderVersionIncompatible = `No compatible versions of provider %s were found.`