diff --git a/internal/command/init.go b/internal/command/init.go index aabd669983..48391d6fbf 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -232,7 +232,7 @@ func (c *InitCommand) Run(args []string) int { // With all of the modules (hopefully) installed, we can now try to load the // whole configuration tree. - config, confDiags := c.loadConfig(path) + config, confDiags := c.loadConfigWithTests(path, "tests") // configDiags will be handled after the version constraint check, since an // incorrect version of terraform may be producing errors for configuration // constructs added in later versions. diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 5ada9765cc..15ef100ccd 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -2741,6 +2741,49 @@ func TestInit_tests(t *testing.T) { } } +func TestInit_testsWithProvider(t *testing.T) { + // Create a temporary working directory that is empty + td := t.TempDir() + testCopyDir(t, testFixturePath("init-with-tests-with-provider"), td) + defer testChdir(t, td)() + + provider := applyFixtureProvider() // We just want the types from this provider. + + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + + ui := new(cli.MockUi) + view, _ := testView(t) + c := &InitCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(provider), + Ui: ui, + View: view, + ProviderSource: providerSource, + }, + } + + args := []string{} + if code := c.Run(args); code == 0 { + t.Fatalf("expected failure but got: \n%s", ui.OutputWriter.String()) + } + + got := ui.ErrorWriter.String() + want := ` +Error: Failed to query available provider packages + +Could not retrieve the list of available versions for provider +hashicorp/test: no available releases match the given constraints 1.0.1, +1.0.2 + +` + if diff := cmp.Diff(got, want); len(diff) > 0 { + t.Fatalf("wrong error message: \ngot:\n%s\nwant:\n%s\ndiff:\n%s", got, want, diff) + } +} + func TestInit_testsWithModule(t *testing.T) { // Create a temporary working directory that is empty td := t.TempDir() diff --git a/internal/command/meta_config.go b/internal/command/meta_config.go index 0b91eba29a..0124d3820e 100644 --- a/internal/command/meta_config.go +++ b/internal/command/meta_config.go @@ -51,6 +51,23 @@ func (m *Meta) loadConfig(rootDir string) (*configs.Config, tfdiags.Diagnostics) return config, diags } +// loadConfigWithTests matches loadConfig, except it also loads any test files +// into the config alongside the main configuration. +func (m *Meta) loadConfigWithTests(rootDir, testDir string) (*configs.Config, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + rootDir = m.normalizePath(rootDir) + + loader, err := m.initConfigLoader() + if err != nil { + diags = diags.Append(err) + return nil, diags + } + + config, hclDiags := loader.LoadConfigWithTests(rootDir, testDir) + diags = diags.Append(hclDiags) + return config, diags +} + // loadSingleModule reads configuration from the given directory and returns // a description of that module only, without attempting to assemble a module // tree for referenced child modules. diff --git a/internal/command/providers.go b/internal/command/providers.go index f631d9fbbe..77e282c827 100644 --- a/internal/command/providers.go +++ b/internal/command/providers.go @@ -6,6 +6,7 @@ package command import ( "fmt" "path/filepath" + "strings" "github.com/xlab/treeprint" @@ -69,7 +70,7 @@ func (c *ProvidersCommand) Run(args []string) int { return 1 } - config, configDiags := c.loadConfig(configPath) + config, configDiags := c.loadConfigWithTests(configPath, "tests") diags = diags.Append(configDiags) if configDiags.HasErrors() { c.showDiagnostics(diags) @@ -146,6 +147,24 @@ func (c *ProvidersCommand) populateTreeNode(tree treeprint.Tree, node *configs.M } tree.AddNode(fmt.Sprintf("provider[%s]%s", fqn.String(), versionsStr)) } + for name, testNode := range node.Tests { + name = strings.TrimSuffix(name, ".tftest") + name = strings.ReplaceAll(name, "/", ".") + branch := tree.AddBranch(fmt.Sprintf("test.%s", name)) + + for fqn, dep := range testNode.Requirements { + versionsStr := getproviders.VersionConstraintsString(dep) + if versionsStr != "" { + versionsStr = " " + versionsStr + } + branch.AddNode(fmt.Sprintf("provider[%s]%s", fqn.String(), versionsStr)) + } + + for _, run := range testNode.Runs { + branch := branch.AddBranch(fmt.Sprintf("run.%s", run.Name)) + c.populateTreeNode(branch, run) + } + } for name, childNode := range node.Children { branch := tree.AddBranch(fmt.Sprintf("module.%s", name)) c.populateTreeNode(branch, childNode) diff --git a/internal/command/providers_test.go b/internal/command/providers_test.go index 697966a689..364566deb3 100644 --- a/internal/command/providers_test.go +++ b/internal/command/providers_test.go @@ -166,3 +166,39 @@ func TestProviders_state(t *testing.T) { } } } + +func TestProviders_tests(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("err: %s", err) + } + if err := os.Chdir(testFixturePath("providers/tests")); err != nil { + t.Fatalf("err: %s", err) + } + defer os.Chdir(cwd) + + ui := new(cli.MockUi) + c := &ProvidersCommand{ + Meta: Meta{ + Ui: ui, + }, + } + + args := []string{} + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + wantOutput := []string{ + "provider[registry.terraform.io/hashicorp/foo]", + "test.main", + "provider[registry.terraform.io/hashicorp/bar]", + } + + output := ui.OutputWriter.String() + for _, want := range wantOutput { + if !strings.Contains(output, want) { + t.Errorf("output missing %s:\n%s", want, output) + } + } +} diff --git a/internal/command/test.go b/internal/command/test.go index 4e48ef3896..cde82a7792 100644 --- a/internal/command/test.go +++ b/internal/command/test.go @@ -11,7 +11,6 @@ import ( "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs" - "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/moduletest" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/states" @@ -21,8 +20,6 @@ import ( type TestCommand struct { Meta - - loader *configload.Loader } func (c *TestCommand) Help() string { @@ -60,15 +57,7 @@ func (c *TestCommand) Run(rawArgs []string) int { view := views.NewTest(arguments.ViewHuman, c.View) - loader, err := c.initConfigLoader() - diags = diags.Append(err) - if err != nil { - view.Diagnostics(nil, nil, diags) - return 1 - } - c.loader = loader - - config, configDiags := loader.LoadConfigWithTests(".", "tests") + config, configDiags := c.loadConfigWithTests(".", "tests") diags = diags.Append(configDiags) if configDiags.HasErrors() { view.Diagnostics(nil, nil, diags) @@ -161,13 +150,13 @@ func (c *TestCommand) ExecuteTestFile(ctx *terraform.Context, file *moduletest.F if run.Config.ConfigUnderTest != nil { // Then we want to execute a different module under a kind of // sandbox. - state := c.ExecuteTestRun(ctx, run, states.NewState(), run.Config.ConfigUnderTest, file.Config.Variables) + state := c.ExecuteTestRun(ctx, run, file, states.NewState(), run.Config.ConfigUnderTest) mgr.States = append(mgr.States, &TestModuleState{ State: state, Run: run, }) } else { - mgr.State = c.ExecuteTestRun(ctx, run, mgr.State, config, file.Config.Variables) + mgr.State = c.ExecuteTestRun(ctx, run, file, mgr.State, config) } file.Status = file.Status.Merge(run.Status) } @@ -178,7 +167,23 @@ func (c *TestCommand) ExecuteTestFile(ctx *terraform.Context, file *moduletest.F } } -func (c *TestCommand) ExecuteTestRun(ctx *terraform.Context, run *moduletest.Run, state *states.State, config *configs.Config, globals map[string]hcl.Expression) *states.State { +func (c *TestCommand) ExecuteTestRun(ctx *terraform.Context, run *moduletest.Run, file *moduletest.File, state *states.State, config *configs.Config) *states.State { + + // Since we don't want to modify the actual plan and apply operations for + // tests where possible, we insert provider blocks directly into the config + // under test for each test run. + // + // This function transforms the config under test by inserting relevant + // provider blocks. It returns a reset function which restores the config + // back to the original state. + cfgReset, cfgDiags := config.TransformForTest(run.Config, file.Config) + defer cfgReset() + run.Diagnostics = run.Diagnostics.Append(cfgDiags) + if cfgDiags.HasErrors() { + run.Status = moduletest.Error + return state + } + var targets []addrs.Targetable for _, target := range run.Config.Options.Target { addr, diags := addrs.ParseTarget(target) @@ -213,7 +218,7 @@ func (c *TestCommand) ExecuteTestRun(ctx *terraform.Context, run *moduletest.Run replaces = append(replaces, addr) } - variables, diags := c.GetInputValues(run.Config.Variables, globals, config) + variables, diags := c.GetInputValues(run.Config.Variables, file.Config.Variables, config) run.Diagnostics = run.Diagnostics.Append(diags) if diags.HasErrors() { run.Status = moduletest.Error @@ -305,12 +310,34 @@ func (c *TestCommand) cleanupState(ctx *terraform.Context, view views.Test, run return } - var locals map[string]hcl.Expression + var locals, globals map[string]hcl.Expression if run != nil { locals = run.Config.Variables } + if file != nil { + globals = file.Config.Variables + } + + var cfgDiags tfdiags.Diagnostics + if run == nil { + cfgReset, diags := config.TransformForTest(nil, file.Config) + defer cfgReset() + cfgDiags = cfgDiags.Append(diags) + } else { + cfgReset, diags := config.TransformForTest(run.Config, file.Config) + defer cfgReset() + cfgDiags = cfgDiags.Append(diags) + } + if cfgDiags.HasErrors() { + // This shouldn't really trigger, as we will have applied this transform + // earlier and it will have worked so a problem now would be strange. + // To be safe, we'll handle it anyway. + view.DestroySummary(cfgDiags, run, file, state) + return + } + c.View.Diagnostics(cfgDiags) - variables, variableDiags := c.GetInputValues(locals, file.Config.Variables, config) + variables, variableDiags := c.GetInputValues(locals, globals, config) if variableDiags.HasErrors() { // This shouldn't really trigger, as we will have created something // using these variables at an earlier stage so for them to have a diff --git a/internal/command/test_test.go b/internal/command/test_test.go index 10a3ed0488..ad4e3faad8 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -138,10 +138,6 @@ func TestTest(t *testing.T) { } func TestTest_ProviderAlias(t *testing.T) { - // TODO(liamcervante): Enable this test once we have added support for - // provider aliasing and customisation into the testing framework. - t.Skip() - td := t.TempDir() testCopyDir(t, testFixturePath(path.Join("test", "with_provider_alias")), td) defer testChdir(t, td)() diff --git a/internal/command/testdata/init-with-tests-with-provider/main.tf b/internal/command/testdata/init-with-tests-with-provider/main.tf new file mode 100644 index 0000000000..fe11fd160e --- /dev/null +++ b/internal/command/testdata/init-with-tests-with-provider/main.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.2" + } + } +} + +resource "test_instance" "foo" { + ami = "bar" +} diff --git a/internal/command/testdata/init-with-tests-with-provider/main.tftest b/internal/command/testdata/init-with-tests-with-provider/main.tftest new file mode 100644 index 0000000000..8b0776e1a7 --- /dev/null +++ b/internal/command/testdata/init-with-tests-with-provider/main.tftest @@ -0,0 +1,12 @@ +run "setup" { + module { + source = "./setup" + } +} + +run "test" { + assert { + condition = test_instance.foo.ami == "bar" + error_message = "incorrect value" + } +} diff --git a/internal/command/testdata/init-with-tests-with-provider/setup/main.tf b/internal/command/testdata/init-with-tests-with-provider/setup/main.tf new file mode 100644 index 0000000000..b0d3436f4d --- /dev/null +++ b/internal/command/testdata/init-with-tests-with-provider/setup/main.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.1" + } + } +} + +resource "test_instance" "baz" { + ami = "baz" +} diff --git a/internal/command/testdata/providers/tests/main.tf b/internal/command/testdata/providers/tests/main.tf new file mode 100644 index 0000000000..f2388a802f --- /dev/null +++ b/internal/command/testdata/providers/tests/main.tf @@ -0,0 +1,3 @@ +resource "bar_instance" "test" { + +} diff --git a/internal/command/testdata/providers/tests/main.tftest b/internal/command/testdata/providers/tests/main.tftest new file mode 100644 index 0000000000..08de6964a1 --- /dev/null +++ b/internal/command/testdata/providers/tests/main.tftest @@ -0,0 +1,3 @@ +provider "foo" { + +} diff --git a/internal/configs/config.go b/internal/configs/config.go index bae01bd370..023d298369 100644 --- a/internal/configs/config.go +++ b/internal/configs/config.go @@ -10,6 +10,7 @@ import ( version "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/getproviders" @@ -93,6 +94,14 @@ type ModuleRequirements struct { SourceDir string Requirements getproviders.Requirements Children map[string]*ModuleRequirements + Tests map[string]*TestFileModuleRequirements +} + +// TestFileModuleRequirements maps the runs for a given test file to the module +// requirements for that run block. +type TestFileModuleRequirements struct { + Requirements getproviders.Requirements + Runs map[string]*ModuleRequirements } // NewEmptyConfig constructs a single-node configuration tree with an empty @@ -292,7 +301,7 @@ func (c *Config) VerifyDependencySelections(depLocks *depsfile.Locks) []error { // may be incomplete. func (c *Config) ProviderRequirements() (getproviders.Requirements, hcl.Diagnostics) { reqs := make(getproviders.Requirements) - diags := c.addProviderRequirements(reqs, true) + diags := c.addProviderRequirements(reqs, true, true) return reqs, diags } @@ -304,7 +313,7 @@ func (c *Config) ProviderRequirements() (getproviders.Requirements, hcl.Diagnost // may be incomplete. func (c *Config) ProviderRequirementsShallow() (getproviders.Requirements, hcl.Diagnostics) { reqs := make(getproviders.Requirements) - diags := c.addProviderRequirements(reqs, false) + diags := c.addProviderRequirements(reqs, false, true) return reqs, diags } @@ -317,7 +326,7 @@ func (c *Config) ProviderRequirementsShallow() (getproviders.Requirements, hcl.D // may be incomplete. func (c *Config) ProviderRequirementsByModule() (*ModuleRequirements, hcl.Diagnostics) { reqs := make(getproviders.Requirements) - diags := c.addProviderRequirements(reqs, false) + diags := c.addProviderRequirements(reqs, false, false) children := make(map[string]*ModuleRequirements) for name, child := range c.Children { @@ -327,11 +336,37 @@ func (c *Config) ProviderRequirementsByModule() (*ModuleRequirements, hcl.Diagno diags = append(diags, childDiags...) } + tests := make(map[string]*TestFileModuleRequirements) + for name, test := range c.Module.Tests { + testReqs := &TestFileModuleRequirements{ + Requirements: make(getproviders.Requirements), + Runs: make(map[string]*ModuleRequirements), + } + + for _, provider := range test.Providers { + diags = append(diags, c.addProviderRequirementsFromProviderBlock(testReqs.Requirements, provider)...) + } + + for _, run := range test.Runs { + if run.ConfigUnderTest == nil { + continue + } + + runReqs, runDiags := run.ConfigUnderTest.ProviderRequirementsByModule() + runReqs.Name = run.Name + testReqs.Runs[run.Name] = runReqs + diags = append(diags, runDiags...) + } + + tests[name] = testReqs + } + ret := &ModuleRequirements{ SourceAddr: c.SourceAddr, SourceDir: c.Module.SourceDir, Requirements: reqs, Children: children, + Tests: tests, } return ret, diags @@ -341,7 +376,7 @@ func (c *Config) ProviderRequirementsByModule() (*ModuleRequirements, hcl.Diagno // implementation, gradually mutating a shared requirements object to // eventually return. If the recurse argument is true, the requirements will // include all descendant modules; otherwise, only the specified module. -func (c *Config) addProviderRequirements(reqs getproviders.Requirements, recurse bool) hcl.Diagnostics { +func (c *Config) addProviderRequirements(reqs getproviders.Requirements, recurse, tests bool) hcl.Diagnostics { var diags hcl.Diagnostics // First we'll deal with the requirements directly in _our_ module... @@ -487,38 +522,34 @@ func (c *Config) addProviderRequirements(reqs getproviders.Requirements, recurse // "provider" block can also contain version constraints for _, provider := range c.Module.ProviderConfigs { - fqn := c.Module.ProviderForLocalConfig(addrs.LocalProviderConfig{LocalName: provider.Name}) - if _, ok := reqs[fqn]; !ok { - // We'll at least have an unconstrained dependency then, but might - // add to this in the loop below. - reqs[fqn] = nil - } - if provider.Version.Required != nil { - // The model of version constraints in this package is still the - // old one using a different upstream module to represent versions, - // so we'll need to shim that out here for now. The two parsers - // don't exactly agree in practice 🙄 so this might produce new errors. - // TODO: Use the new parser throughout this package so we can get the - // better error messages it produces in more situations. - constraints, err := getproviders.ParseVersionConstraints(provider.Version.Required.String()) - if err != nil { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid version constraint", - // The errors returned by ParseVersionConstraint already include - // the section of input that was incorrect, so we don't need to - // include that here. - Detail: fmt.Sprintf("Incorrect version constraint syntax: %s.", err.Error()), - Subject: provider.Version.DeclRange.Ptr(), - }) + moreDiags := c.addProviderRequirementsFromProviderBlock(reqs, provider) + diags = append(diags, moreDiags...) + } + + // We may have provider blocks and required_providers set in some testing + // files. + if tests { + for _, file := range c.Module.Tests { + for _, provider := range file.Providers { + moreDiags := c.addProviderRequirementsFromProviderBlock(reqs, provider) + diags = append(diags, moreDiags...) + } + + if recurse { + // Then we'll also look for requirements in testing modules. + for _, run := range file.Runs { + if run.ConfigUnderTest != nil { + moreDiags := run.ConfigUnderTest.addProviderRequirements(reqs, true, false) + diags = append(diags, moreDiags...) + } + } } - reqs[fqn] = append(reqs[fqn], constraints...) } } if recurse { for _, childConfig := range c.Children { - moreDiags := childConfig.addProviderRequirements(reqs, true) + moreDiags := childConfig.addProviderRequirements(reqs, true, false) diags = append(diags, moreDiags...) } } @@ -526,6 +557,40 @@ func (c *Config) addProviderRequirements(reqs getproviders.Requirements, recurse return diags } +func (c *Config) addProviderRequirementsFromProviderBlock(reqs getproviders.Requirements, provider *Provider) hcl.Diagnostics { + var diags hcl.Diagnostics + + fqn := c.Module.ProviderForLocalConfig(addrs.LocalProviderConfig{LocalName: provider.Name}) + if _, ok := reqs[fqn]; !ok { + // We'll at least have an unconstrained dependency then, but might + // add to this in the loop below. + reqs[fqn] = nil + } + if provider.Version.Required != nil { + // The model of version constraints in this package is still the + // old one using a different upstream module to represent versions, + // so we'll need to shim that out here for now. The two parsers + // don't exactly agree in practice 🙄 so this might produce new errors. + // TODO: Use the new parser throughout this package so we can get the + // better error messages it produces in more situations. + constraints, err := getproviders.ParseVersionConstraints(provider.Version.Required.String()) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid version constraint", + // The errors returned by ParseVersionConstraint already include + // the section of input that was incorrect, so we don't need to + // include that here. + Detail: fmt.Sprintf("Incorrect version constraint syntax: %s.", err.Error()), + Subject: provider.Version.DeclRange.Ptr(), + }) + } + reqs[fqn] = append(reqs[fqn], constraints...) + } + + return diags +} + // resolveProviderTypes walks through the providers in the module and ensures // the true types are assigned based on the provider requirements for the // module. @@ -661,3 +726,94 @@ func (c *Config) CheckCoreVersionRequirements() hcl.Diagnostics { return diags } + +// TransformForTest prepares the config to execute the given test. +// +// This function directly edits the config that is to be tested, and returns a +// function that will reset the config back to its original state. +// +// Tests will call this before they execute, and then call the deferred function +// to reset the config before the next test. +func (c *Config) TransformForTest(run *TestRun, file *TestFile) (func(), hcl.Diagnostics) { + var diags hcl.Diagnostics + + // Currently, we only need to override the provider settings. + // + // We can have a set of providers defined within the config, we can also + // have a set of providers defined within the test file. Then the run can + // also specify a set of overrides that tell Terraform exactly which + // providers from the test file to apply into the config. + // + // The process here is as follows: + // 1. Take all the providers in the original config keyed by name.alias, + // we call this `previous` + // 2. Copy them all into a new map, we call this `next`. + // 3a. If the run has configuration specifying provider overrides, we copy + // only the specified providers from the test file into `next`. While + // doing this we ensure to preserve the name and alias from the + // original config. + // 3b. If the run has no override configuration, we copy all the providers + // from the test file into `next`, overriding all providers with name + // collisions from the original config. + // 4. We then modify the original configuration so that the providers it + // holds are the combination specified by the original config, the test + // file and the run file. + // 5. We then return a function that resets the original config back to + // its original state. This can be called by the surrounding test once + // completed so future run blocks can safely execute. + + // First, initialise `previous` and `next`. `previous` contains a backup of + // the providers from the original config. `next` contains the set of + // providers that will be used by the test. `next` starts with the set of + // providers from the original config. + previous := c.Module.ProviderConfigs + next := make(map[string]*Provider) + for key, value := range previous { + next[key] = value + } + + if run != nil && len(run.Providers) > 0 { + // Then we'll only copy over and overwrite the specific providers asked + // for by this run block. + + for _, ref := range run.Providers { + + testProvider, ok := file.Providers[ref.InParent.String()] + if !ok { + // Then this reference was invalid as we didn't have the + // specified provider in the parent. This should have been + // caught earlier in validation anyway so is unlikely to happen. + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Missing provider definition for %s", ref.InParent.String()), + Detail: "This provider block references a provider definition that does not exist.", + Subject: ref.InParent.NameRange.Ptr(), + }) + continue + } + + next[ref.InChild.String()] = &Provider{ + Name: ref.InChild.Name, + NameRange: ref.InChild.NameRange, + Alias: ref.InChild.Alias, + AliasRange: ref.InChild.AliasRange, + Version: testProvider.Version, + Config: testProvider.Config, + DeclRange: testProvider.DeclRange, + } + + } + } else { + // Otherwise, let's copy over and overwrite all providers specified by + // the test file itself. + for key, provider := range file.Providers { + next[key] = provider + } + } + + c.Module.ProviderConfigs = next + return func() { + // Reset the original config within the returned function. + c.Module.ProviderConfigs = previous + }, diags +} diff --git a/internal/configs/config_test.go b/internal/configs/config_test.go index 699e1991c1..d9c80df8d6 100644 --- a/internal/configs/config_test.go +++ b/internal/configs/config_test.go @@ -4,17 +4,23 @@ package configs import ( + "bytes" + "fmt" "os" + "strings" "testing" "github.com/go-test/deep" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclparse" "github.com/zclconf/go-cty/cty" version "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2/hclsyntax" svchost "github.com/hashicorp/terraform-svchost" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/getproviders" @@ -163,6 +169,42 @@ func TestConfigProviderRequirements(t *testing.T) { } } +func TestConfigProviderRequirementsInclTests(t *testing.T) { + cfg, diags := testNestedModuleConfigFromDirWithTests(t, "testdata/provider-reqs-with-tests") + // TODO: Version Constraint Deprecation. + // Once we've removed the version argument from provider configuration + // blocks, this can go back to expected 0 diagnostics. + // assertNoDiagnostics(t, diags) + assertDiagnosticCount(t, diags, 1) + assertDiagnosticSummary(t, diags, "Version constraints inside provider configuration blocks are deprecated") + + tlsProvider := addrs.NewProvider( + addrs.DefaultProviderRegistryHost, + "hashicorp", "tls", + ) + nullProvider := addrs.NewDefaultProvider("null") + randomProvider := addrs.NewDefaultProvider("random") + impliedProvider := addrs.NewDefaultProvider("implied") + terraformProvider := addrs.NewBuiltInProvider("terraform") + configuredProvider := addrs.NewDefaultProvider("configured") + + got, diags := cfg.ProviderRequirements() + assertNoDiagnostics(t, diags) + want := getproviders.Requirements{ + // the nullProvider constraints from the two modules are merged + nullProvider: getproviders.MustParseVersionConstraints("~> 2.0.0"), + randomProvider: getproviders.MustParseVersionConstraints("~> 1.2.0"), + tlsProvider: getproviders.MustParseVersionConstraints("~> 3.0"), + configuredProvider: getproviders.MustParseVersionConstraints("~> 1.4"), + impliedProvider: nil, + terraformProvider: nil, + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("wrong result\n%s", diff) + } +} + func TestConfigProviderRequirementsDuplicate(t *testing.T) { _, diags := testNestedModuleConfigFromDir(t, "testdata/duplicate-local-name") assertDiagnosticCount(t, diags, 3) @@ -205,6 +247,37 @@ func TestConfigProviderRequirementsShallow(t *testing.T) { } } +func TestConfigProviderRequirementsShallowInclTests(t *testing.T) { + cfg, diags := testNestedModuleConfigFromDirWithTests(t, "testdata/provider-reqs-with-tests") + // TODO: Version Constraint Deprecation. + // Once we've removed the version argument from provider configuration + // blocks, this can go back to expected 0 diagnostics. + // assertNoDiagnostics(t, diags) + assertDiagnosticCount(t, diags, 1) + assertDiagnosticSummary(t, diags, "Version constraints inside provider configuration blocks are deprecated") + + tlsProvider := addrs.NewProvider( + addrs.DefaultProviderRegistryHost, + "hashicorp", "tls", + ) + impliedProvider := addrs.NewDefaultProvider("implied") + terraformProvider := addrs.NewBuiltInProvider("terraform") + configuredProvider := addrs.NewDefaultProvider("configured") + + got, diags := cfg.ProviderRequirementsShallow() + assertNoDiagnostics(t, diags) + want := getproviders.Requirements{ + tlsProvider: getproviders.MustParseVersionConstraints("~> 3.0"), + configuredProvider: getproviders.MustParseVersionConstraints("~> 1.4"), + impliedProvider: nil, + terraformProvider: nil, + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("wrong result\n%s", diff) + } +} + func TestConfigProviderRequirementsByModule(t *testing.T) { cfg, diags := testNestedModuleConfigFromDir(t, "testdata/provider-reqs") // TODO: Version Constraint Deprecation. @@ -262,6 +335,69 @@ func TestConfigProviderRequirementsByModule(t *testing.T) { grandchildProvider: nil, }, Children: map[string]*ModuleRequirements{}, + Tests: make(map[string]*TestFileModuleRequirements), + }, + }, + Tests: make(map[string]*TestFileModuleRequirements), + }, + }, + Tests: make(map[string]*TestFileModuleRequirements), + } + + ignore := cmpopts.IgnoreUnexported(version.Constraint{}, cty.Value{}, hclsyntax.Body{}) + if diff := cmp.Diff(want, got, ignore); diff != "" { + t.Errorf("wrong result\n%s", diff) + } +} + +func TestConfigProviderRequirementsByModuleInclTests(t *testing.T) { + cfg, diags := testNestedModuleConfigFromDirWithTests(t, "testdata/provider-reqs-with-tests") + // TODO: Version Constraint Deprecation. + // Once we've removed the version argument from provider configuration + // blocks, this can go back to expected 0 diagnostics. + // assertNoDiagnostics(t, diags) + assertDiagnosticCount(t, diags, 1) + assertDiagnosticSummary(t, diags, "Version constraints inside provider configuration blocks are deprecated") + + tlsProvider := addrs.NewProvider( + addrs.DefaultProviderRegistryHost, + "hashicorp", "tls", + ) + nullProvider := addrs.NewDefaultProvider("null") + randomProvider := addrs.NewDefaultProvider("random") + impliedProvider := addrs.NewDefaultProvider("implied") + terraformProvider := addrs.NewBuiltInProvider("terraform") + configuredProvider := addrs.NewDefaultProvider("configured") + + got, diags := cfg.ProviderRequirementsByModule() + assertNoDiagnostics(t, diags) + want := &ModuleRequirements{ + Name: "", + SourceAddr: nil, + SourceDir: "testdata/provider-reqs-with-tests", + Requirements: getproviders.Requirements{ + // Only the root module's version is present here + tlsProvider: getproviders.MustParseVersionConstraints("~> 3.0"), + impliedProvider: nil, + terraformProvider: nil, + }, + Children: make(map[string]*ModuleRequirements), + Tests: map[string]*TestFileModuleRequirements{ + "provider-reqs-root.tftest": { + Requirements: getproviders.Requirements{ + configuredProvider: getproviders.MustParseVersionConstraints("~> 1.4"), + }, + Runs: map[string]*ModuleRequirements{ + "setup": { + Name: "setup", + SourceAddr: addrs.ModuleSourceLocal("./setup"), + SourceDir: "testdata/provider-reqs-with-tests/setup", + Requirements: getproviders.Requirements{ + nullProvider: getproviders.MustParseVersionConstraints("~> 2.0.0"), + randomProvider: getproviders.MustParseVersionConstraints("~> 1.2.0"), + }, + Children: make(map[string]*ModuleRequirements), + Tests: make(map[string]*TestFileModuleRequirements), }, }, }, @@ -421,7 +557,7 @@ func TestConfigAddProviderRequirements(t *testing.T) { reqs := getproviders.Requirements{ addrs.NewDefaultProvider("null"): nil, } - diags = cfg.addProviderRequirements(reqs, true) + diags = cfg.addProviderRequirements(reqs, true, false) assertNoDiagnostics(t, diags) } @@ -447,7 +583,7 @@ func TestConfigImportProviderClashesWithResources(t *testing.T) { cfg, diags := testModuleConfigFromFile("testdata/invalid-import-files/import-and-resource-clash.tf") assertNoDiagnostics(t, diags) - diags = cfg.addProviderRequirements(getproviders.Requirements{}, true) + diags = cfg.addProviderRequirements(getproviders.Requirements{}, true, false) assertExactDiagnostics(t, diags, []string{ `testdata/invalid-import-files/import-and-resource-clash.tf:9,3-19: Invalid import provider argument; The provider argument can only be specified in import blocks that will generate configuration. @@ -459,10 +595,235 @@ func TestConfigImportProviderWithNoResourceProvider(t *testing.T) { cfg, diags := testModuleConfigFromFile("testdata/invalid-import-files/import-and-no-resource.tf") assertNoDiagnostics(t, diags) - diags = cfg.addProviderRequirements(getproviders.Requirements{}, true) + diags = cfg.addProviderRequirements(getproviders.Requirements{}, true, false) assertExactDiagnostics(t, diags, []string{ `testdata/invalid-import-files/import-and-no-resource.tf:5,3-19: Invalid import provider argument; The provider argument can only be specified in import blocks that will generate configuration. Use the provider argument in the target resource block to configure the provider for a resource with explicit provider configuration.`, }) } + +func TestTransformForTest(t *testing.T) { + + str := func(providers map[string]string) string { + var buffer bytes.Buffer + for key, config := range providers { + buffer.WriteString(fmt.Sprintf("%s: %s\n", key, config)) + } + return buffer.String() + } + + convertToProviders := func(t *testing.T, contents map[string]string) map[string]*Provider { + t.Helper() + + providers := make(map[string]*Provider) + for key, content := range contents { + parser := hclparse.NewParser() + file, diags := parser.ParseHCL([]byte(content), fmt.Sprintf("%s.hcl", key)) + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + provider := &Provider{ + Config: file.Body, + } + + parts := strings.Split(key, ".") + provider.Name = parts[0] + if len(parts) > 1 { + provider.Alias = parts[1] + } + + providers[key] = provider + } + return providers + } + + validate := func(t *testing.T, msg string, expected map[string]string, actual map[string]*Provider) { + t.Helper() + + converted := make(map[string]string) + for key, provider := range actual { + content, err := provider.Config.Content(&hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + {Name: "source", Required: true}, + }, + }) + if err != nil { + t.Fatal(err) + } + + source, diags := content.Attributes["source"].Expr.Value(nil) + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + converted[key] = fmt.Sprintf("source = %q", source.AsString()) + } + + if diff := cmp.Diff(expected, converted); len(diff) > 0 { + t.Errorf("%s\nexpected:\n%s\nactual:\n%s\ndiff:\n%s", msg, str(expected), str(converted), diff) + } + } + + tcs := map[string]struct { + configProviders map[string]string + fileProviders map[string]string + runProviders []PassedProviderConfig + expectedProviders map[string]string + expectedErrors []string + }{ + "empty": { + configProviders: make(map[string]string), + expectedProviders: make(map[string]string), + }, + "only providers in config": { + configProviders: map[string]string{ + "foo": "source = \"config\"", + "bar": "source = \"config\"", + }, + expectedProviders: map[string]string{ + "foo": "source = \"config\"", + "bar": "source = \"config\"", + }, + }, + "only providers in test file": { + configProviders: make(map[string]string), + fileProviders: map[string]string{ + "foo": "source = \"testfile\"", + "bar": "source = \"testfile\"", + }, + expectedProviders: map[string]string{ + "foo": "source = \"testfile\"", + "bar": "source = \"testfile\"", + }, + }, + "only providers in run block": { + configProviders: make(map[string]string), + runProviders: []PassedProviderConfig{ + { + InChild: &ProviderConfigRef{ + Name: "foo", + }, + InParent: &ProviderConfigRef{ + Name: "bar", + }, + }, + }, + expectedProviders: make(map[string]string), + expectedErrors: []string{ + ":0,0-0: Missing provider definition for bar; This provider block references a provider definition that does not exist.", + }, + }, + "subset of providers in test file": { + configProviders: make(map[string]string), + fileProviders: map[string]string{ + "bar": "source = \"testfile\"", + }, + runProviders: []PassedProviderConfig{ + { + InChild: &ProviderConfigRef{ + Name: "foo", + }, + InParent: &ProviderConfigRef{ + Name: "bar", + }, + }, + }, + expectedProviders: map[string]string{ + "foo": "source = \"testfile\"", + }, + }, + "overrides providers in config": { + configProviders: map[string]string{ + "foo": "source = \"config\"", + "bar": "source = \"config\"", + }, + fileProviders: map[string]string{ + "bar": "source = \"testfile\"", + }, + expectedProviders: map[string]string{ + "foo": "source = \"config\"", + "bar": "source = \"testfile\"", + }, + }, + "overrides subset of providers in config": { + configProviders: map[string]string{ + "foo": "source = \"config\"", + "bar": "source = \"config\"", + }, + fileProviders: map[string]string{ + "foo": "source = \"testfile\"", + "bar": "source = \"testfile\"", + }, + runProviders: []PassedProviderConfig{ + { + InChild: &ProviderConfigRef{ + Name: "bar", + }, + InParent: &ProviderConfigRef{ + Name: "bar", + }, + }, + }, + expectedProviders: map[string]string{ + "foo": "source = \"config\"", + "bar": "source = \"testfile\"", + }, + }, + "handles aliases": { + configProviders: map[string]string{ + "foo.primary": "source = \"config\"", + "foo.secondary": "source = \"config\"", + }, + fileProviders: map[string]string{ + "foo": "source = \"testfile\"", + }, + runProviders: []PassedProviderConfig{ + { + InChild: &ProviderConfigRef{ + Name: "foo.secondary", + }, + InParent: &ProviderConfigRef{ + Name: "foo", + }, + }, + }, + expectedProviders: map[string]string{ + "foo.primary": "source = \"config\"", + "foo.secondary": "source = \"testfile\"", + }, + }, + } + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + config := &Config{ + Module: &Module{ + ProviderConfigs: convertToProviders(t, tc.configProviders), + }, + } + + file := &TestFile{ + Providers: convertToProviders(t, tc.fileProviders), + } + + run := &TestRun{ + Providers: tc.runProviders, + } + + reset, diags := config.TransformForTest(run, file) + + var actualErrs []string + for _, err := range diags.Errs() { + actualErrs = append(actualErrs, err.Error()) + } + if diff := cmp.Diff(actualErrs, tc.expectedErrors, cmpopts.IgnoreUnexported()); len(diff) > 0 { + t.Errorf("unmatched errors\nexpected:\n%s\nactual:\n%s\ndiff:\n%s", strings.Join(tc.expectedErrors, "\n"), strings.Join(actualErrs, "\n"), diff) + } + + validate(t, "after transform mismatch", tc.expectedProviders, config.Module.ProviderConfigs) + reset() + validate(t, "after reset mismatch", tc.configProviders, config.Module.ProviderConfigs) + + }) + } +} diff --git a/internal/configs/module_call.go b/internal/configs/module_call.go index fd115fa15d..2f2706f935 100644 --- a/internal/configs/module_call.go +++ b/internal/configs/module_call.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/getmodules" ) @@ -157,36 +158,9 @@ func decodeModuleBlock(block *hcl.Block, override bool) (*ModuleCall, hcl.Diagno } if attr, exists := content.Attributes["providers"]; exists { - seen := make(map[string]hcl.Range) - pairs, pDiags := hcl.ExprMap(attr.Expr) - diags = append(diags, pDiags...) - for _, pair := range pairs { - key, keyDiags := decodeProviderConfigRef(pair.Key, "providers") - diags = append(diags, keyDiags...) - value, valueDiags := decodeProviderConfigRef(pair.Value, "providers") - diags = append(diags, valueDiags...) - if keyDiags.HasErrors() || valueDiags.HasErrors() { - continue - } - - matchKey := key.String() - if prev, exists := seen[matchKey]; exists { - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Duplicate provider address", - Detail: fmt.Sprintf("A provider configuration was already passed to %s at %s. Each child provider configuration can be assigned only once.", matchKey, prev), - Subject: pair.Value.Range().Ptr(), - }) - continue - } - - rng := hcl.RangeBetween(pair.Key.Range(), pair.Value.Range()) - seen[matchKey] = rng - mc.Providers = append(mc.Providers, PassedProviderConfig{ - InChild: key, - InParent: value, - }) - } + providers, providerDiags := decodePassedProviderConfigs(attr) + diags = append(diags, providerDiags...) + mc.Providers = append(mc.Providers, providers...) } var seenEscapeBlock *hcl.Block @@ -246,6 +220,43 @@ type PassedProviderConfig struct { InParent *ProviderConfigRef } +func decodePassedProviderConfigs(attr *hcl.Attribute) ([]PassedProviderConfig, hcl.Diagnostics) { + var diags hcl.Diagnostics + var providers []PassedProviderConfig + + seen := make(map[string]hcl.Range) + pairs, pDiags := hcl.ExprMap(attr.Expr) + diags = append(diags, pDiags...) + for _, pair := range pairs { + key, keyDiags := decodeProviderConfigRef(pair.Key, "providers") + diags = append(diags, keyDiags...) + value, valueDiags := decodeProviderConfigRef(pair.Value, "providers") + diags = append(diags, valueDiags...) + if keyDiags.HasErrors() || valueDiags.HasErrors() { + continue + } + + matchKey := key.String() + if prev, exists := seen[matchKey]; exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate provider address", + Detail: fmt.Sprintf("A provider configuration was already passed to %s at %s. Each child provider configuration can be assigned only once.", matchKey, prev), + Subject: pair.Value.Range().Ptr(), + }) + continue + } + + rng := hcl.RangeBetween(pair.Key.Range(), pair.Value.Range()) + seen[matchKey] = rng + providers = append(providers, PassedProviderConfig{ + InChild: key, + InParent: value, + }) + } + return providers, diags +} + var moduleBlockSchema = &hcl.BodySchema{ Attributes: []hcl.AttributeSchema{ { diff --git a/internal/configs/parser_test.go b/internal/configs/parser_test.go index 6de8c717a5..2a3fee5a1b 100644 --- a/internal/configs/parser_test.go +++ b/internal/configs/parser_test.go @@ -68,6 +68,23 @@ func testModuleConfigFromDir(path string) (*Config, hcl.Diagnostics) { return cfg, append(diags, moreDiags...) } +// testNestedModuleConfigFromDirWithTests matches testNestedModuleConfigFromDir +// except it also loads any test files within the directory. +func testNestedModuleConfigFromDirWithTests(t *testing.T, path string) (*Config, hcl.Diagnostics) { + t.Helper() + + parser := NewParser(nil) + mod, diags := parser.LoadConfigDirWithTests(path, "tests") + if mod == nil { + t.Fatal("got nil root module; want non-nil") + } + + cfg, nestedDiags := buildNestedModuleConfig(mod, path, parser) + + diags = append(diags, nestedDiags...) + return cfg, diags +} + // testNestedModuleConfigFromDir reads configuration from the given directory path as // a module with (optional) submodules and returns its configuration. This is a // helper for use in unit tests. @@ -80,8 +97,15 @@ func testNestedModuleConfigFromDir(t *testing.T, path string) (*Config, hcl.Diag t.Fatal("got nil root module; want non-nil") } + cfg, nestedDiags := buildNestedModuleConfig(mod, path, parser) + + diags = append(diags, nestedDiags...) + return cfg, diags +} + +func buildNestedModuleConfig(mod *Module, path string, parser *Parser) (*Config, hcl.Diagnostics) { versionI := 0 - cfg, nestedDiags := BuildConfig(mod, ModuleWalkerFunc( + return BuildConfig(mod, ModuleWalkerFunc( func(req *ModuleRequest) (*Module, *version.Version, hcl.Diagnostics) { // For the sake of this test we're going to just treat our // SourceAddr as a path relative to the calling module. @@ -103,9 +127,6 @@ func testNestedModuleConfigFromDir(t *testing.T, path string) (*Config, hcl.Diag return mod, version, diags }, )) - - diags = append(diags, nestedDiags...) - return cfg, diags } func assertNoDiagnostics(t *testing.T, diags hcl.Diagnostics) bool { diff --git a/internal/configs/test_file.go b/internal/configs/test_file.go index a6578d7738..75850e473f 100644 --- a/internal/configs/test_file.go +++ b/internal/configs/test_file.go @@ -44,6 +44,13 @@ type TestFile struct { // for every run block within the test file. Variables map[string]hcl.Expression + // Providers defines a set of providers that are available to run blocks + // within this test file. + // + // If empty, tests should use the default providers for the module under + // test. + Providers map[string]*Provider + // Runs defines the sequential list of run blocks that should be executed in // order. Runs []*TestRun @@ -77,6 +84,14 @@ type TestRun struct { // take precedence over the global definition. Variables map[string]hcl.Expression + // Providers specifies the set of providers that should be loaded into the + // module for this run block. + // + // Providers specified here must be configured in one of the provider blocks + // for this file. If empty, the run block will load the default providers + // for the module under test. + Providers []PassedProviderConfig + // CheckRules defines the list of assertions/validations that should be // checked by this run block. CheckRules []*CheckRule @@ -144,7 +159,9 @@ func loadTestFile(body hcl.Body) (*TestFile, hcl.Diagnostics) { content, contentDiags := body.Content(testFileSchema) diags = append(diags, contentDiags...) - tf := TestFile{} + tf := TestFile{ + Providers: make(map[string]*Provider), + } for _, block := range content.Blocks { switch block.Type { @@ -173,6 +190,12 @@ func loadTestFile(body hcl.Body) (*TestFile, hcl.Diagnostics) { for _, v := range vars { tf.Variables[v.Name] = v.Expr } + case "provider": + provider, providerDiags := decodeProviderBlock(block) + diags = append(diags, providerDiags...) + if provider != nil { + tf.Providers[provider.moduleUniqueKey()] = provider + } } } @@ -285,6 +308,12 @@ func decodeTestRunBlock(block *hcl.Block) (*TestRun, hcl.Diagnostics) { r.Command = ApplyTestCommand // Default to apply } + if attr, exists := content.Attributes["providers"]; exists { + providers, providerDiags := decodePassedProviderConfigs(attr) + diags = append(diags, providerDiags...) + r.Providers = append(r.Providers, providers...) + } + if attr, exists := content.Attributes["expect_failures"]; exists { failures, failDiags := decodeDependsOn(attr) diags = append(diags, failDiags...) @@ -464,6 +493,10 @@ var testFileSchema = &hcl.BodySchema{ Type: "run", LabelNames: []string{"name"}, }, + { + Type: "provider", + LabelNames: []string{"name"}, + }, { Type: "variables", }, @@ -473,6 +506,7 @@ var testFileSchema = &hcl.BodySchema{ var testRunBlockSchema = &hcl.BodySchema{ Attributes: []hcl.AttributeSchema{ {Name: "command"}, + {Name: "providers"}, {Name: "expect_failures"}, }, Blocks: []hcl.BlockHeaderSchema{ diff --git a/internal/configs/testdata/provider-reqs-with-tests/provider-reqs-root.tf b/internal/configs/testdata/provider-reqs-with-tests/provider-reqs-root.tf new file mode 100644 index 0000000000..b072ef7bab --- /dev/null +++ b/internal/configs/testdata/provider-reqs-with-tests/provider-reqs-root.tf @@ -0,0 +1,20 @@ +terraform { + required_providers { + tls = { + source = "hashicorp/tls" + version = "~> 3.0" + } + } +} + +# There is no provider in required_providers called "implied", so this +# implicitly declares a dependency on "hashicorp/implied". +resource "implied_foo" "bar" { +} + +# There is no provider in required_providers called "terraform", but for +# this name in particular we imply terraform.io/builtin/terraform instead, +# to avoid selecting the now-unmaintained +# registry.terraform.io/hashicorp/terraform. +data "terraform_remote_state" "bar" { +} diff --git a/internal/configs/testdata/provider-reqs-with-tests/provider-reqs-root.tftest b/internal/configs/testdata/provider-reqs-with-tests/provider-reqs-root.tftest new file mode 100644 index 0000000000..e368ef00da --- /dev/null +++ b/internal/configs/testdata/provider-reqs-with-tests/provider-reqs-root.tftest @@ -0,0 +1,11 @@ +# There is no provider in required_providers called "configured", so the version +# constraint should come from this configuration block. +provider "configured" { + version = "~> 1.4" +} + +run "setup" { + module { + source = "./setup" + } +} diff --git a/internal/configs/testdata/provider-reqs-with-tests/setup/setup.tf b/internal/configs/testdata/provider-reqs-with-tests/setup/setup.tf new file mode 100644 index 0000000000..7341d6af93 --- /dev/null +++ b/internal/configs/testdata/provider-reqs-with-tests/setup/setup.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + null = "~> 2.0.0" + random = { + version = "~> 1.2.0" + } + } +}