cliconfig: Allow forcing use of the plugin cache despite the lock file

Currently Terraform will use an entry from the global plugin cache only if
it matches a checksum already recorded in the dependency lock file. This
allows Terraform to produce a complete lock file entry on the first
encounter with a new provider, whereas using the cache in that case would
cause the lock file to only cover the single package in the cache and
thereefore be unusable on any other operating system or CPU architecture.

This temporary CLI config option is a pragmatic exception to support those
who cannot currently correctly use the dependency lock file but who still
want to benefit from the plugin cache. With this setting enabled,
Terraform has permission to produce a dependency lock file that is only
suitable for the current system if that would allow use of an existing
entry in the plugin cache.

We are introducing this option to resolve a conflict between the needs of
folks who are using the dependency lock file as expected and the needs of
folks who cannot use the dependency lock file for some reason. The hope
then is to give respite to those who need this exception in the meantime
while we understand better why they cannot use the dependency lock file
and improve its design so that everyone will be able to use it
successfully in a future version of Terraform. This option will become a
silent no-op in a future version of Terraform, once the dependency lock
file behavior is sufficient for all supported Terraform development
workflows.
This commit is contained in:
Martin Atkins 2023-01-10 16:26:11 -08:00
parent 3cc7e55465
commit e2380b1038
7 changed files with 410 additions and 2 deletions

View File

