diff --git a/command/meta.go b/command/meta.go index 2eb2ba895d..d4f008786b 100644 --- a/command/meta.go +++ b/command/meta.go @@ -421,6 +421,28 @@ func (m *Meta) contextOpts() (*terraform.ContextOpts, error) { } opts.Providers = providerFactories opts.Provisioners = m.provisionerFactories() + + // Read the dependency locks so that they can be verified against the + // provider requirements in the configuration + lockedDependencies, diags := m.lockedDependencies() + + // If the locks file is invalid, we should fail early rather than + // ignore it. A missing locks file will return no error. + if diags.HasErrors() { + return nil, diags.Err() + } + opts.LockedDependencies = lockedDependencies + + // If any unmanaged providers or dev overrides are enabled, they must + // be listed in the context so that they can be ignored when verifying + // the locks against the configuration + opts.ProvidersInDevelopment = make(map[addrs.Provider]struct{}) + for provider := range m.UnmanagedProviders { + opts.ProvidersInDevelopment[provider] = struct{}{} + } + for provider := range m.ProviderDevOverrides { + opts.ProvidersInDevelopment[provider] = struct{}{} + } } opts.ProviderSHA256s = m.providerPluginsLock().Read() diff --git a/terraform/context.go b/terraform/context.go index e74a72ae47..0cefbec472 100644 --- a/terraform/context.go +++ b/terraform/context.go @@ -5,8 +5,10 @@ import ( "context" "fmt" "log" + "strings" "sync" + "github.com/apparentlymart/go-versions/versions" "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/configs" "github.com/hashicorp/terraform/instances" @@ -19,6 +21,8 @@ import ( "github.com/hashicorp/terraform/tfdiags" "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/terraform/internal/depsfile" + "github.com/hashicorp/terraform/internal/getproviders" _ "github.com/hashicorp/terraform/internal/logging" ) @@ -67,6 +71,14 @@ type ContextOpts struct { // plugins that will be requested from the provider resolver. ProviderSHA256s map[string][]byte + // If non-nil, will be verified to ensure that provider requirements from + // configuration can be satisfied by the set of locked dependencies. + LockedDependencies *depsfile.Locks + + // Set of providers to exclude from the requirements check process, as they + // are marked as in local development. + ProvidersInDevelopment map[addrs.Provider]struct{} + UIInput UIInput } @@ -212,6 +224,50 @@ func NewContext(opts *ContextOpts) (*Context, tfdiags.Diagnostics) { config = configs.NewEmptyConfig() } + // If we have a configuration and a set of locked dependencies, verify that + // the provider requirements from the configuration can be satisfied by the + // locked dependencies. + if opts.LockedDependencies != nil { + reqs, providerDiags := config.ProviderRequirements() + diags = diags.Append(providerDiags) + + locked := opts.LockedDependencies.AllProviders() + unmetReqs := make(getproviders.Requirements) + for provider, versionConstraints := range reqs { + // Builtin providers are not listed in the locks file + if provider.IsBuiltIn() { + continue + } + // Development providers must be excluded from this check + if _, ok := opts.ProvidersInDevelopment[provider]; ok { + continue + } + // If the required provider doesn't exist in the lock, or the + // locked version doesn't meet the constraints, mark the + // requirement unmet + acceptable := versions.MeetingConstraints(versionConstraints) + if lock, ok := locked[provider]; !ok || !acceptable.Has(lock.Version()) { + unmetReqs[provider] = versionConstraints + } + } + + if len(unmetReqs) > 0 { + var buf strings.Builder + for provider, versionConstraints := range unmetReqs { + fmt.Fprintf(&buf, "\n- %s", provider) + if len(versionConstraints) > 0 { + fmt.Fprintf(&buf, " (%s)", getproviders.VersionConstraintsString(versionConstraints)) + } + } + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Provider requirements cannot be satisfied by locked dependencies", + fmt.Sprintf("The following required providers are not installed:\n%s\n\nPlease run \"terraform init\".", buf.String()), + )) + return nil, diags + } + } + log.Printf("[TRACE] terraform.NewContext: complete") // By the time we get here, we should have values defined for all of diff --git a/terraform/context_test.go b/terraform/context_test.go index dde3ec5e99..ae9537a16f 100644 --- a/terraform/context_test.go +++ b/terraform/context_test.go @@ -15,10 +15,12 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/configs" "github.com/hashicorp/terraform/configs/configload" "github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/configs/hcl2shim" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans/planfile" "github.com/hashicorp/terraform/providers" @@ -117,6 +119,181 @@ func TestNewContextRequiredVersion(t *testing.T) { } } +func TestNewContext_lockedDependencies(t *testing.T) { + configBeepGreaterThanOne := ` +terraform { + required_providers { + beep = { + source = "example.com/foo/beep" + version = ">= 1.0.0" + } + } +} +` + configBeepLessThanOne := ` +terraform { + required_providers { + beep = { + source = "example.com/foo/beep" + version = "< 1.0.0" + } + } +} +` + configBuiltin := ` +terraform { + required_providers { + terraform = { + source = "terraform.io/builtin/terraform" + } + } +} +` + locksBeepGreaterThanOne := ` +provider "example.com/foo/beep" { + version = "1.0.0" + constraints = ">= 1.0.0" + hashes = [ + "h1:does-not-match", + ] +} +` + configBeepBoop := ` +terraform { + required_providers { + beep = { + source = "example.com/foo/beep" + version = "< 1.0.0" # different from locks + } + boop = { + source = "example.com/foo/boop" + version = ">= 2.0.0" + } + } +} +` + locksBeepBoop := ` +provider "example.com/foo/beep" { + version = "1.0.0" + constraints = ">= 1.0.0" + hashes = [ + "h1:does-not-match", + ] +} +provider "example.com/foo/boop" { + version = "2.3.4" + constraints = ">= 2.0.0" + hashes = [ + "h1:does-not-match", + ] +} +` + beepAddr := addrs.MustParseProviderSourceString("example.com/foo/beep") + boopAddr := addrs.MustParseProviderSourceString("example.com/foo/boop") + + testCases := map[string]struct { + Config string + LockFile string + DevProviders []addrs.Provider + WantErr string + }{ + "dependencies met": { + Config: configBeepGreaterThanOne, + LockFile: locksBeepGreaterThanOne, + }, + "no locks given": { + Config: configBeepGreaterThanOne, + }, + "builtin provider with empty locks": { + Config: configBuiltin, + LockFile: `# This file is maintained automatically by "terraform init".`, + }, + "multiple providers, one in development": { + Config: configBeepBoop, + LockFile: locksBeepBoop, + DevProviders: []addrs.Provider{beepAddr}, + }, + "development provider with empty locks": { + Config: configBeepGreaterThanOne, + LockFile: `# This file is maintained automatically by "terraform init".`, + DevProviders: []addrs.Provider{beepAddr}, + }, + "multiple providers, one in development, one missing": { + Config: configBeepBoop, + LockFile: locksBeepGreaterThanOne, + DevProviders: []addrs.Provider{beepAddr}, + WantErr: `Provider requirements cannot be satisfied by locked dependencies: The following required providers are not installed: + +- example.com/foo/boop (>= 2.0.0) + +Please run "terraform init".`, + }, + "wrong provider version": { + Config: configBeepLessThanOne, + LockFile: locksBeepGreaterThanOne, + WantErr: `Provider requirements cannot be satisfied by locked dependencies: The following required providers are not installed: + +- example.com/foo/beep (< 1.0.0) + +Please run "terraform init".`, + }, + "empty locks": { + Config: configBeepGreaterThanOne, + LockFile: `# This file is maintained automatically by "terraform init".`, + WantErr: `Provider requirements cannot be satisfied by locked dependencies: The following required providers are not installed: + +- example.com/foo/beep (>= 1.0.0) + +Please run "terraform init".`, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + var locks *depsfile.Locks + if tc.LockFile != "" { + var diags tfdiags.Diagnostics + locks, diags = depsfile.LoadLocksFromBytes([]byte(tc.LockFile), "test.lock.hcl") + if len(diags) > 0 { + t.Fatalf("unexpected error loading locks file: %s", diags.Err()) + } + } + devProviders := make(map[addrs.Provider]struct{}) + for _, provider := range tc.DevProviders { + devProviders[provider] = struct{}{} + } + opts := &ContextOpts{ + Config: testModuleInline(t, map[string]string{ + "main.tf": tc.Config, + }), + LockedDependencies: locks, + ProvidersInDevelopment: devProviders, + Providers: map[addrs.Provider]providers.Factory{ + beepAddr: testProviderFuncFixed(testProvider("beep")), + boopAddr: testProviderFuncFixed(testProvider("boop")), + addrs.NewBuiltInProvider("terraform"): testProviderFuncFixed(testProvider("terraform")), + }, + } + + ctx, diags := NewContext(opts) + if tc.WantErr != "" { + if len(diags) == 0 { + t.Fatal("expected diags but none returned") + } + if got, want := diags.Err().Error(), tc.WantErr; got != want { + t.Errorf("wrong diags\n got: %s\nwant: %s", got, want) + } + } else { + if len(diags) > 0 { + t.Errorf("unexpected diags: %s", diags.Err()) + } + if ctx == nil { + t.Error("ctx is nil") + } + } + }) + } +} + func testContext2(t *testing.T, opts *ContextOpts) *Context { t.Helper()