diff --git a/command/013_config_upgrade.go b/command/013_config_upgrade.go new file mode 100644 index 000000000..d05445792 --- /dev/null +++ b/command/013_config_upgrade.go @@ -0,0 +1,216 @@ +package command + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/hashicorp/terraform/configs" + "github.com/hashicorp/terraform/internal/initwd" + "github.com/hashicorp/terraform/moduledeps" + "github.com/hashicorp/terraform/tfdiags" +) + +// ZeroThirteenUpgradeCommand upgrades configuration files for a module +// to include explicit provider source settings +type ZeroThirteenUpgradeCommand struct { + Meta +} + +func (c *ZeroThirteenUpgradeCommand) Run(args []string) int { + args, err := c.Meta.process(args, true) + if err != nil { + return 1 + } + + flags := c.Meta.defaultFlagSet("0.13upgrade") + flags.Usage = func() { c.Ui.Error(c.Help()) } + if err := flags.Parse(args); err != nil { + return 1 + } + + var diags tfdiags.Diagnostics + + var dir string + args = flags.Args() + switch len(args) { + case 0: + dir = "." + case 1: + dir = args[0] + default: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Too many arguments", + "The command 0.13upgrade expects only a single argument, giving the directory containing the module to upgrade.", + )) + c.showDiagnostics(diags) + return 1 + } + + // Check for user-supplied plugin path + if c.pluginPath, err = c.loadPluginPath(); err != nil { + c.Ui.Error(fmt.Sprintf("Error loading plugin path: %s", err)) + return 1 + } + + dir = c.normalizePath(dir) + + // Upgrade only if some configuration is present + empty, err := configs.IsEmptyDir(dir) + if err != nil { + diags = diags.Append(fmt.Errorf("Error checking configuration: %s", err)) + return 1 + } + if empty { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Not a module directory", + fmt.Sprintf("The given directory %s does not contain any Terraform configuration files.", dir), + )) + c.showDiagnostics(diags) + return 1 + } + + // Early-load the config so that we can check provider dependencies + earlyConfig, earlyConfDiags := c.loadConfigEarly(dir) + if earlyConfDiags.HasErrors() { + c.Ui.Error(strings.TrimSpace("Failed to load configuration")) + diags = diags.Append(earlyConfDiags) + c.showDiagnostics(diags) + return 1 + } + + { + // Before we go further, we'll check to make sure none of the modules + // in the configuration declare that they don't support this Terraform + // version, so we can produce a version-related error message rather + // than potentially-confusing downstream errors. + versionDiags := initwd.CheckCoreVersionRequirements(earlyConfig) + diags = diags.Append(versionDiags) + if versionDiags.HasErrors() { + c.showDiagnostics(diags) + return 1 + } + } + + // Find the provider dependencies + configDeps, depsDiags := earlyConfig.ProviderDependencies() + if depsDiags.HasErrors() { + c.Ui.Error(strings.TrimSpace("Could not detect provider dependencies")) + diags = diags.Append(depsDiags) + c.showDiagnostics(diags) + return 1 + } + + // Detect source for each provider + providerSources, detectDiags := detectProviderSources(configDeps.Providers) + if detectDiags.HasErrors() { + c.Ui.Error(strings.TrimSpace("Unable to detect sources for providers")) + diags = diags.Append(detectDiags) + c.showDiagnostics(diags) + return 1 + } + + if len(providerSources) == 0 { + c.Ui.Output("No non-default providers found. Your configuration is ready to use!") + return 0 + } + + // Generate the required providers configuration + genDiags := generateRequiredProviders(providerSources, dir) + diags = diags.Append(genDiags) + + c.showDiagnostics(diags) + if diags.HasErrors() { + return 2 + } + + if len(diags) != 0 { + c.Ui.Output(`-----------------------------------------------------------------------------`) + } + c.Ui.Output(c.Colorize().Color(` +[bold][green]Upgrade complete![reset] + +Use your version control system to review the proposed changes, make any +necessary adjustments, and then commit. +`)) + + return 0 +} + +// For providers which need a source attribute, detect and return source +// FIXME: currently does not filter or detect sources +func detectProviderSources(providers moduledeps.Providers) (map[string]string, tfdiags.Diagnostics) { + sources := make(map[string]string) + for provider := range providers { + sources[provider.Type] = provider.String() + } + return sources, nil +} + +var providersTemplate = template.Must(template.New("providers.tf").Parse(`terraform { + required_providers { + {{- range $type, $source := .}} + {{$type}} = { + source = "{{$source}}" + } + {{- end}} + } +} +`)) + +// Generate a file with terraform.required_providers blocks for each provider +func generateRequiredProviders(providerSources map[string]string, dir string) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + // Find unused file named "providers.tf", or fall back to e.g. "providers-1.tf" + path := filepath.Join(dir, "providers.tf") + if _, err := os.Stat(path); !os.IsNotExist(err) { + for i := 1; ; i++ { + path = filepath.Join(dir, fmt.Sprintf("providers-%d.tf", i)) + if _, err := os.Stat(path); os.IsNotExist(err) { + break + } + } + } + + f, err := os.Create(path) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Unable to create providers file", + fmt.Sprintf("Error when generating providers configuration at '%s': %s", path, err), + )) + return diags + } + defer f.Close() + + err = providersTemplate.Execute(f, providerSources) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Unable to create providers file", + fmt.Sprintf("Error when generating providers configuration at '%s': %s", path, err), + )) + return diags + } + + return nil +} + +func (c *ZeroThirteenUpgradeCommand) Help() string { + helpText := ` +Usage: terraform 0.13upgrade [module-dir] + + Generates a "providers.tf" configuration file which includes source + configuration for every non-default provider. +` + return strings.TrimSpace(helpText) +} + +func (c *ZeroThirteenUpgradeCommand) Synopsis() string { + return "Rewrites pre-0.13 module source code for v0.13" +} diff --git a/command/013_config_upgrade_test.go b/command/013_config_upgrade_test.go new file mode 100644 index 000000000..219beb551 --- /dev/null +++ b/command/013_config_upgrade_test.go @@ -0,0 +1,200 @@ +package command + +import ( + "bytes" + "io/ioutil" + "os" + "strings" + "testing" + + "github.com/hashicorp/terraform/helper/copy" + "github.com/mitchellh/cli" +) + +func TestZeroThirteenUpgrade_success(t *testing.T) { + testCases := map[string]struct { + path string + args []string + out string + }{ + "implicit": { + path: "013upgrade-implicit-providers", + out: "providers.tf", + }, + "explicit": { + path: "013upgrade-explicit-providers", + out: "providers.tf", + }, + "subdir": { + path: "013upgrade-subdir", + args: []string{"subdir"}, + out: "subdir/providers.tf", + }, + "fileExists": { + path: "013upgrade-file-exists", + out: "providers-1.tf", + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + td := tempDir(t) + copy.CopyDir(testFixturePath(tc.path), td) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + ui := new(cli.MockUi) + c := &ZeroThirteenUpgradeCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + }, + } + + if code := c.Run(tc.args); code != 0 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + if !strings.Contains(output, "Upgrade complete") { + t.Fatal("unexpected output:", output) + } + + actual, err := ioutil.ReadFile(tc.out) + if err != nil { + t.Fatalf("failed to read output %s: %s", tc.out, err) + } + expected, err := ioutil.ReadFile("expected/providers.tf") + if err != nil { + t.Fatal("failed to read expected/providers.tf", err) + } + + if !bytes.Equal(actual, expected) { + t.Fatalf("actual output: \n%s\nexpected output: \n%s", string(actual), string(expected)) + } + }) + } +} + +func TestZeroThirteenUpgrade_invalidFlags(t *testing.T) { + td := tempDir(t) + os.MkdirAll(td, 0755) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + ui := new(cli.MockUi) + c := &ZeroThirteenUpgradeCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + }, + } + + if code := c.Run([]string{"--whoops"}); code == 0 { + t.Fatal("expected error, got:", ui.OutputWriter) + } + + errMsg := ui.ErrorWriter.String() + if !strings.Contains(errMsg, "Usage: terraform 0.13upgrade") { + t.Fatal("unexpected error:", errMsg) + } +} + +func TestZeroThirteenUpgrade_tooManyArguments(t *testing.T) { + td := tempDir(t) + os.MkdirAll(td, 0755) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + ui := new(cli.MockUi) + c := &ZeroThirteenUpgradeCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + }, + } + + if code := c.Run([]string{".", "./modules/test"}); code == 0 { + t.Fatal("expected error, got:", ui.OutputWriter) + } + + errMsg := ui.ErrorWriter.String() + if !strings.Contains(errMsg, "Error: Too many arguments") { + t.Fatal("unexpected error:", errMsg) + } +} + +func TestZeroThirteenUpgrade_empty(t *testing.T) { + td := tempDir(t) + os.MkdirAll(td, 0755) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + ui := new(cli.MockUi) + c := &ZeroThirteenUpgradeCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + }, + } + + if code := c.Run(nil); code == 0 { + t.Fatal("expected error, got:", ui.OutputWriter) + } + + errMsg := ui.ErrorWriter.String() + if !strings.Contains(errMsg, "Not a module directory") { + t.Fatal("unexpected error:", errMsg) + } +} + +func TestZeroThirteenUpgrade_invalidProviderVersion(t *testing.T) { + td := tempDir(t) + copy.CopyDir(testFixturePath("013upgrade-invalid"), td) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + ui := new(cli.MockUi) + c := &ZeroThirteenUpgradeCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + }, + } + + if code := c.Run(nil); code == 0 { + t.Fatal("expected error, got:", ui.OutputWriter) + } + + errMsg := ui.ErrorWriter.String() + if !strings.Contains(errMsg, "Invalid provider version constraint") { + t.Fatal("unexpected error:", errMsg) + } +} + +func TestZeroThirteenUpgrade_noProviders(t *testing.T) { + td := tempDir(t) + copy.CopyDir(testFixturePath("013upgrade-no-providers"), td) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + ui := new(cli.MockUi) + c := &ZeroThirteenUpgradeCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + }, + } + + if code := c.Run(nil); code != 0 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + if !strings.Contains(output, "No non-default providers found") { + t.Fatal("unexpected output:", output) + } + + if _, err := os.Stat("providers.tf"); !os.IsNotExist(err) { + t.Fatal("unexpected providers.tf created") + } +} diff --git a/command/testdata/013upgrade-explicit-providers/expected/providers.tf b/command/testdata/013upgrade-explicit-providers/expected/providers.tf new file mode 100644 index 000000000..c12b7df0a --- /dev/null +++ b/command/testdata/013upgrade-explicit-providers/expected/providers.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + bar = { + source = "registry.terraform.io/-/bar" + } + baz = { + source = "registry.terraform.io/-/baz" + } + foo = { + source = "registry.terraform.io/-/foo" + } + } +} diff --git a/command/testdata/013upgrade-explicit-providers/main.tf b/command/testdata/013upgrade-explicit-providers/main.tf new file mode 100644 index 000000000..80ee3b7fe --- /dev/null +++ b/command/testdata/013upgrade-explicit-providers/main.tf @@ -0,0 +1,10 @@ +provider foo {} + +terraform { + required_providers { + bar = "1.0.0" + baz = { + version = "~> 2.0.0" + } + } +} diff --git a/command/testdata/013upgrade-file-exists/expected/providers.tf b/command/testdata/013upgrade-file-exists/expected/providers.tf new file mode 100644 index 000000000..5c4b69105 --- /dev/null +++ b/command/testdata/013upgrade-file-exists/expected/providers.tf @@ -0,0 +1,10 @@ +terraform { + required_providers { + alpha = { + source = "registry.terraform.io/-/alpha" + } + beta = { + source = "registry.terraform.io/-/beta" + } + } +} diff --git a/command/testdata/013upgrade-file-exists/providers.tf b/command/testdata/013upgrade-file-exists/providers.tf new file mode 100644 index 000000000..a13ab6d45 --- /dev/null +++ b/command/testdata/013upgrade-file-exists/providers.tf @@ -0,0 +1,2 @@ +provider alpha {} +provider beta {} diff --git a/command/testdata/013upgrade-implicit-providers/expected/providers.tf b/command/testdata/013upgrade-implicit-providers/expected/providers.tf new file mode 100644 index 000000000..1f1064007 --- /dev/null +++ b/command/testdata/013upgrade-implicit-providers/expected/providers.tf @@ -0,0 +1,10 @@ +terraform { + required_providers { + cloud = { + source = "registry.terraform.io/-/cloud" + } + some = { + source = "registry.terraform.io/-/some" + } + } +} diff --git a/command/testdata/013upgrade-implicit-providers/main.tf b/command/testdata/013upgrade-implicit-providers/main.tf new file mode 100644 index 000000000..be9fa73e1 --- /dev/null +++ b/command/testdata/013upgrade-implicit-providers/main.tf @@ -0,0 +1,2 @@ +resource some_resource a {} +resource cloud_horse x {} diff --git a/command/testdata/013upgrade-invalid/main.tf b/command/testdata/013upgrade-invalid/main.tf new file mode 100644 index 000000000..b81807c3e --- /dev/null +++ b/command/testdata/013upgrade-invalid/main.tf @@ -0,0 +1,3 @@ +provider "invalid" { + version = "invalid" +} diff --git a/command/testdata/013upgrade-no-providers/main.tf b/command/testdata/013upgrade-no-providers/main.tf new file mode 100644 index 000000000..d7ad43496 --- /dev/null +++ b/command/testdata/013upgrade-no-providers/main.tf @@ -0,0 +1,11 @@ +variable "x" { + default = 3 +} + +variable "y" { + default = 5 +} + +output "product" { + value = var.x * var.y +} diff --git a/command/testdata/013upgrade-subdir/expected/providers.tf b/command/testdata/013upgrade-subdir/expected/providers.tf new file mode 100644 index 000000000..5c4b69105 --- /dev/null +++ b/command/testdata/013upgrade-subdir/expected/providers.tf @@ -0,0 +1,10 @@ +terraform { + required_providers { + alpha = { + source = "registry.terraform.io/-/alpha" + } + beta = { + source = "registry.terraform.io/-/beta" + } + } +} diff --git a/command/testdata/013upgrade-subdir/subdir/main.tf b/command/testdata/013upgrade-subdir/subdir/main.tf new file mode 100644 index 000000000..19cf29fdc --- /dev/null +++ b/command/testdata/013upgrade-subdir/subdir/main.tf @@ -0,0 +1,2 @@ +resource beta_resource b {} +resource alpha_resource a {} diff --git a/commands.go b/commands.go index 7ea882c89..899cb11e6 100644 --- a/commands.go +++ b/commands.go @@ -91,6 +91,7 @@ func initCommands(config *cliconfig.Config, services *disco.Disco, providerSrc g "force-unlock": struct{}{}, "push": struct{}{}, "0.12upgrade": struct{}{}, + "0.13upgrade": struct{}{}, } Commands = map[string]cli.CommandFactory{ @@ -312,6 +313,12 @@ func initCommands(config *cliconfig.Config, services *disco.Disco, providerSrc g }, nil }, + "0.13upgrade": func() (cli.Command, error) { + return &command.ZeroThirteenUpgradeCommand{ + Meta: meta, + }, nil + }, + "debug": func() (cli.Command, error) { return &command.DebugCommand{ Meta: meta,