@ -95,6 +95,8 @@ func initCommands(
CLIConfigDir: configDir,
PluginCacheDir: config.PluginCacheDir,
PluginCacheMayBreakDependencyLockFile: config.PluginCacheMayBreakDependencyLockFile,
ShutdownCh: makeShutdownCh(),
ProviderSource: providerSrc,

View File

@ -38,6 +38,17 @@ type Config struct {
// avoid repeatedly re-downloading over the Internet.
PluginCacheDir string `hcl:"plugin_cache_dir"`
// PluginCacheMayBreakDependencyLockFile is an interim accommodation for
// those who wish to use the Plugin Cache Dir even in cases where doing so
// will cause the dependency lock file to be incomplete.
//
// This is likely to become a silent no-op in future Terraform versions but
// is here in recognition of the fact that the dependency lock file is not
// yet a good fit for all Terraform workflows and folks in that category
// would prefer to have the plugin cache dir's behavior to take priority
// over the requirements of the dependency lock file.
PluginCacheMayBreakDependencyLockFile bool `hcl:"plugin_cache_may_break_dependency_lock_file"`
Hosts map[string]*ConfigHost `hcl:"host"`
Credentials map[string]map[string]interface{} `hcl:"credentials"`

View File

@ -103,6 +103,22 @@ type Meta struct {
// into the given directory.
PluginCacheDir string
// PluginCacheMayBreakDependencyLockFile is a temporary CLI configuration-based
// opt out for the behavior of only using the plugin cache dir if its
// contents match checksums recorded in the dependency lock file.
//
// This is an accommodation for those who currently essentially ignore the
// dependency lock file -- treating it only as transient working directory
// state -- and therefore don't care if the plugin cache dir causes the
// checksums inside to only be sufficient for the computer where Terraform
// is currently running.
//
// We intend to remove this exception again (making the CLI configuration
// setting a silent no-op) in future once we've improved the dependency
// lock file mechanism so that it's usable for everyone and there are no
// longer any compelling reasons for folks to not lock their dependencies.
PluginCacheMayBreakDependencyLockFile bool
// ProviderSource allows determining the available versions of a provider
// and determines where a distribution package for a particular
// provider version can be obtained.

View File

@ -63,6 +63,7 @@ func (m *Meta) providerInstallerCustomSource(source getproviders.Source) *provid
inst := providercache.NewInstaller(targetDir, source)
if globalCacheDir != nil {
inst.SetGlobalCacheDir(globalCacheDir)
inst.SetGlobalCacheDirMayBreakDependencyLockFile(m.PluginCacheMayBreakDependencyLockFile)
}
var builtinProviderTypes []string
for ty := range m.internalProviders() {

View File

@ -3,6 +3,7 @@ package providercache
import (
"context"
"fmt"
"log"
"sort"
"strings"
@ -36,6 +37,12 @@ type Installer struct {
// version between different configurations on the same system.
globalCacheDir *Dir
// globalCacheDirMayBreakDependencyLockFile allows a temporary exception to
// the rule that an entry in globalCacheDir can normally only be used if
// its validity is already confirmed by an entry in the dependency lock
// file.
globalCacheDirMayBreakDependencyLockFile bool
// 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
@ -103,6 +110,19 @@ func (i *Installer) SetGlobalCacheDir(cacheDir *Dir) {
i.globalCacheDir = cacheDir
}
// SetGlobalCacheDirMayBreakDependencyLockFile activates or deactivates our
// temporary exception to the rule that the global cache directory can be used
// only when entries are confirmed by existing entries in the dependency lock
// file.
//
// If this is set then if we install a provider for the first time from the
// cache then the dependency lock file will include only the checksum from
// the package in the global cache, which means the lock file won't be portable
// to Terraform running on another operating system or CPU architecture.
func (i *Installer) SetGlobalCacheDirMayBreakDependencyLockFile(mayBreak bool) {
i.globalCacheDirMayBreakDependencyLockFile = mayBreak
}
// HasGlobalCacheDir returns true if someone has previously called
// SetGlobalCacheDir to configure a global cache directory for this installer.
func (i *Installer) HasGlobalCacheDir() bool {
@ -375,6 +395,41 @@ NeedProvider:
}
}
if !acceptablePackage && i.globalCacheDirMayBreakDependencyLockFile {
// The "may break dependency lock file" setting effectively
// means that we'll accept any matching package that's
// already in the cache, regardless of whether it matches
// what's in the dependency lock file.
//
// That means two less-ideal situations might occur:
// - If this provider is not currently tracked in the lock
// file at all then after installation the lock file will
// only accept the package that was already present in
// the cache as a valid checksum. That means the generated
// lock file won't be portable to other operating systems
// or CPU architectures.
// - If the provider _is_ currently tracked in the lock file
// but the checksums there don't match what was in the
// cache then the LinkFromOtherCache call below will
// fail with a checksum error, and the user will need to
// either manually remove the entry from the lock file
// or remove the mismatching item from the cache,
// depending on which of these they prefer to use as the
// source of truth for the expected contents of the
// package.
//
// If the lock file already includes this provider and the
// cache entry matches one of the locked checksums then
// there's no problem, but in that case we wouldn't enter
// this branch because acceptablePackage would already be
// true from the check above.
log.Printf(
"[WARN] plugin_cache_may_break_dependency_lock_file: Using global cache dir package for %s v%s even though it doesn't match this configuration's dependency lock file",
provider.String(), version.String(),
)
acceptablePackage = true
}
// TODO: Should we emit an event through the events object
// for "there was an entry in the cache but we ignored it
// because the checksum didn't match"? We can't use

View File

@ -3,6 +3,7 @@ package providercache
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
@ -809,6 +810,281 @@ func TestEnsureProviderVersions(t *testing.T) {
}
},
},
"successful initial install of one provider through a warm global cache without a lock file entry but allowing the cache to break the lock file": {
Source: getproviders.NewMockSource(
[]getproviders.PackageMeta{
{
Provider: beepProvider,
Version: getproviders.MustParseVersion("2.0.0"),
TargetPlatform: fakePlatform,
Location: beepProviderDir,
},
{
Provider: beepProvider,
Version: getproviders.MustParseVersion("2.1.0"),
TargetPlatform: fakePlatform,
Location: beepProviderDir,
},
},
nil,
),
LockFile: `
# (intentionally empty)
`,
Prepare: func(t *testing.T, inst *Installer, dir *Dir) {
globalCacheDirPath := tmpDir(t)
globalCacheDir := NewDirWithPlatform(globalCacheDirPath, fakePlatform)
_, err := globalCacheDir.InstallPackage(
context.Background(),
getproviders.PackageMeta{
Provider: beepProvider,
Version: getproviders.MustParseVersion("2.1.0"),
TargetPlatform: fakePlatform,
Location: beepProviderDir,
},
nil,
)
if err != nil {
t.Fatalf("failed to populate global cache: %s", err)
}
inst.SetGlobalCacheDir(globalCacheDir)
inst.SetGlobalCacheDirMayBreakDependencyLockFile(true)
},
Mode: InstallNewProvidersOnly,
Reqs: getproviders.Requirements{
beepProvider: getproviders.MustParseVersionConstraints(">= 2.0.0"),
},
Check: func(t *testing.T, dir *Dir, locks *depsfile.Locks) {
if allCached := dir.AllAvailablePackages(); len(allCached) != 1 {
t.Errorf("wrong number of cache directory entries; want only one\n%s", spew.Sdump(allCached))
}
if allLocked := locks.AllProviders(); len(allLocked) != 1 {
t.Errorf("wrong number of provider lock entries; want only one\n%s", spew.Sdump(allLocked))
}
gotLock := locks.Provider(beepProvider)
wantLock := depsfile.NewProviderLock(
beepProvider,
getproviders.MustParseVersion("2.1.0"),
getproviders.MustParseVersionConstraints(">= 2.0.0"),
[]getproviders.Hash{beepProviderHash},
)
if diff := cmp.Diff(wantLock, gotLock, depsfile.ProviderLockComparer); diff != "" {
t.Errorf("wrong lock entry\n%s", diff)
}
gotEntry := dir.ProviderLatestVersion(beepProvider)
wantEntry := &CachedProvider{
Provider: beepProvider,
Version: getproviders.MustParseVersion("2.1.0"),
PackageDir: filepath.Join(dir.BasePath(), "example.com/foo/beep/2.1.0/bleep_bloop"),
}
if diff := cmp.Diff(wantEntry, gotEntry); diff != "" {
t.Errorf("wrong cache entry\n%s", diff)
}
},
WantEvents: func(inst *Installer, dir *Dir) map[addrs.Provider][]*testInstallerEventLogItem {
return map[addrs.Provider][]*testInstallerEventLogItem{
noProvider: {
{
Event: "PendingProviders",
Args: map[addrs.Provider]getproviders.VersionConstraints{
beepProvider: getproviders.MustParseVersionConstraints(">= 2.0.0"),
},
},
},
beepProvider: {
{
Event: "QueryPackagesBegin",
Provider: beepProvider,
Args: struct {
Constraints string
Locked bool
}{">= 2.0.0", false},
},
{
Event: "QueryPackagesSuccess",
Provider: beepProvider,
Args: "2.1.0",
},
{
Event: "LinkFromCacheBegin",
Provider: beepProvider,
Args: struct {
Version string
CacheRoot string
}{
"2.1.0",
inst.globalCacheDir.BasePath(),
},
},
{
Event: "ProvidersLockUpdated",
Provider: beepProvider,
Args: struct {
Version string
Local []getproviders.Hash
Signed []getproviders.Hash
Prior []getproviders.Hash
}{
"2.1.0",
[]getproviders.Hash{"h1:2y06Ykj0FRneZfGCTxI9wRTori8iB7ZL5kQ6YyEnh84="},
nil,
nil,
},
},
{
Event: "LinkFromCacheSuccess",
Provider: beepProvider,
Args: struct {
Version string
LocalDir string
}{
"2.1.0",
filepath.Join(dir.BasePath(), "/example.com/foo/beep/2.1.0/bleep_bloop"),
},
},
},
}
},
},
"failing install of one provider through a warm global cache with an incorrect locked checksum while allowing the cache to break the lock file": {
Source: getproviders.NewMockSource(
[]getproviders.PackageMeta{
{
Provider: beepProvider,
Version: getproviders.MustParseVersion("2.0.0"),
TargetPlatform: fakePlatform,
Location: beepProviderDir,
},
{
Provider: beepProvider,
Version: getproviders.MustParseVersion("2.1.0"),
TargetPlatform: fakePlatform,
Location: beepProviderDir,
},
},
nil,
),
LockFile: `
# The existing cache entry is valid only if it matches a
# checksum already recorded in the lock file, but this
# test is overriding that rule using a special setting.
provider "example.com/foo/beep" {
version = "2.1.0"
constraints = ">= 1.0.0"
hashes = [
"h1:wrong-not-matchy",
]
}
`,
Prepare: func(t *testing.T, inst *Installer, dir *Dir) {
globalCacheDirPath := tmpDir(t)
globalCacheDir := NewDirWithPlatform(globalCacheDirPath, fakePlatform)
_, err := globalCacheDir.InstallPackage(
context.Background(),
getproviders.PackageMeta{
Provider: beepProvider,
Version: getproviders.MustParseVersion("2.1.0"),
TargetPlatform: fakePlatform,
Location: beepProviderDir,
},
nil,
)
if err != nil {
t.Fatalf("failed to populate global cache: %s", err)
}
inst.SetGlobalCacheDir(globalCacheDir)
inst.SetGlobalCacheDirMayBreakDependencyLockFile(true)
},
Mode: InstallNewProvidersOnly,
Reqs: getproviders.Requirements{
beepProvider: getproviders.MustParseVersionConstraints(">= 2.0.0"),
},
Check: func(t *testing.T, dir *Dir, locks *depsfile.Locks) {
if allCached := dir.AllAvailablePackages(); len(allCached) != 0 {
t.Errorf("wrong number of cache directory entries; want none\n%s", spew.Sdump(allCached))
}
if allLocked := locks.AllProviders(); len(allLocked) != 1 {
t.Errorf("wrong number of provider lock entries; want only one\n%s", spew.Sdump(allLocked))
}
gotLock := locks.Provider(beepProvider)
wantLock := depsfile.NewProviderLock(
// The lock file entry hasn't changed because the cache
// entry didn't match the existing lock file entry.
beepProvider,
getproviders.MustParseVersion("2.1.0"),
getproviders.MustParseVersionConstraints(">= 1.0.0"),
[]getproviders.Hash{"h1:wrong-not-matchy"},
)
if diff := cmp.Diff(wantLock, gotLock, depsfile.ProviderLockComparer); diff != "" {
t.Errorf("wrong lock entry\n%s", diff)
}
// The provider wasn't installed into the local cache directory
// because that would make the local cache mismatch the
// lock file.
gotEntry := dir.ProviderLatestVersion(beepProvider)
wantEntry := (*CachedProvider)(nil)
if diff := cmp.Diff(wantEntry, gotEntry); diff != "" {
t.Errorf("wrong cache entry\n%s", diff)
}
},
WantErr: `doesn't match any of the checksums`,
WantEvents: func(inst *Installer, dir *Dir) map[addrs.Provider][]*testInstallerEventLogItem {
return map[addrs.Provider][]*testInstallerEventLogItem{
noProvider: {
{
Event: "PendingProviders",
Args: map[addrs.Provider]getproviders.VersionConstraints{
beepProvider: getproviders.MustParseVersionConstraints(">= 2.0.0"),
},
},
},
beepProvider: {
{
Event: "QueryPackagesBegin",
Provider: beepProvider,
Args: struct {
Constraints string
Locked bool
}{">= 2.0.0", true},
},
{
Event: "QueryPackagesSuccess",
Provider: beepProvider,
Args: "2.1.0",
},
{
Event: "LinkFromCacheBegin",
Provider: beepProvider,
Args: struct {
Version string
CacheRoot string
}{
"2.1.0",
inst.globalCacheDir.BasePath(),
},
},
{
Event: "LinkFromCacheFailure",
Provider: beepProvider,
Args: struct {
Version string
Error string
}{
"2.1.0",
fmt.Sprintf(
"the provider cache at %s has a copy of example.com/foo/beep 2.1.0 that doesn't match any of the checksums recorded in the dependency lock file",
dir.BasePath(),
),
},
},
},
}
},
},
"successful reinstall of one previously-locked provider": {
Source: getproviders.NewMockSource(
[]getproviders.PackageMeta{
@ -1972,8 +2248,8 @@ func TestEnsureProviderVersions(t *testing.T) {
if test.WantErr != "" {
if instErr == nil {
t.Errorf("succeeded; want error\nwant: %s", test.WantErr)
} else if got, want := instErr.Error(), test.WantErr; got != want {
t.Errorf("wrong error\ngot: %s\nwant: %s", got, want)
} else if got, want := instErr.Error(), test.WantErr; !strings.Contains(got, want) {
t.Errorf("wrong error\ngot: %s\nwant substring: %s", got, want)
}
} else if instErr != nil {
t.Errorf("unexpected error\ngot: %s", instErr.Error())

View File

@ -387,6 +387,53 @@ grow to contain several unused versions which you must delete manually.
safe. The provider installer's behavior in environments with multiple `terraform
init` calls is undefined.
## Allowing the Provider Plugin Cache to break the dependency lock file
~> **Note:** The option described in is for unusual and exceptional situations
only. Do not set this option unless you are sure you need it and you fully
understand the consequences of enabling it.
By default Terraform will use packages from the global cache directory only
if they match at least one of the checksums recorded in the
[dependency lock file](https://developer.hashicorp.com/terraform/language/files/dependency-lock)
for that provider. This ensures that Terraform can always
generate a complete and correct dependency lock file entry the first time you
use a new provider in a particular configuration.
However, we know that in some special situations teams have been unable to use
the dependency lock file as intended, and so they don't include it in their
version control as recommended and instead let Terraform re-generate it each
time it installs providers.
For those teams that don't preserve the dependency lock file in their version
control systems between runs, Terraform allows an additional CLI Configuration
setting which tells Terraform to always treat a package in the cache directory
as valid even if there isn't already an entry in the dependency lock file
to confirm it:
```hcl
plugin_cache_may_break_dependency_lock_file = true
```
Setting this option gives Terraform CLI permission to create an incomplete
dependency lock file entry for a provider if that would allow Terraform to
use the cache to install that provider. In that situation the dependency lock
file will be valid for use on the current system but may not be valid for use on
another computer with a different operating system or CPU architecture, because
it will include only a checksum of the package in the global cache.
We recommend that most users leave this option unset, in which case Terraform
will always install a provider from upstream the first time you use it with
a particular configuration, but can then re-use the cache entry on later runs
once the dependency lock file records valid checksums for the provider package.
~> **Note:** The Terraform team intends to improve the dependency lock file
mechanism in future versions so that it will be usable in more situations. At
that time this option will become silently ignored. If your workflow relies on
the use of this option, please open a GitHub issue to share details about your
situation so that we can consider how to support it without breaking the
dependency lock file.
### Development Overrides for Provider Developers
-> **Note:** Development overrides work only in Terraform v0.14 and later.