diff --git a/command/012_config_upgrade.go b/command/012_config_upgrade.go new file mode 100644 index 000000000..106e5fd37 --- /dev/null +++ b/command/012_config_upgrade.go @@ -0,0 +1,245 @@ +package command + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/hashicorp/hcl2/hcl" + "github.com/hashicorp/terraform/configs/configupgrade" + "github.com/hashicorp/terraform/terraform" + "github.com/hashicorp/terraform/tfdiags" +) + +// ZeroTwelveUpgradeCommand is a Command implementation that can upgrade +// the configuration files for a module from pre-0.11 syntax to new 0.12 +// idiom, while also flagging any suspicious constructs that will require +// human review. +type ZeroTwelveUpgradeCommand struct { + Meta +} + +func (c *ZeroTwelveUpgradeCommand) Run(args []string) int { + args, err := c.Meta.process(args, true) + if err != nil { + return 1 + } + + var skipConfirm, force bool + + flags := c.Meta.flagSet("0.12upgrade") + flags.BoolVar(&skipConfirm, "yes", false, "skip confirmation prompt") + flags.BoolVar(&force, "force", false, "override duplicate upgrade heuristic") + 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.12upgrade expects only a single argument, giving the directory containing the module to upgrade.", + )) + c.showDiagnostics(diags) + return 1 + } + + dir = c.normalizePath(dir) + + sources, err := configupgrade.LoadModule(dir) + if err != nil { + if os.IsNotExist(err) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Module directory not found", + fmt.Sprintf("The given directory %s does not exist.", dir), + )) + } else { + diags = diags.Append(err) + } + c.showDiagnostics(diags) + return 1 + } + + if len(sources) == 0 { + 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 + } + + // The config loader doesn't naturally populate our sources + // map, so we'll do it manually so our diagnostics can have + // source code snippets inside them. + // This is weird, but this whole upgrade codepath is pretty + // weird and temporary, so we'll accept it. + if loader, err := c.initConfigLoader(); err == nil { + parser := loader.Parser() + for name, src := range sources { + parser.ForceFileSource(filepath.Join(dir, name), src) + } + } + + if !force { + // We'll check first if this directory already looks upgraded, so we + // don't waste the user's time dealing with an interactive prompt + // immediately followed by an error. + if already, rng := sources.MaybeAlreadyUpgraded(); already { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Module already upgraded", + Detail: fmt.Sprintf("The module in directory %s has a version constraint that suggests it has already been upgraded for v0.12. If this is incorrect, either remove this constraint or override this heuristic with the -force argument. Upgrading a module that was already upgraded may change the meaning of that module.", dir), + Subject: rng.ToHCL().Ptr(), + }) + c.showDiagnostics(diags) + return 1 + } + } + + if !skipConfirm { + c.Ui.Output(fmt.Sprintf(` +This command will rewrite the configuration files in the given directory so +that they use the new syntax features from Terraform v0.12, and will identify +any constructs that may need to be adjusted for correct operation with +Terraform v0.12. + +We recommend using this command in a clean version control work tree, so that +you can easily see the proposed changes as a diff against the latest commit. +If you have uncommited changes already present, we recommend aborting this +command and dealing with them before running this command again. +`)) + + query := "Would you like to upgrade the module in the current directory?" + if dir != "." { + query = fmt.Sprintf("Would you like to upgrade the module in %s?", dir) + } + v, err := c.UIInput().Input(&terraform.InputOpts{ + Id: "approve", + Query: query, + Description: `Only 'yes' will be accepted to confirm.`, + }) + if err != nil { + diags = diags.Append(err) + c.showDiagnostics(diags) + return 1 + } + if v != "yes" { + c.Ui.Info("Upgrade cancelled.") + return 0 + } + + c.Ui.Output(`-----------------------------------------------------------------------------`) + } + + newSources, upgradeDiags := configupgrade.Upgrade(sources) + diags = diags.Append(upgradeDiags) + if upgradeDiags.HasErrors() { + c.showDiagnostics(diags) + return 2 + } + + // Now we'll write the contents of newSources into the filesystem. + for name, src := range newSources { + fn := filepath.Join(dir, name) + if src == nil { + // indicates a file to be deleted + err := os.Remove(fn) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to remove file", + fmt.Sprintf("The file %s must be renamed as part of the upgrade process, but the old file could not be deleted: %s.", fn, err), + )) + } + continue + } + + err := ioutil.WriteFile(fn, src, 0644) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to write file", + fmt.Sprintf("The file %s must be updated or created as part of the upgrade process, but there was an error while writing: %s.", fn, err), + )) + } + } + + c.showDiagnostics(diags) + if diags.HasErrors() { + return 2 + } + + if !skipConfirm { + if len(diags) != 0 { + c.Ui.Output(`-----------------------------------------------------------------------------`) + } + c.Ui.Output(c.Colorize().Color(` +[bold][green]Upgrade complete![reset] + +The configuration files were upgraded successfully. Use your version control +system to review the proposed changes, make any necessary adjustments, and +then commit. +`)) + if len(diags) != 0 { + // We checked for errors above, so these must be warnings. + c.Ui.Output(`Some warnings were generated during the upgrade, as shown above. These +indicate situations where Terraform could not decide on an appropriate course +of action without further human input. + +Where possible, these have also been marked with TF-UPGRADE-TODO comments to +mark the locations where a decision must be made. After reviewing and adjusting +these, manually remove the TF-UPGRADE-TODO comment before continuing. +`) + } + + } + return 0 +} + +func (c *ZeroTwelveUpgradeCommand) Help() string { + helpText := ` +Usage: terraform 0.12upgrade [module-dir] + + Rewrites the .tf files for a single module that was written for a Terraform + version prior to v0.12 so that it uses new syntax features from v0.12 + and later. + + Also rewrites constructs that behave differently after v0.12, and flags any + suspicious constructs that require human review, + + By default, 0.12upgrade rewrites the files in the current working directory. + However, a path to a different directory can be provided. The command will + prompt for confirmation interactively unless the -yes option is given. + +Options: + + -yes Skip the initial introduction messages and interactive + confirmation. This can be used to run this command in + batch from a script. + + -force Override the heuristic that attempts to detect if a + configuration is already written for v0.12 or later. + Some of the transformations made by this command are + not idempotent, so re-running against the same module + may change the meanings expressions in the module. +` + return strings.TrimSpace(helpText) +} + +func (c *ZeroTwelveUpgradeCommand) Synopsis() string { + return "Rewrites pre-0.12 module source code for v0.12" +} diff --git a/commands.go b/commands.go index 584763387..ac4ca02b0 100644 --- a/commands.go +++ b/commands.go @@ -74,6 +74,7 @@ func initCommands(config *Config, services *disco.Disco) { "debug": struct{}{}, // includes all subcommands "force-unlock": struct{}{}, "push": struct{}{}, + "0.12upgrade": struct{}{}, } Commands = map[string]cli.CommandFactory{ @@ -271,6 +272,12 @@ func initCommands(config *Config, services *disco.Disco) { // Plumbing //----------------------------------------------------------- + "0.12upgrade": func() (cli.Command, error) { + return &command.ZeroTwelveUpgradeCommand{ + Meta: meta, + }, nil + }, + "debug": func() (cli.Command, error) { return &command.DebugCommand{ Meta: meta, diff --git a/configs/configupgrade/upgrade.go b/configs/configupgrade/upgrade.go index 0707a8d4e..09c771b8a 100644 --- a/configs/configupgrade/upgrade.go +++ b/configs/configupgrade/upgrade.go @@ -61,6 +61,7 @@ func Upgrade(input ModuleSources) (ModuleSources, tfdiags.Diagnostics) { ret[name] = nil // mark for deletion oldName := name name = input.UnusedFilename(name + ".json") + ret[name] = src diags = diags.Append(&hcl2.Diagnostic{ Severity: hcl2.DiagWarning, @@ -70,6 +71,7 @@ func Upgrade(input ModuleSources) (ModuleSources, tfdiags.Diagnostics) { oldName, name, ), }) + continue } } @@ -77,7 +79,6 @@ func Upgrade(input ModuleSources) (ModuleSources, tfdiags.Diagnostics) { // We don't do any automatic rewriting for JSON files, since they // are usually generated and thus it's the generating program that // needs to be updated, rather than its output. - ret[name] = src diags = diags.Append(&hcl2.Diagnostic{ Severity: hcl2.DiagWarning, Summary: "JSON configuration file was not rewritten", diff --git a/configs/parser.go b/configs/parser.go index 3d3f07ff1..8176fa1b7 100644 --- a/configs/parser.go +++ b/configs/parser.go @@ -83,3 +83,18 @@ func (p *Parser) LoadHCLFile(path string) (hcl.Body, hcl.Diagnostics) { func (p *Parser) Sources() map[string][]byte { return p.p.Sources() } + +// ForceFileSource artificially adds source code to the cache of file sources, +// as if it had been loaded from the given filename. +// +// This should be used only in special situations where configuration is loaded +// some other way. Most callers should load configuration via methods of +// Parser, which will update the sources cache automatically. +func (p *Parser) ForceFileSource(filename string, src []byte) { + // We'll make a synthetic hcl.File here just so we can reuse the + // existing cache. + p.p.AddFile(filename, &hcl.File{ + Body: hcl.EmptyBody(), + Bytes: src, + }) +}