diff --git a/command/e2etest/init_test.go b/command/e2etest/init_test.go index 2451c3c91..e013f6291 100644 --- a/command/e2etest/init_test.go +++ b/command/e2etest/init_test.go @@ -44,8 +44,8 @@ func TestInitProviders(t *testing.T) { t.Logf("(this can happen if you have a copy of the plugin in one of the global plugin search dirs)") } - if !strings.Contains(stdout, "* hashicorp/template: version = ") { - t.Errorf("provider pinning recommendation is missing from output:\n%s", stdout) + if !strings.Contains(stdout, "Terraform has created a lock file") { + t.Errorf("lock file notification is missing from output:\n%s", stdout) } } diff --git a/command/init.go b/command/init.go index 475d5c9ba..f2f7adf1e 100644 --- a/command/init.go +++ b/command/init.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "os" - "sort" "strings" "github.com/hashicorp/hcl/v2" @@ -422,9 +421,9 @@ the backend configuration is present and valid. func (c *InitCommand) getProviders(config *configs.Config, state *states.State, upgrade bool, pluginDirs []string) (output, abort bool, diags tfdiags.Diagnostics) { // First we'll collect all the provider dependencies we can see in the // configuration and the state. - reqs, moreDiags := config.ProviderRequirements() - diags = diags.Append(moreDiags) - if moreDiags.HasErrors() { + reqs, hclDiags := config.ProviderRequirements() + diags = diags.Append(hclDiags) + if hclDiags.HasErrors() { return false, true, diags } if state != nil { @@ -444,6 +443,10 @@ func (c *InitCommand) getProviders(config *configs.Config, state *states.State, )) } } + + previousLocks, moreDiags := c.lockedDependencies() + diags = diags.Append(moreDiags) + if diags.HasErrors() { return false, true, diags } @@ -729,7 +732,7 @@ func (c *InitCommand) getProviders(config *configs.Config, state *states.State, ctx, done := c.InterruptibleContext() defer done() ctx = evts.OnContext(ctx) - selected, err := inst.EnsureProviderVersions(ctx, reqs, mode) + newLocks, err := inst.EnsureProviderVersions(ctx, previousLocks, reqs, mode) if ctx.Err() == context.Canceled { c.showDiagnostics(diags) c.Ui.Error("Provider installation was canceled by an interrupt signal.") @@ -746,29 +749,41 @@ func (c *InitCommand) getProviders(config *configs.Config, state *states.State, return true, true, diags } - // If any providers have "floating" versions (completely unconstrained) - // we'll suggest the user constrain with a pessimistic constraint to - // avoid implicitly adopting a later major release. - constraintSuggestions := make(map[string]string) - for addr, version := range selected { - req := reqs[addr] - - if len(req) == 0 { - constraintSuggestions[addr.ForDisplay()] = "~> " + version.String() + // If the provider dependencies have changed since the last run then we'll + // say a little about that in case the reader wasn't expecting a change. + // (When we later integrate module dependencies into the lock file we'll + // probably want to refactor this so that we produce one lock-file related + // message for all changes together, but this is here for now just because + // it's the smallest change relative to what came before it, which was + // a hidden JSON file specifically for tracking providers.) + if !newLocks.Equal(previousLocks) { + if previousLocks.Empty() { + // A change from empty to non-empty is special because it suggests + // we're running "terraform init" for the first time against a + // new configuration. In that case we'll take the opportunity to + // say a little about what the dependency lock file is, for new + // users or those who are upgrading from a previous Terraform + // version that didn't have dependency lock files. + c.Ui.Output(c.Colorize().Color(` +Terraform has created a lock file [bold].terraform.lock.hcl[reset] to record the provider +selections it made above. Include this file in your version control repository +so that Terraform can guarantee to make the same selections by default when +you run "terraform init" in the future.`)) + } else { + c.Ui.Output(c.Colorize().Color(` +Terraform has made some changes to the provider dependency selections recorded +in the .terraform.lock.hcl file. Review those changes and commit them to your +version control system if they represent changes you intended to make.`)) } } - if len(constraintSuggestions) != 0 { - names := make([]string, 0, len(constraintSuggestions)) - for name := range constraintSuggestions { - names = append(names, name) - } - sort.Strings(names) - c.Ui.Output(outputInitProvidersUnconstrained) - for _, name := range names { - c.Ui.Output(fmt.Sprintf("* %s: version = %q", name, constraintSuggestions[name])) - } - } + // TODO: Check whether newLocks is different from previousLocks and mention + // in the UI if so. We should emit a different message if previousLocks was + // empty, because that indicates we were creating a lock file for the first + // time and so we need to introduce the user to the idea of it. + + moreDiags = c.replaceLockedDependencies(newLocks) + diags = diags.Append(moreDiags) return true, false, diags } diff --git a/command/init_test.go b/command/init_test.go index 0db9b396c..985aad232 100644 --- a/command/init_test.go +++ b/command/init_test.go @@ -1,6 +1,7 @@ package command import ( + "bytes" "context" "encoding/json" "fmt" @@ -20,6 +21,7 @@ import ( "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/configs" "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/getproviders" "github.com/hashicorp/terraform/internal/providercache" "github.com/hashicorp/terraform/states" @@ -1126,13 +1128,13 @@ func TestInit_getProviderInvalidPackage(t *testing.T) { } wantErrors := []string{ - "Failed to validate installed provider", + "Failed to install provider", "could not find executable file starting with terraform-provider-package", } got := ui.ErrorWriter.String() for _, wantError := range wantErrors { if !strings.Contains(got, wantError) { - t.Fatalf("missing error:\nwant: %q\n got: %q", wantError, got) + t.Fatalf("missing error:\nwant: %q\ngot:\n%s", wantError, got) } } } @@ -1267,29 +1269,38 @@ func TestInit_providerSource(t *testing.T) { t.Errorf("wrong cache directory contents after upgrade\n%s", diff) } - inst := m.providerInstaller() - gotSelected, err := inst.SelectedPackages() + locks, err := m.lockedDependencies() if err != nil { - t.Fatalf("failed to get selected packages from installer: %s", err) + t.Fatalf("failed to get locked dependencies: %s", err) } - wantSelected := map[addrs.Provider]*providercache.CachedProvider{ - addrs.NewDefaultProvider("test-beta"): { - Provider: addrs.NewDefaultProvider("test-beta"), - Version: getproviders.MustParseVersion("1.2.4"), - PackageDir: expectedPackageInstallPath("test-beta", "1.2.4", false), - }, - addrs.NewDefaultProvider("test"): { - Provider: addrs.NewDefaultProvider("test"), - Version: getproviders.MustParseVersion("1.2.3"), - PackageDir: expectedPackageInstallPath("test", "1.2.3", false), - }, - addrs.NewDefaultProvider("source"): { - Provider: addrs.NewDefaultProvider("source"), - Version: getproviders.MustParseVersion("1.2.3"), - PackageDir: expectedPackageInstallPath("source", "1.2.3", false), - }, + gotProviderLocks := locks.AllProviders() + wantProviderLocks := map[addrs.Provider]*depsfile.ProviderLock{ + addrs.NewDefaultProvider("test-beta"): depsfile.NewProviderLock( + addrs.NewDefaultProvider("test-beta"), + getproviders.MustParseVersion("1.2.4"), + getproviders.MustParseVersionConstraints("= 1.2.4"), + []getproviders.Hash{ + getproviders.HashScheme1.New("see6W06w09Ea+AobFJ+mbvPTie6ASqZAAdlFZbs8BSM="), + }, + ), + addrs.NewDefaultProvider("test"): depsfile.NewProviderLock( + addrs.NewDefaultProvider("test"), + getproviders.MustParseVersion("1.2.3"), + getproviders.MustParseVersionConstraints("= 1.2.3"), + []getproviders.Hash{ + getproviders.HashScheme1.New("wlbEC2mChQZ2hhgUhl6SeVLPP7fMqOFUZAQhQ9GIIno="), + }, + ), + addrs.NewDefaultProvider("source"): depsfile.NewProviderLock( + addrs.NewDefaultProvider("source"), + getproviders.MustParseVersion("1.2.3"), + getproviders.MustParseVersionConstraints("= 1.2.3"), + []getproviders.Hash{ + getproviders.HashScheme1.New("myS3qb3px3tRBq1ZWRYJeUH+kySWpBc0Yy8rw6W7/p4="), + }, + ), } - if diff := cmp.Diff(wantSelected, gotSelected); diff != "" { + if diff := cmp.Diff(gotProviderLocks, wantProviderLocks, depsfile.ProviderLockComparer); diff != "" { t.Errorf("wrong version selections after upgrade\n%s", diff) } @@ -1435,32 +1446,40 @@ func TestInit_getUpgradePlugins(t *testing.T) { t.Errorf("wrong cache directory contents after upgrade\n%s", diff) } - inst := m.providerInstaller() - gotSelected, err := inst.SelectedPackages() + locks, err := m.lockedDependencies() if err != nil { - t.Fatalf("failed to get selected packages from installer: %s", err) + t.Fatalf("failed to get locked dependencies: %s", err) } - wantSelected := map[addrs.Provider]*providercache.CachedProvider{ - addrs.NewDefaultProvider("between"): { - Provider: addrs.NewDefaultProvider("between"), - Version: getproviders.MustParseVersion("2.3.4"), - PackageDir: expectedPackageInstallPath("between", "2.3.4", false), - }, - addrs.NewDefaultProvider("exact"): { - Provider: addrs.NewDefaultProvider("exact"), - Version: getproviders.MustParseVersion("1.2.3"), - PackageDir: expectedPackageInstallPath("exact", "1.2.3", false), - }, - addrs.NewDefaultProvider("greater-than"): { - Provider: addrs.NewDefaultProvider("greater-than"), - Version: getproviders.MustParseVersion("2.3.4"), - PackageDir: expectedPackageInstallPath("greater-than", "2.3.4", false), - }, + gotProviderLocks := locks.AllProviders() + wantProviderLocks := map[addrs.Provider]*depsfile.ProviderLock{ + addrs.NewDefaultProvider("between"): depsfile.NewProviderLock( + addrs.NewDefaultProvider("between"), + getproviders.MustParseVersion("2.3.4"), + getproviders.MustParseVersionConstraints("> 1.0.0, < 3.0.0"), + []getproviders.Hash{ + getproviders.HashScheme1.New("JVqAvZz88A+hS2wHVtTWQkHaxoA/LrUAz0H3jPBWPIA="), + }, + ), + addrs.NewDefaultProvider("exact"): depsfile.NewProviderLock( + addrs.NewDefaultProvider("exact"), + getproviders.MustParseVersion("1.2.3"), + getproviders.MustParseVersionConstraints("= 1.2.3"), + []getproviders.Hash{ + getproviders.HashScheme1.New("H1TxWF8LyhBb6B4iUdKhLc/S9sC/jdcrCykpkbGcfbg="), + }, + ), + addrs.NewDefaultProvider("greater-than"): depsfile.NewProviderLock( + addrs.NewDefaultProvider("greater-than"), + getproviders.MustParseVersion("2.3.4"), + getproviders.MustParseVersionConstraints(">= 2.3.3"), + []getproviders.Hash{ + getproviders.HashScheme1.New("SJPpXx/yoFE/W+7eCipjJ+G21xbdnTBD7lWodZ8hWkU="), + }, + ), } - if diff := cmp.Diff(wantSelected, gotSelected); diff != "" { + if diff := cmp.Diff(gotProviderLocks, wantProviderLocks, depsfile.ProviderLockComparer); diff != "" { t.Errorf("wrong version selections after upgrade\n%s", diff) } - } func TestInit_getProviderMissing(t *testing.T) { @@ -1537,7 +1556,7 @@ func TestInit_providerLockFile(t *testing.T) { defer testChdir(t, td)() providerSource, close := newMockProviderSource(t, map[string][]string{ - "test": []string{"1.2.3"}, + "test": {"1.2.3"}, }) defer close() @@ -1557,23 +1576,26 @@ func TestInit_providerLockFile(t *testing.T) { t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) } - selectionsFile := ".terraform/plugins/selections.json" - buf, err := ioutil.ReadFile(selectionsFile) + lockFile := ".terraform.lock.hcl" + buf, err := ioutil.ReadFile(lockFile) if err != nil { - t.Fatalf("failed to read provider selections file %s: %s", selectionsFile, err) + t.Fatalf("failed to read dependency lock file %s: %s", lockFile, err) } + buf = bytes.TrimSpace(buf) // The hash in here is for the fake package that newMockProviderSource produces // (so it'll change if newMockProviderSource starts producing different contents) wantLockFile := strings.TrimSpace(` -{ - "registry.terraform.io/hashicorp/test": { - "hash": "h1:wlbEC2mChQZ2hhgUhl6SeVLPP7fMqOFUZAQhQ9GIIno=", - "version": "1.2.3" - } +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/test" { + version = "1.2.3" + constraints = "1.2.3" + hashes = ["h1:wlbEC2mChQZ2hhgUhl6SeVLPP7fMqOFUZAQhQ9GIIno="] } `) - if string(buf) != wantLockFile { - t.Errorf("wrong provider selections file contents\ngot: %s\nwant: %s", buf, wantLockFile) + if diff := cmp.Diff(wantLockFile, string(buf)); diff != "" { + t.Errorf("wrong dependency lock file contents\n%s", diff) } } @@ -1697,29 +1719,38 @@ func TestInit_pluginDirProviders(t *testing.T) { t.Fatalf("bad: \n%s", ui.ErrorWriter) } - inst := m.providerInstaller() - gotSelected, err := inst.SelectedPackages() + locks, err := m.lockedDependencies() if err != nil { - t.Fatalf("failed to get selected packages from installer: %s", err) + t.Fatalf("failed to get locked dependencies: %s", err) } - wantSelected := map[addrs.Provider]*providercache.CachedProvider{ - addrs.NewDefaultProvider("between"): { - Provider: addrs.NewDefaultProvider("between"), - Version: getproviders.MustParseVersion("2.3.4"), - PackageDir: expectedPackageInstallPath("between", "2.3.4", false), - }, - addrs.NewDefaultProvider("exact"): { - Provider: addrs.NewDefaultProvider("exact"), - Version: getproviders.MustParseVersion("1.2.3"), - PackageDir: expectedPackageInstallPath("exact", "1.2.3", false), - }, - addrs.NewDefaultProvider("greater-than"): { - Provider: addrs.NewDefaultProvider("greater-than"), - Version: getproviders.MustParseVersion("2.3.4"), - PackageDir: expectedPackageInstallPath("greater-than", "2.3.4", false), - }, + gotProviderLocks := locks.AllProviders() + wantProviderLocks := map[addrs.Provider]*depsfile.ProviderLock{ + addrs.NewDefaultProvider("between"): depsfile.NewProviderLock( + addrs.NewDefaultProvider("between"), + getproviders.MustParseVersion("2.3.4"), + getproviders.MustParseVersionConstraints("> 1.0.0, < 3.0.0"), + []getproviders.Hash{ + getproviders.HashScheme1.New("JVqAvZz88A+hS2wHVtTWQkHaxoA/LrUAz0H3jPBWPIA="), + }, + ), + addrs.NewDefaultProvider("exact"): depsfile.NewProviderLock( + addrs.NewDefaultProvider("exact"), + getproviders.MustParseVersion("1.2.3"), + getproviders.MustParseVersionConstraints("= 1.2.3"), + []getproviders.Hash{ + getproviders.HashScheme1.New("H1TxWF8LyhBb6B4iUdKhLc/S9sC/jdcrCykpkbGcfbg="), + }, + ), + addrs.NewDefaultProvider("greater-than"): depsfile.NewProviderLock( + addrs.NewDefaultProvider("greater-than"), + getproviders.MustParseVersion("2.3.4"), + getproviders.MustParseVersionConstraints(">= 2.3.3"), + []getproviders.Hash{ + getproviders.HashScheme1.New("SJPpXx/yoFE/W+7eCipjJ+G21xbdnTBD7lWodZ8hWkU="), + }, + ), } - if diff := cmp.Diff(wantSelected, gotSelected); diff != "" { + if diff := cmp.Diff(gotProviderLocks, wantProviderLocks, depsfile.ProviderLockComparer); diff != "" { t.Errorf("wrong version selections after upgrade\n%s", diff) } @@ -1983,7 +2014,7 @@ func installFakeProviderPackagesElsewhere(t *testing.T, cacheDir *providercache. if err != nil { t.Fatalf("failed to prepare fake package for %s %s: %s", name, versionStr, err) } - _, err = cacheDir.InstallPackage(context.Background(), meta) + _, err = cacheDir.InstallPackage(context.Background(), meta, nil) if err != nil { t.Fatalf("failed to install fake package for %s %s: %s", name, versionStr, err) } diff --git a/command/meta.go b/command/meta.go index abc09eb46..face4be68 100644 --- a/command/meta.go +++ b/command/meta.go @@ -410,7 +410,12 @@ func (m *Meta) contextOpts() (*terraform.ContextOpts, error) { // This situation shouldn't arise commonly in practice because // the selections file is generated programmatically. log.Printf("[WARN] Failed to determine selected providers: %s", err) - providerFactories = nil + + // variable providerFactories may now be incomplete, which could + // lead to errors reported downstream from here. providerFactories + // tries to populate as many providers as possible even in an + // error case, so that operations not using problematic providers + // can still succeed. } opts.Providers = providerFactories opts.Provisioners = m.provisionerFactories() diff --git a/command/meta_dependencies.go b/command/meta_dependencies.go new file mode 100644 index 000000000..4a80adfc3 --- /dev/null +++ b/command/meta_dependencies.go @@ -0,0 +1,62 @@ +package command + +import ( + "os" + + "github.com/hashicorp/terraform/internal/depsfile" + "github.com/hashicorp/terraform/tfdiags" +) + +// dependenclyLockFilename is the filename of the dependency lock file. +// +// This file should live in the same directory as the .tf files for the +// root module of the configuration, alongside the .terraform directory +// as long as that directory's path isn't overridden by the TF_DATA_DIR +// environment variable. +// +// We always expect to find this file in the current working directory +// because that should also be the root module directory. +// +// Some commands have legacy command line arguments that make the root module +// directory something other than the root module directory; when using those, +// the lock file will be written in the "wrong" place (the current working +// directory instead of the root module directory) but we do that intentionally +// to match where the ".terraform" directory would also be written in that +// case. Eventually we will phase out those legacy arguments in favor of the +// global -chdir=... option, which _does_ preserve the intended invariant +// that the root module directory is always the current working directory. +const dependencyLockFilename = ".terraform.lock.hcl" + +// lockedDependencies reads the dependency lock information from the lock file +// in the current working directory. +// +// If the lock file doesn't exist at the time of the call, lockedDependencies +// indicates success and returns an empty Locks object. If the file does +// exist then the result is either a representation of the contents of that +// file at the instant of the call or error diagnostics explaining some way +// in which the lock file is invalid. +// +// The result is a snapshot of the locked dependencies at the time of the call +// and does not update as a result of calling replaceLockedDependencies +// or any other modification method. +func (m *Meta) lockedDependencies() (*depsfile.Locks, tfdiags.Diagnostics) { + // We check that the file exists first, because the underlying HCL + // parser doesn't distinguish that error from other error types + // in a machine-readable way but we want to treat that as a success + // with no locks. There is in theory a race condition here in that + // the file could be created or removed in the meantime, but we're not + // promising to support two concurrent dependency installation processes. + _, err := os.Stat(dependencyLockFilename) + if os.IsNotExist(err) { + return depsfile.NewLocks(), nil + } + + return depsfile.LoadLocksFromFile(dependencyLockFilename) +} + +// replaceLockedDependencies creates or overwrites the lock file in the +// current working directory to contain the information recorded in the given +// locks object. +func (m *Meta) replaceLockedDependencies(new *depsfile.Locks) tfdiags.Diagnostics { + return depsfile.SaveLocksToFile(new, dependencyLockFilename) +} diff --git a/command/meta_providers.go b/command/meta_providers.go index 548868ee7..e15ca4677 100644 --- a/command/meta_providers.go +++ b/command/meta_providers.go @@ -7,6 +7,7 @@ import ( "path/filepath" hclog "github.com/hashicorp/go-hclog" + "github.com/hashicorp/go-multierror" plugin "github.com/hashicorp/go-plugin" "github.com/hashicorp/terraform/addrs" @@ -160,34 +161,86 @@ func (m *Meta) providerInstallSource() getproviders.Source { // be honored with what is currently in the cache, such as if a selected // package has been removed from the cache or if the contents of a selected // package have been modified outside of the installer. If it returns an error, -// the returned map may be incomplete or invalid. +// the returned map may be incomplete or invalid, but will be as complete +// as possible given the cause of the error. func (m *Meta) providerFactories() (map[addrs.Provider]providers.Factory, error) { - // We don't have to worry about potentially calling - // providerInstallerCustomSource here because we're only using this - // installer for its SelectedPackages method, which does not consult - // any provider sources. - inst := m.providerInstaller() - selected, err := inst.SelectedPackages() - if err != nil { - return nil, fmt.Errorf("failed to recall provider packages selected by earlier 'terraform init': %s", err) + locks, diags := m.lockedDependencies() + if diags.HasErrors() { + return nil, fmt.Errorf("failed to read dependency lock file: %s", diags.Err()) } + // We'll always run through all of our providers, even if one of them + // encounters an error, so that we can potentially report multiple errors + // where appropriate and so that callers can potentially make use of the + // partial result we return if e.g. they want to enumerate which providers + // are available, or call into one of the providers that didn't fail. + var err error + + // For the providers from the lock file, we expect them to be already + // available in the provider cache because "terraform init" should already + // have put them there. + providerLocks := locks.AllProviders() + cacheDir := m.providerLocalCacheDir() + // The internal providers are _always_ available, even if the configuration // doesn't request them, because they don't need any special installation // and they'll just be ignored if not used. internalFactories := m.internalProviders() - factories := make(map[addrs.Provider]providers.Factory, len(selected)+len(internalFactories)+len(m.UnmanagedProviders)) + // The Terraform SDK test harness (and possibly other callers in future) + // can ask that we use its own already-started provider servers, which we + // call "unmanaged" because Terraform isn't responsible for starting + // and stopping them. + unmanagedProviders := m.UnmanagedProviders + + factories := make(map[addrs.Provider]providers.Factory, len(providerLocks)+len(internalFactories)+len(unmanagedProviders)) for name, factory := range internalFactories { factories[addrs.NewBuiltInProvider(name)] = factory } - for provider, reattach := range m.UnmanagedProviders { - factories[provider] = unmanagedProviderFactory(provider, reattach) - } - for provider, cached := range selected { + for provider, lock := range providerLocks { + reportError := func(thisErr error) { + err = multierror.Append(err, thisErr) + // We'll populate a provider factory that just echoes our error + // again if called, which allows us to still report a helpful + // error even if it gets detected downstream somewhere from the + // caller using our partial result. + factories[provider] = providerFactoryError(thisErr) + } + + version := lock.Version() + cached := cacheDir.ProviderVersion(provider, version) + if cached == nil { + reportError(fmt.Errorf( + "there is no package for %s %s cached in %s", + provider, version, cacheDir.BasePath(), + )) + continue + } + // The cached package must match one of the checksums recorded in + // the lock file, if any. + if allowedHashes := lock.PreferredHashes(); len(allowedHashes) != 0 { + matched, err := cached.MatchesAnyHash(allowedHashes) + if err != nil { + reportError(fmt.Errorf( + "failed to verify checksum of %s %s package cached in in %s: %s", + provider, version, cacheDir.BasePath(), err, + )) + continue + } + if !matched { + reportError(fmt.Errorf( + "the cached package for %s %s (in %s) does not match any of the checksums recorded in the dependency lock file", + provider, version, cacheDir.BasePath(), + )) + continue + } + } factories[provider] = providerFactory(cached) } - return factories, nil + for provider, reattach := range unmanagedProviders { + factories[provider] = unmanagedProviderFactory(provider, reattach) + } + return factories, err } func (m *Meta) internalProviders() map[string]providers.Factory { @@ -286,3 +339,13 @@ func unmanagedProviderFactory(provider addrs.Provider, reattach *plugin.Reattach return p, nil } } + +// providerFactoryError is a stub providers.Factory that returns an error +// when called. It's used to allow providerFactories to still produce a +// factory for each available provider in an error case, for situations +// where the caller can do something useful with that partial result. +func providerFactoryError(err error) providers.Factory { + return func() (providers.Interface, error) { + return nil, err + } +} diff --git a/command/version.go b/command/version.go index ea73870ef..b20cc4c6a 100644 --- a/command/version.go +++ b/command/version.go @@ -6,6 +6,9 @@ import ( "fmt" "sort" "strings" + + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/internal/depsfile" ) // VersionCommand is a Command implementation prints the version. @@ -80,29 +83,28 @@ func (c *VersionCommand) Run(args []string) int { } } - // We'll also attempt to print out the selected plugin versions. We can - // do this only if "terraform init" was already run and thus we've committed - // to a specific set of plugins. If not, the plugins lock will be empty - // and so we'll show _no_ providers. + // We'll also attempt to print out the selected plugin versions. We do + // this based on the dependency lock file, and so the result might be + // empty or incomplete if the user hasn't successfully run "terraform init" + // since the most recent change to dependencies. // // Generally-speaking this is a best-effort thing that will give us a good // result in the usual case where the user successfully ran "terraform init" // and then hit a problem running _another_ command. - providerInstaller := c.providerInstaller() - providerSelections, err := providerInstaller.SelectedPackages() - var pluginVersions []string - if err != nil { - // we'll just ignore it and show no plugins at all, then. - providerSelections = nil - } - for providerAddr, cached := range providerSelections { - version := cached.Version.String() - if version == "0.0.0" { - pluginVersions = append(pluginVersions, fmt.Sprintf("+ provider %s (unversioned)", providerAddr)) - } else { - pluginVersions = append(pluginVersions, fmt.Sprintf("+ provider %s v%s", providerAddr, version)) + var providerVersions []string + var providerLocks map[addrs.Provider]*depsfile.ProviderLock + if locks, err := c.lockedDependencies(); err == nil { + providerLocks = locks.AllProviders() + for providerAddr, lock := range providerLocks { + version := lock.Version().String() + if version == "0.0.0" { + providerVersions = append(providerVersions, fmt.Sprintf("+ provider %s (unversioned)", providerAddr)) + } else { + providerVersions = append(providerVersions, fmt.Sprintf("+ provider %s v%s", providerAddr, version)) + } } } + // If we have a version check function, then let's check for // the latest version as well. if c.CheckFunc != nil { @@ -120,8 +122,8 @@ func (c *VersionCommand) Run(args []string) int { if jsonOutput { selectionsOutput := make(map[string]string) - for providerAddr, cached := range providerSelections { - version := cached.Version.String() + for providerAddr, lock := range providerLocks { + version := lock.Version().String() selectionsOutput[providerAddr.String()] = version } @@ -148,9 +150,9 @@ func (c *VersionCommand) Run(args []string) int { return 0 } else { c.Ui.Output(versionString.String()) - if len(pluginVersions) != 0 { - sort.Strings(pluginVersions) - for _, str := range pluginVersions { + if len(providerVersions) != 0 { + sort.Strings(providerVersions) + for _, str := range providerVersions { c.Ui.Output(str) } } diff --git a/command/version_test.go b/command/version_test.go index f4b0e52ea..3844dbd12 100644 --- a/command/version_test.go +++ b/command/version_test.go @@ -1,10 +1,15 @@ package command import ( + "io/ioutil" "os" "strings" "testing" + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/internal/depsfile" + "github.com/hashicorp/terraform/internal/getproviders" "github.com/mitchellh/cli" ) @@ -13,49 +18,49 @@ func TestVersionCommand_implements(t *testing.T) { } func TestVersion(t *testing.T) { - fixtureDir := "testdata/providers-schema/basic" - td := tempDir(t) - testCopyDir(t, fixtureDir, td) + td, err := ioutil.TempDir("", "terraform-test-version") + if err != nil { + t.Fatal(err) + } defer os.RemoveAll(td) defer testChdir(t, td)() - ui := new(cli.MockUi) + // We'll create a fixed dependency lock file in our working directory + // so we can verify that the version command shows the information + // from it. + locks := depsfile.NewLocks() + locks.SetProvider( + addrs.NewDefaultProvider("test2"), + getproviders.MustParseVersion("1.2.3"), + nil, + nil, + ) + locks.SetProvider( + addrs.NewDefaultProvider("test1"), + getproviders.MustParseVersion("7.8.9-beta.2"), + nil, + nil, + ) - providerSource, close := newMockProviderSource(t, map[string][]string{ - "test": []string{"1.2.3"}, - }) - defer close() - - m := Meta{ - testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, - ProviderSource: providerSource, - } - - // `terraform init` - ic := &InitCommand{ - Meta: m, - } - if code := ic.Run([]string{}); code != 0 { - t.Fatalf("init failed\n%s", ui.ErrorWriter) - } - // flush the init output from the mock ui - ui.OutputWriter.Reset() - - // `terraform version` + ui := cli.NewMockUi() c := &VersionCommand{ - Meta: m, + Meta: Meta{ + Ui: ui, + }, Version: "4.5.6", VersionPrerelease: "foo", } + if err := c.replaceLockedDependencies(locks); err != nil { + t.Fatal(err) + } if code := c.Run([]string{}); code != 0 { t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) } actual := strings.TrimSpace(ui.OutputWriter.String()) - expected := "Terraform v4.5.6-foo\n+ provider registry.terraform.io/hashicorp/test v1.2.3" + expected := "Terraform v4.5.6-foo\n+ provider registry.terraform.io/hashicorp/test1 v7.8.9-beta.2\n+ provider registry.terraform.io/hashicorp/test2 v1.2.3" if actual != expected { - t.Fatalf("wrong output\ngot: %#v\nwant: %#v", actual, expected) + t.Fatalf("wrong output\ngot:\n%s\nwant:\n%s", actual, expected) } } @@ -108,38 +113,21 @@ func TestVersion_outdated(t *testing.T) { } func TestVersion_json(t *testing.T) { - fixtureDir := "testdata/providers-schema/basic" - td := tempDir(t) - testCopyDir(t, fixtureDir, td) + td, err := ioutil.TempDir("", "terraform-test-version") + if err != nil { + t.Fatal(err) + } defer os.RemoveAll(td) defer testChdir(t, td)() - ui := new(cli.MockUi) - - providerSource, close := newMockProviderSource(t, map[string][]string{ - "test": []string{"1.2.3"}, - }) - defer close() - - m := Meta{ - testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, - ProviderSource: providerSource, + ui := cli.NewMockUi() + meta := Meta{ + Ui: ui, } - // `terraform init` - ic := &InitCommand{ - Meta: m, - } - if code := ic.Run([]string{}); code != 0 { - t.Fatalf("init failed\n%s", ui.ErrorWriter) - } - // flush the init output from the mock ui - ui.OutputWriter.Reset() - // `terraform version -json` without prerelease c := &VersionCommand{ - Meta: m, + Meta: meta, Version: "4.5.6", } if code := c.Run([]string{"-json"}); code != 0 { @@ -147,28 +135,65 @@ func TestVersion_json(t *testing.T) { } actual := strings.TrimSpace(ui.OutputWriter.String()) - expected := "{\n \"terraform_version\": \"4.5.6\",\n \"terraform_revision\": \"\",\n \"provider_selections\": {\n \"registry.terraform.io/hashicorp/test\": \"1.2.3\"\n },\n \"terraform_outdated\": false\n}" - if actual != expected { - t.Fatalf("wrong output\ngot: %#v\nwant: %#v", actual, expected) + expected := strings.TrimSpace(` +{ + "terraform_version": "4.5.6", + "terraform_revision": "", + "provider_selections": {}, + "terraform_outdated": false +} +`) + if diff := cmp.Diff(expected, actual); diff != "" { + t.Fatalf("wrong output\n%s", diff) } // flush the output from the mock ui ui.OutputWriter.Reset() - // `terraform version -json` with prerelease + // Now we'll create a fixed dependency lock file in our working directory + // so we can verify that the version command shows the information + // from it. + locks := depsfile.NewLocks() + locks.SetProvider( + addrs.NewDefaultProvider("test2"), + getproviders.MustParseVersion("1.2.3"), + nil, + nil, + ) + locks.SetProvider( + addrs.NewDefaultProvider("test1"), + getproviders.MustParseVersion("7.8.9-beta.2"), + nil, + nil, + ) + + // `terraform version -json` with prerelease and provider dependencies c = &VersionCommand{ - Meta: m, + Meta: meta, Version: "4.5.6", VersionPrerelease: "foo", } + if err := c.replaceLockedDependencies(locks); err != nil { + t.Fatal(err) + } if code := c.Run([]string{"-json"}); code != 0 { t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) } actual = strings.TrimSpace(ui.OutputWriter.String()) - expected = "{\n \"terraform_version\": \"4.5.6-foo\",\n \"terraform_revision\": \"\",\n \"provider_selections\": {\n \"registry.terraform.io/hashicorp/test\": \"1.2.3\"\n },\n \"terraform_outdated\": false\n}" - if actual != expected { - t.Fatalf("wrong output\ngot: %#v\nwant: %#v", actual, expected) + expected = strings.TrimSpace(` +{ + "terraform_version": "4.5.6-foo", + "terraform_revision": "", + "provider_selections": { + "registry.terraform.io/hashicorp/test1": "7.8.9-beta.2", + "registry.terraform.io/hashicorp/test2": "1.2.3" + }, + "terraform_outdated": false +} +`) + if diff := cmp.Diff(expected, actual); diff != "" { + t.Fatalf("wrong output\n%s", diff) } } diff --git a/internal/depsfile/locks.go b/internal/depsfile/locks.go index 401789766..a53994a6e 100644 --- a/internal/depsfile/locks.go +++ b/internal/depsfile/locks.go @@ -2,6 +2,7 @@ package depsfile import ( "fmt" + "sort" "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/internal/getproviders" @@ -46,6 +47,18 @@ func (l *Locks) Provider(addr addrs.Provider) *ProviderLock { return l.providers[addr] } +// AllProviders returns a map describing all of the provider locks in the +// receiver. +func (l *Locks) AllProviders() map[addrs.Provider]*ProviderLock { + // We return a copy of our internal map so that future calls to + // SetProvider won't modify the map we're returning, or vice-versa. + ret := make(map[addrs.Provider]*ProviderLock, len(l.providers)) + for k, v := range l.providers { + ret[k] = v + } + return ret +} + // SetProvider creates a new lock or replaces the existing lock for the given // provider. // @@ -53,6 +66,10 @@ func (l *Locks) Provider(addr addrs.Provider) *ProviderLock { // invalidates any ProviderLock object previously returned from Provider or // SetProvider for the given provider address. // +// The ownership of the backing array for the slice of hashes passes to this +// function, and so the caller must not read or write that backing array after +// calling SetProvider. +// // Only lockable providers can be passed to this method. If you pass a // non-lockable provider address then this function will panic. Use // function ProviderIsLockable to determine whether a particular provider @@ -62,14 +79,61 @@ func (l *Locks) SetProvider(addr addrs.Provider, version getproviders.Version, c panic(fmt.Sprintf("Locks.SetProvider with non-lockable provider %s", addr)) } - new := &ProviderLock{ + new := NewProviderLock(addr, version, constraints, hashes) + l.providers[new.addr] = new + return new +} + +// NewProviderLock creates a new ProviderLock object that isn't associated +// with any Locks object. +// +// This is here primarily for testing. Most callers should use Locks.SetProvider +// to construct a new provider lock and insert it into a Locks object at the +// same time. +// +// The ownership of the backing array for the slice of hashes passes to this +// function, and so the caller must not read or write that backing array after +// calling NewProviderLock. +// +// Only lockable providers can be passed to this method. If you pass a +// non-lockable provider address then this function will panic. Use +// function ProviderIsLockable to determine whether a particular provider +// should participate in the version locking mechanism. +func NewProviderLock(addr addrs.Provider, version getproviders.Version, constraints getproviders.VersionConstraints, hashes []getproviders.Hash) *ProviderLock { + if !ProviderIsLockable(addr) { + panic(fmt.Sprintf("Locks.NewProviderLock with non-lockable provider %s", addr)) + } + + // Normalize the hashes into lexical order so that we can do straightforward + // equality tests between different locks for the same provider. The + // hashes are logically a set, so the given order is insignificant. + sort.Slice(hashes, func(i, j int) bool { + return string(hashes[i]) < string(hashes[j]) + }) + + // This is a slightly-tricky in-place deduping to avoid unnecessarily + // allocating a new array in the common case where there are no duplicates: + // we iterate over "hashes" at the same time as appending to another slice + // with the same backing array, relying on the fact that deduping can only + // _skip_ elements from the input, and will never generate additional ones + // that would cause the writer to get ahead of the reader. This also + // assumes that we already sorted the items, which means that any duplicates + // will be consecutive in the sequence. + dedupeHashes := hashes[:0] + prevHash := getproviders.NilHash + for _, hash := range hashes { + if hash != prevHash { + dedupeHashes = append(dedupeHashes, hash) + prevHash = hash + } + } + + return &ProviderLock{ addr: addr, version: version, versionConstraints: constraints, - hashes: hashes, + hashes: dedupeHashes, } - l.providers[addr] = new - return new } // ProviderIsLockable returns true if the given provider is eligible for @@ -121,20 +185,14 @@ func (l *Locks) Equal(other *Locks) bool { } // Although "hashes" is declared as a slice, it's logically an - // unordered set and so we'll compare it as such. + // unordered set. However, we normalize the slice of hashes when + // recieving it in NewProviderLock, so we can just do a simple + // item-by-item equality test here. if len(thisLock.hashes) != len(otherLock.hashes) { return false } - found := make(map[getproviders.Hash]int, len(thisLock.hashes)) - for _, hash := range thisLock.hashes { - found[hash]++ - } - for _, hash := range otherLock.hashes { - found[hash]++ - } - for _, count := range found { - if count != 2 { - // It wasn't in both sets, then + for i := range thisLock.hashes { + if thisLock.hashes[i] != otherLock.hashes[i] { return false } } diff --git a/internal/depsfile/testing.go b/internal/depsfile/testing.go new file mode 100644 index 000000000..cf8965be8 --- /dev/null +++ b/internal/depsfile/testing.go @@ -0,0 +1,22 @@ +package depsfile + +import ( + "github.com/google/go-cmp/cmp" +) + +// ProviderLockComparer is an option for github.com/google/go-cmp/cmp that +// specifies how to compare values of type depsfile.ProviderLock. +// +// Use this, rather than crafting comparison options yourself, in case the +// comparison strategy needs to change in future due to implementation details +// of the ProviderLock type. +var ProviderLockComparer cmp.Option + +func init() { + // For now, direct comparison of the unexported fields is good enough + // because we store everything in a normalized form. If that changes + // later then we might need to write a custom transformer to a hidden + // type with exported fields, so we can retain the ability for cmp to + // still report differences deeply. + ProviderLockComparer = cmp.AllowUnexported(ProviderLock{}) +} diff --git a/internal/getproviders/hash.go b/internal/getproviders/hash.go index 4ada2514b..2a8adfced 100644 --- a/internal/getproviders/hash.go +++ b/internal/getproviders/hash.go @@ -425,6 +425,16 @@ func (m PackageMeta) MatchesHash(want Hash) (bool, error) { return PackageMatchesHash(m.Location, want) } +// MatchesAnyHash returns true if the package at the location associated with +// the receiver matches at least one of the given hashes, or false otherwise. +// +// If it cannot read from the given location, MatchesHash returns an error. +// Unlike the signular MatchesHash, MatchesAnyHash considers an unsupported +// hash format to be a successful non-match. +func (m PackageMeta) MatchesAnyHash(acceptable []Hash) (bool, error) { + return PackageMatchesAnyHash(m.Location, acceptable) +} + // HashV1 computes a hash of the contents of the package at the location // associated with the receiver using hash algorithm 1. // diff --git a/internal/providercache/cached_provider.go b/internal/providercache/cached_provider.go index 55b6965f2..359e22597 100644 --- a/internal/providercache/cached_provider.go +++ b/internal/providercache/cached_provider.go @@ -50,7 +50,7 @@ func (cp *CachedProvider) Hash() (getproviders.Hash, error) { // MatchesHash returns true if the package on disk matches the given hash, // or false otherwise. If it cannot traverse the package directory and read // all of the files in it, or if the hash is in an unsupported format, -// CheckHash returns an error. +// MatchesHash returns an error. // // MatchesHash may accept hashes in a number of different formats. Over time // the set of supported formats may grow and shrink. @@ -58,6 +58,16 @@ func (cp *CachedProvider) MatchesHash(want getproviders.Hash) (bool, error) { return getproviders.PackageMatchesHash(cp.PackageLocation(), want) } +// MatchesAnyHash returns true if the package on disk matches the given hash, +// or false otherwise. If it cannot traverse the package directory and read +// all of the files in it, MatchesAnyHash returns an error. +// +// Unlike the singular MatchesHash, MatchesAnyHash considers unsupported hash +// formats as successfully non-matching, rather than returning an error. +func (cp *CachedProvider) MatchesAnyHash(allowed []getproviders.Hash) (bool, error) { + return getproviders.PackageMatchesAnyHash(cp.PackageLocation(), allowed) +} + // HashV1 computes a hash of the contents of the package directory associated // with the receiving cached provider using hash algorithm 1. // diff --git a/internal/providercache/dir.go b/internal/providercache/dir.go index 576af7ef4..803c771ce 100644 --- a/internal/providercache/dir.go +++ b/internal/providercache/dir.go @@ -66,6 +66,12 @@ func NewDirWithPlatform(baseDir string, platform getproviders.Platform) *Dir { } } +// BasePath returns the filesystem path of the base directory of this +// cache directory. +func (d *Dir) BasePath() string { + return filepath.Clean(d.baseDir) +} + // AllAvailablePackages returns a description of all of the packages already // present in the directory. The cache entries are grouped by the provider // they relate to and then sorted by version precedence, with highest diff --git a/internal/providercache/dir_modify.go b/internal/providercache/dir_modify.go index 586269a77..5ac79ba4f 100644 --- a/internal/providercache/dir_modify.go +++ b/internal/providercache/dir_modify.go @@ -11,7 +11,12 @@ import ( // InstallPackage takes a metadata object describing a package available for // installation, retrieves that package, and installs it into the receiving // cache directory. -func (d *Dir) InstallPackage(ctx context.Context, meta getproviders.PackageMeta) (*getproviders.PackageAuthenticationResult, error) { +// +// If the allowedHashes set has non-zero length then at least one of the hashes +// in the set must match the package that "entry" refers to. If none of the +// hashes match then the returned error message assumes that the hashes came +// from a lock file. +func (d *Dir) InstallPackage(ctx context.Context, meta getproviders.PackageMeta, allowedHashes []getproviders.Hash) (*getproviders.PackageAuthenticationResult, error) { if meta.TargetPlatform != d.targetPlatform { return nil, fmt.Errorf("can't install %s package into cache directory expecting %s", meta.TargetPlatform, d.targetPlatform) } @@ -26,11 +31,11 @@ func (d *Dir) InstallPackage(ctx context.Context, meta getproviders.PackageMeta) log.Printf("[TRACE] providercache.Dir.InstallPackage: installing %s v%s from %s", meta.Provider, meta.Version, meta.Location) switch meta.Location.(type) { case getproviders.PackageHTTPURL: - return installFromHTTPURL(ctx, meta, newPath) + return installFromHTTPURL(ctx, meta, newPath, allowedHashes) case getproviders.PackageLocalArchive: - return installFromLocalArchive(ctx, meta, newPath) + return installFromLocalArchive(ctx, meta, newPath, allowedHashes) case getproviders.PackageLocalDir: - return installFromLocalDir(ctx, meta, newPath) + return installFromLocalDir(ctx, meta, newPath, allowedHashes) default: // Should not get here, because the above should be exhaustive for // all implementations of getproviders.Location. @@ -48,7 +53,26 @@ func (d *Dir) InstallPackage(ctx context.Context, meta getproviders.PackageMeta) // It's invalid to link a CachedProvider from a particular Dir into that same // Dir, because that would otherwise potentially replace a real package // directory with a circular link back to itself. -func (d *Dir) LinkFromOtherCache(entry *CachedProvider) error { +// +// If the allowedHashes set has non-zero length then at least one of the hashes +// in the set must match the package that "entry" refers to. If none of the +// hashes match then the returned error message assumes that the hashes came +// from a lock file. +func (d *Dir) LinkFromOtherCache(entry *CachedProvider, allowedHashes []getproviders.Hash) error { + if len(allowedHashes) > 0 { + if matches, err := entry.MatchesAnyHash(allowedHashes); err != nil { + return fmt.Errorf( + "failed to calculate checksum for cached copy of %s %s in %s: %s", + entry.Provider, entry.Version, d.baseDir, err, + ) + } else if !matches { + return fmt.Errorf( + "the provider cache at %s has a copy of %s %s that doesn't match any of the checksums recorded in the dependency lock file", + d.baseDir, entry.Provider, entry.Version, + ) + } + } + newPath := getproviders.UnpackedDirectoryPathForPackage( d.baseDir, entry.Provider, entry.Version, d.targetPlatform, ) @@ -76,6 +100,8 @@ func (d *Dir) LinkFromOtherCache(entry *CachedProvider) error { entry.Provider.Type, entry.Version, d.targetPlatform), Location: getproviders.PackageLocalDir(currentPath), } - _, err := installFromLocalDir(context.TODO(), meta, newPath) + // No further hash check here because we already checked the hash + // of the source directory above. + _, err := installFromLocalDir(context.TODO(), meta, newPath, nil) return err } diff --git a/internal/providercache/dir_modify_test.go b/internal/providercache/dir_modify_test.go index 14f1a20e7..0e39ffad9 100644 --- a/internal/providercache/dir_modify_test.go +++ b/internal/providercache/dir_modify_test.go @@ -46,7 +46,7 @@ func TestInstallPackage(t *testing.T) { Location: getproviders.PackageLocalArchive("testdata/terraform-provider-null_2.1.0_linux_amd64.zip"), } - result, err := tmpDir.InstallPackage(context.TODO(), meta) + result, err := tmpDir.InstallPackage(context.TODO(), meta, nil) if err != nil { t.Fatalf("InstallPackage failed: %s", err) } @@ -129,7 +129,7 @@ func TestLinkFromOtherCache(t *testing.T) { t.Fatalf("null provider has no latest version in source directory") } - err = tmpDir.LinkFromOtherCache(cacheEntry) + err = tmpDir.LinkFromOtherCache(cacheEntry, nil) if err != nil { t.Fatalf("LinkFromOtherCache failed: %s", err) } diff --git a/internal/providercache/installer.go b/internal/providercache/installer.go index 07af9b9e9..0cafea99d 100644 --- a/internal/providercache/installer.go +++ b/internal/providercache/installer.go @@ -3,7 +3,6 @@ package providercache import ( "context" "fmt" - "path/filepath" "sort" "strings" @@ -11,6 +10,7 @@ import ( "github.com/hashicorp/terraform/addrs" copydir "github.com/hashicorp/terraform/internal/copy" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/getproviders" ) @@ -129,24 +129,28 @@ func (i *Installer) SetUnmanagedProviderTypes(types map[addrs.Provider]struct{}) // 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) { +func (i *Installer) EnsureProviderVersions(ctx context.Context, locks *depsfile.Locks, reqs getproviders.Requirements, mode InstallMode) (*depsfile.Locks, error) { errs := map[addrs.Provider]error{} evts := installerEventsForContext(ctx) + // We'll work with a copy of the given locks, so we can modify it and + // return the updated locks without affecting the caller's object. + // We'll add or replace locks in here during our work so that the final + // locks file reflects what the installer has selected. + locks = locks.DeepCopy() + 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() + // source about. If we're in the normal (non-upgrade) mode then we'll + // just ask the source to confirm the continued existence of what + // was locked, or otherwise we'll find the newest version matching the + // configured version constraint. mightNeed := map[addrs.Provider]getproviders.VersionSet{} -MightNeedProvider: + locked := map[addrs.Provider]bool{} for provider, versionConstraints := range reqs { if provider.IsBuiltIn() { // Built in providers do not require installation but we'll still @@ -189,35 +193,35 @@ MightNeedProvider: 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) + if !mode.forceQueryAllProviders() { + // If we're not forcing potential changes of version then an + // existing selection from the lock file takes priority over + // the currently-configured version constraints. + if lock := locks.Provider(provider); lock != nil { + if !acceptableVersions.Has(lock.Version()) { + err := fmt.Errorf( + "locked provider %s %s does not match configured version constraint %s; must use terraform init -upgrade to allow selection of new versions", + provider, lock.Version(), getproviders.VersionConstraintsString(versionConstraints), + ) + errs[provider] = err + // This is a funny case where we're returning an error + // before we do any querying at all. To keep the event + // stream consistent without introducing an extra event + // type, we'll emit an artificial QueryPackagesBegin for + // this provider before we indicate that it failed using + // QueryPackagesFailure. + if cb := evts.QueryPackagesBegin; cb != nil { + cb(provider, versionConstraints) + } + if cb := evts.QueryPackagesFailure; cb != nil { + cb(provider, err) + } + continue } - continue MightNeedProvider + acceptableVersions = versions.Only(lock.Version()) + locked[provider] = true } } - // If we get here then we didn't find any cached version that is - // in our set of acceptable versions. mightNeed[provider] = acceptableVersions } @@ -267,7 +271,15 @@ 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])) + if locked[provider] { + // This situation should be a rare one: it suggests that a + // version was previously available but was yanked for some + // reason. + lock := locks.Provider(provider) + err = fmt.Errorf("the previously-selected version %s is no longer available", lock.Version()) + } else { + 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) @@ -286,6 +298,12 @@ NeedProvider: return nil, err } + lock := locks.Provider(provider) + var preferredHashes []getproviders.Hash + if lock != nil && lock.Version() == version { // hash changes are expected if the version is also changing + preferredHashes = lock.PreferredHashes() + } + if i.globalCacheDir != nil { // Step 3a: If our global cache already has this version available then // we'll just link it in. @@ -293,7 +311,16 @@ NeedProvider: if cb := evts.LinkFromCacheBegin; cb != nil { cb(provider, version, i.globalCacheDir.baseDir) } - err := i.targetDir.LinkFromOtherCache(cached) + if _, err := cached.ExecutableFile(); err != nil { + err := fmt.Errorf("provider binary not found: %s", err) + errs[provider] = err + if cb := evts.LinkFromCacheFailure; cb != nil { + cb(provider, version, err) + } + continue + } + + err := i.targetDir.LinkFromOtherCache(cached, preferredHashes) if err != nil { errs[provider] = err if cb := evts.LinkFromCacheFailure; cb != nil { @@ -306,12 +333,60 @@ NeedProvider: 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) + errs[provider] = err if cb := evts.LinkFromCacheFailure; cb != nil { cb(provider, version, err) } continue } - selected[provider] = version + + // The LinkFromOtherCache call above should've verified that + // the package matches one of the hashes previously recorded, + // if any. We'll now augment those hashes with one freshly + // calculated from the package we just linked, which allows + // the lock file to gradually transition to recording newer hash + // schemes when they become available. + var newHashes []getproviders.Hash + if lock != nil && lock.Version() == version { + // If the version we're installing is identical to the + // one we previously locked then we'll keep all of the + // hashes we saved previously and add to it. Otherwise + // we'll be starting fresh, because each version has its + // own set of packages and thus its own hashes. + newHashes = append(newHashes, preferredHashes...) + + // NOTE: The behavior here is unfortunate when a particular + // provider version was already cached on the first time + // the current configuration requested it, because that + // means we don't currently get the opportunity to fetch + // and verify the checksums for the new package from + // upstream. That's currently unavoidable because upstream + // checksums are in the "ziphash" format and so we can't + // verify them against our cache directory's unpacked + // packages: we'd need to go fetch the package from the + // origin and compare against it, which would defeat the + // purpose of the global cache. + // + // If we fetch from upstream on the first encounter with + // a particular provider then we'll end up in the other + // codepath below where we're able to also include the + // checksums from the origin registry. + } + newHash, err := cached.Hash() + if err != nil { + err := fmt.Errorf("after linking %s from provider cache at %s, failed to compute a checksum for it: %s", provider, i.globalCacheDir.baseDir, err) + errs[provider] = err + if cb := evts.LinkFromCacheFailure; cb != nil { + cb(provider, version, err) + } + continue + } + // The hashes slice gets deduplicated in the lock file + // implementation, so we don't worry about potentially + // creating a duplicate here. + newHashes = append(newHashes, newHash) + lock = locks.SetProvider(provider, version, reqs[provider], newHashes) + if cb := evts.LinkFromCacheSuccess; cb != nil { cb(provider, version, new.PackageDir) } @@ -350,7 +425,7 @@ NeedProvider: installTo = i.targetDir linkTo = nil // no linking needed } - authResult, err := installTo.InstallPackage(ctx, meta) + authResult, err := installTo.InstallPackage(ctx, meta, preferredHashes) 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. @@ -369,12 +444,22 @@ NeedProvider: } continue } + if _, err := new.ExecutableFile(); err != nil { + err := fmt.Errorf("provider binary not found: %s", err) + 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) + // We also don't do a hash check here because we already did that + // as part of the installTo.InstallPackage call above. + err := linkTo.LinkFromOtherCache(new, nil) if err != nil { errs[provider] = err if cb := evts.FetchPackageFailure; cb != nil { @@ -384,7 +469,50 @@ NeedProvider: } } authResults[provider] = authResult - selected[provider] = version + + // The InstallPackage call above should've verified that + // the package matches one of the hashes previously recorded, + // if any. We'll now augment those hashes with a new set populated + // with the hashes returned by the upstream source and from the + // package we've just installed, which allows the lock file to + // gradually transition to newer hash schemes when they become + // available. + // + // This is assuming that if a package matches both a hash we saw before + // _and_ a new hash then the new hash is a valid substitute for + // the previous hash. + // + // The hashes slice gets deduplicated in the lock file + // implementation, so we don't worry about potentially + // creating duplicates here. + var newHashes []getproviders.Hash + if lock != nil && lock.Version() == version { + // If the version we're installing is identical to the + // one we previously locked then we'll keep all of the + // hashes we saved previously and add to it. Otherwise + // we'll be starting fresh, because each version has its + // own set of packages and thus its own hashes. + newHashes = append(newHashes, preferredHashes...) + } + newHash, err := new.Hash() + if err != nil { + err := fmt.Errorf("after installing %s, failed to compute a checksum for it: %s", provider, err) + errs[provider] = err + if cb := evts.FetchPackageFailure; cb != nil { + cb(provider, version, err) + } + continue + } + newHashes = append(newHashes, newHash) + if authResult.SignedByAnyParty() { + // We'll trust new hashes from upstream only if they were verified + // as signed by a suitable key. Otherwise, we'd record only + // a new hash we just calculated ourselves from the bytes on disk, + // and so the hashes would cover only the current platform. + newHashes = append(newHashes, meta.AcceptableHashes()...) + } + lock = locks.SetProvider(provider, version, reqs[provider], newHashes) + if cb := evts.FetchPackageSuccess; cb != nil { cb(provider, version, new.PackageDir, authResult) } @@ -395,115 +523,12 @@ NeedProvider: cb(authResults) } - // 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 - } - if _, err := cached.ExecutableFile(); err != nil { - err := fmt.Errorf("provider binary not found: %s", err) - 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.String(), - } - } - 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{ + return locks, 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 - } - - hash, err := getproviders.ParseHash(entry.PackageHash) - if err != nil { - errs[provider] = fmt.Errorf("local cache for %s has invalid hash %q: %s", provider, entry.PackageHash, err) - continue - } - - ok, err := cached.MatchesHash(hash) - 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 + return locks, nil } // InstallMode customizes the details of how an install operation treats diff --git a/internal/providercache/installer_test.go b/internal/providercache/installer_test.go index 22b20962d..9e338fa74 100644 --- a/internal/providercache/installer_test.go +++ b/internal/providercache/installer_test.go @@ -15,6 +15,7 @@ import ( svchost "github.com/hashicorp/terraform-svchost" "github.com/hashicorp/terraform-svchost/disco" "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/getproviders" ) @@ -35,33 +36,33 @@ func TestEnsureProviderVersions_local_source(t *testing.T) { installer := NewInstaller(dir, source) tests := map[string]struct { - provider string - version string - installed bool - err string + provider string + version string + wantHash getproviders.Hash // getproviders.NilHash if not expected to be installed + err string }{ "install-unpacked": { - provider: "null", - version: "2.0.0", - installed: true, + provider: "null", + version: "2.0.0", + wantHash: getproviders.HashScheme1.New("qjsREM4DqEWECD43FcPqddZ9oxCG+IaMTxvWPciS05g="), }, "invalid-zip-file": { - provider: "null", - version: "2.1.0", - installed: false, - err: "zip: not a valid zip file", + provider: "null", + version: "2.1.0", + wantHash: getproviders.NilHash, + err: "zip: not a valid zip file", }, "version-constraint-unmet": { - provider: "null", - version: "2.2.0", - installed: false, - err: "no available releases match the given constraints 2.2.0", + provider: "null", + version: "2.2.0", + wantHash: getproviders.NilHash, + err: "no available releases match the given constraints 2.2.0", }, "missing-executable": { - provider: "missing/executable", - version: "2.0.0", - installed: true, - err: "provider binary not found: could not find executable file starting with terraform-provider-executable", + provider: "missing/executable", + version: "2.0.0", + wantHash: getproviders.NilHash, // installation fails for a provider with no executable + err: "provider binary not found: could not find executable file starting with terraform-provider-executable", }, } @@ -75,14 +76,24 @@ func TestEnsureProviderVersions_local_source(t *testing.T) { reqs := getproviders.Requirements{ provider: versionConstraint, } - wantSelected := getproviders.Selections{provider: version} - if !test.installed { - wantSelected = getproviders.Selections{} + + newLocks, err := installer.EnsureProviderVersions(ctx, depsfile.NewLocks(), reqs, InstallNewProvidersOnly) + gotProviderlocks := newLocks.AllProviders() + wantProviderLocks := map[addrs.Provider]*depsfile.ProviderLock{ + provider: depsfile.NewProviderLock( + provider, + version, + getproviders.MustParseVersionConstraints("= 2.0.0"), + []getproviders.Hash{ + test.wantHash, + }, + ), + } + if test.wantHash == getproviders.NilHash { + wantProviderLocks = map[addrs.Provider]*depsfile.ProviderLock{} } - selected, err := installer.EnsureProviderVersions(ctx, reqs, InstallNewProvidersOnly) - - if diff := cmp.Diff(wantSelected, selected); diff != "" { + if diff := cmp.Diff(wantProviderLocks, gotProviderlocks, depsfile.ProviderLockComparer); diff != "" { t.Errorf("wrong selected\n%s", diff) } @@ -158,7 +169,7 @@ func TestEnsureProviderVersions_protocol_errors(t *testing.T) { test.provider: test.inputVersion, } ctx := context.TODO() - _, err := installer.EnsureProviderVersions(ctx, reqs, InstallNewProvidersOnly) + _, err := installer.EnsureProviderVersions(ctx, depsfile.NewLocks(), reqs, InstallNewProvidersOnly) switch err := err.(type) { case nil: diff --git a/internal/providercache/lock_file.go b/internal/providercache/lock_file.go deleted file mode 100644 index de8c8e4df..000000000 --- a/internal/providercache/lock_file.go +++ /dev/null @@ -1,114 +0,0 @@ -package providercache - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "os" - "path/filepath" - - "github.com/hashicorp/terraform/addrs" - "github.com/hashicorp/terraform/internal/getproviders" -) - -// lockFile represents a file on disk that captures selected versions and -// their associated package checksums resulting from an install process, so -// that later consumers of that install process can be sure they are reading -// an identical set of providers to what the install process intended. -// -// This is an internal type used to encapsulate the reading, parsing, -// serializing, and writing of lock files. Its public interface is via methods -// on type Installer. -type lockFile struct { - filename string -} - -// LockFileEntry represents an entry for a specific provider in a LockFile. -type lockFileEntry struct { - SelectedVersion getproviders.Version - PackageHash string -} - -var _ json.Marshaler = (*lockFileEntry)(nil) -var _ json.Unmarshaler = (*lockFileEntry)(nil) - -// Read returns the current locks captured in the lock file. -// -// If the file does not exist, the result is successful but empty to indicate -// that no providers at all are available for use. -func (lf *lockFile) Read() (map[addrs.Provider]lockFileEntry, error) { - buf, err := ioutil.ReadFile(lf.filename) - if err != nil { - if os.IsNotExist(err) { - return nil, nil // no file means no locks yet - } - return nil, err - } - - var rawEntries map[string]*lockFileEntry - err = json.Unmarshal(buf, &rawEntries) - if err != nil { - return nil, fmt.Errorf("error parsing %s: %s", lf.filename, err) - } - - ret := make(map[addrs.Provider]lockFileEntry, len(rawEntries)) - for providerStr, entry := range rawEntries { - provider, diags := addrs.ParseProviderSourceString(providerStr) - if diags.HasErrors() { - // This file is both generated and consumed by Terraform, so we - // don't use super-detailed error messages for problems in it. - // If we get here without someone tampering with the file then - // it's presumably a bug in either our serializer or our parser. - return nil, fmt.Errorf("error parsing %s: invalid provider address %q", lf.filename, providerStr) - } - ret[provider] = *entry - } - - return ret, nil -} - -// Write stores a new set of entries in the lock file, disarding any -// selections previously stored there. -func (lf *lockFile) Write(new map[addrs.Provider]lockFileEntry) error { - toStore := make(map[string]*lockFileEntry, len(new)) - for provider := range new { - entry := new[provider] // so that each reference below is to a different object - toStore[provider.String()] = &entry - } - - buf, err := json.MarshalIndent(toStore, "", " ") - if err != nil { - return fmt.Errorf("error writing %s: %s", lf.filename, err) - } - - os.MkdirAll( - filepath.Dir(lf.filename), 0775, - ) // ignore error since WriteFile below will generate a better one anyway - return ioutil.WriteFile(lf.filename, buf, 0664) -} - -func (lfe *lockFileEntry) UnmarshalJSON(src []byte) error { - type Raw struct { - VersionStr string `json:"version"` - Hash string `json:"hash"` - } - var raw Raw - err := json.Unmarshal(src, &raw) - if err != nil { - return err - } - version, err := getproviders.ParseVersion(raw.VersionStr) - if err != nil { - return fmt.Errorf("invalid version number: %s", err) - } - lfe.SelectedVersion = version - lfe.PackageHash = raw.Hash - return nil -} - -func (lfe *lockFileEntry) MarshalJSON() ([]byte, error) { - return json.Marshal(map[string]interface{}{ - "version": lfe.SelectedVersion.String(), - "hash": lfe.PackageHash, - }) -} diff --git a/internal/providercache/package_install.go b/internal/providercache/package_install.go index 8384303fb..ca8a3073d 100644 --- a/internal/providercache/package_install.go +++ b/internal/providercache/package_install.go @@ -23,7 +23,7 @@ import ( // specific protocol and set of expectations.) var unzip = getter.ZipDecompressor{} -func installFromHTTPURL(ctx context.Context, meta getproviders.PackageMeta, targetDir string) (*getproviders.PackageAuthenticationResult, error) { +func installFromHTTPURL(ctx context.Context, meta getproviders.PackageMeta, targetDir string, allowedHashes []getproviders.Hash) (*getproviders.PackageAuthenticationResult, error) { url := meta.Location.String() // When we're installing from an HTTP URL we expect the URL to refer to @@ -83,7 +83,9 @@ func installFromHTTPURL(ctx context.Context, meta getproviders.PackageMeta, targ // We can now delegate to installFromLocalArchive for extraction. To do so, // we construct a new package meta description using the local archive - // path as the location, and skipping authentication. + // path as the location, and skipping authentication. installFromLocalMeta + // is responsible for verifying that the archive matches the allowedHashes, + // though. localMeta := getproviders.PackageMeta{ Provider: meta.Provider, Version: meta.Version, @@ -93,13 +95,13 @@ func installFromHTTPURL(ctx context.Context, meta getproviders.PackageMeta, targ Location: localLocation, Authentication: nil, } - if _, err := installFromLocalArchive(ctx, localMeta, targetDir); err != nil { + if _, err := installFromLocalArchive(ctx, localMeta, targetDir, allowedHashes); err != nil { return nil, err } return authResult, nil } -func installFromLocalArchive(ctx context.Context, meta getproviders.PackageMeta, targetDir string) (*getproviders.PackageAuthenticationResult, error) { +func installFromLocalArchive(ctx context.Context, meta getproviders.PackageMeta, targetDir string, allowedHashes []getproviders.Hash) (*getproviders.PackageAuthenticationResult, error) { var authResult *getproviders.PackageAuthenticationResult if meta.Authentication != nil { var err error @@ -107,6 +109,21 @@ func installFromLocalArchive(ctx context.Context, meta getproviders.PackageMeta, return nil, err } } + + if len(allowedHashes) > 0 { + if matches, err := meta.MatchesAnyHash(allowedHashes); err != nil { + return authResult, fmt.Errorf( + "failed to calculate checksum for %s %s package at %s: %s", + meta.Provider, meta.Version, meta.Location, err, + ) + } else if !matches { + return authResult, fmt.Errorf( + "the current package for %s %s doesn't match any of the checksums previously recorded in the dependency lock file", + meta.Provider, meta.Version, + ) + } + } + filename := meta.Location.String() err := unzip.Decompress(targetDir, filename, true) @@ -121,7 +138,7 @@ func installFromLocalArchive(ctx context.Context, meta getproviders.PackageMeta, // a local directory source _and_ of linking a package from another cache // in LinkFromOtherCache, because they both do fundamentally the same // operation: symlink if possible, or deep-copy otherwise. -func installFromLocalDir(ctx context.Context, meta getproviders.PackageMeta, targetDir string) (*getproviders.PackageAuthenticationResult, error) { +func installFromLocalDir(ctx context.Context, meta getproviders.PackageMeta, targetDir string, allowedHashes []getproviders.Hash) (*getproviders.PackageAuthenticationResult, error) { sourceDir := meta.Location.String() absNew, err := filepath.Abs(targetDir) diff --git a/tools/terraform-bundle/package.go b/tools/terraform-bundle/package.go index 3a6de2b7b..213059961 100644 --- a/tools/terraform-bundle/package.go +++ b/tools/terraform-bundle/package.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-svchost/disco" "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/httpclient" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/getproviders" "github.com/hashicorp/terraform/internal/providercache" discovery "github.com/hashicorp/terraform/plugin/discovery" @@ -378,7 +379,7 @@ func (c *PackageCommand) ensureProviderVersions(installer *providercache.Install ProviderAlreadyInstalled: func(provider addrs.Provider, selectedVersion getproviders.Version) { c.ui.Info(fmt.Sprintf("- Using previously-installed %s v%s", provider.ForDisplay(), selectedVersion)) }, - QueryPackagesBegin: func(provider addrs.Provider, versionConstraints getproviders.VersionConstraints) { + QueryPackagesBegin: func(provider addrs.Provider, versionConstraints getproviders.VersionConstraints, locked bool) { if len(versionConstraints) > 0 { c.ui.Info(fmt.Sprintf("- Finding %s versions matching %q...", provider.ForDisplay(), getproviders.VersionConstraintsString(versionConstraints))) } else { @@ -405,7 +406,13 @@ func (c *PackageCommand) ensureProviderVersions(installer *providercache.Install return err } req[provider] = cstr - _, err = installer.EnsureProviderVersions(ctx, req, mode) + + // We always start with no locks here, because we want to take + // the newest version matching the given version constraint, and + // never consider anything that might've been selected before. + locks := depsfile.NewLocks() + + _, err = installer.EnsureProviderVersions(ctx, locks, req, mode) if err != nil { return err }