terraform: Compare locks and provider requirements

When building a context, we read the dependency locks and ensure that
the provider requirements from the configuration can be satisfied.
If the configured requirements change such that the locks need to be
updated, we explain this and recommend running "terraform init".

This check is ignored for any providers which are locally marked as in
development. This includes unmanaged providers and those listed in the
provider installation `dev_overrides` block.
This commit is contained in:
Alisdair McDiarmid 2020-10-29 16:39:38 -04:00
parent e38e8e2e61
commit 10cc25fc21
3 changed files with 255 additions and 0 deletions

View File

@ -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()

View File

@ -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

View File

@ -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()