diff --git a/internal/command/arguments/state_mv.go b/internal/command/arguments/state_mv.go new file mode 100644 index 0000000000..146f3d8a26 --- /dev/null +++ b/internal/command/arguments/state_mv.go @@ -0,0 +1,93 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "time" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// StateMv represents the command-line arguments for the state mv command. +type StateMv struct { + // DryRun, if true, prints out what would be moved without actually + // moving anything. + DryRun bool + + // BackupPath is the path where Terraform should write the backup state. + BackupPath string + + // BackupOutPath is the path where Terraform should write the backup of + // the destination state. + BackupOutPath string + + // StateLock, if true, requests that the backend lock the state for this + // operation. + StateLock bool + + // StateLockTimeout is the duration to retry a state lock. + StateLockTimeout time.Duration + + // StatePath is an optional path to a local state file. + StatePath string + + // StateOutPath is an optional path to write the destination state. + StateOutPath string + + // IgnoreRemoteVersion, if true, continues even if remote and local + // Terraform versions are incompatible. + IgnoreRemoteVersion bool + + // SourceAddr is the source resource address. + SourceAddr string + + // DestAddr is the destination resource address. + DestAddr string +} + +// ParseStateMv processes CLI arguments, returning a StateMv value and +// diagnostics. If errors are encountered, a StateMv value is still returned +// representing the best effort interpretation of the arguments. +func ParseStateMv(args []string) (*StateMv, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + mv := &StateMv{ + StateLock: true, + } + + cmdFlags := defaultFlagSet("state mv") + cmdFlags.BoolVar(&mv.DryRun, "dry-run", false, "dry run") + cmdFlags.StringVar(&mv.BackupPath, "backup", "-", "backup") + cmdFlags.StringVar(&mv.BackupOutPath, "backup-out", "-", "backup") + cmdFlags.BoolVar(&mv.StateLock, "lock", true, "lock states") + cmdFlags.DurationVar(&mv.StateLockTimeout, "lock-timeout", 0, "lock timeout") + cmdFlags.StringVar(&mv.StatePath, "state", "", "path") + cmdFlags.StringVar(&mv.StateOutPath, "state-out", "", "path") + cmdFlags.BoolVar(&mv.IgnoreRemoteVersion, "ignore-remote-version", false, "continue even if remote and local Terraform versions are incompatible") + + if err := cmdFlags.Parse(args); err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + err.Error(), + )) + } + + args = cmdFlags.Args() + if len(args) != 2 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Required argument missing", + "Exactly two arguments expected: the source and destination addresses.", + )) + } + + if len(args) > 0 { + mv.SourceAddr = args[0] + } + if len(args) > 1 { + mv.DestAddr = args[1] + } + + return mv, diags +} diff --git a/internal/command/arguments/state_mv_test.go b/internal/command/arguments/state_mv_test.go new file mode 100644 index 0000000000..acaad320df --- /dev/null +++ b/internal/command/arguments/state_mv_test.go @@ -0,0 +1,174 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "testing" + "time" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestParseStateMv_valid(t *testing.T) { + testCases := map[string]struct { + args []string + want *StateMv + }{ + "addresses only": { + []string{"test_instance.foo", "test_instance.bar"}, + &StateMv{ + DryRun: false, + BackupPath: "-", + BackupOutPath: "-", + StateLock: true, + StateLockTimeout: 0, + StatePath: "", + StateOutPath: "", + IgnoreRemoteVersion: false, + SourceAddr: "test_instance.foo", + DestAddr: "test_instance.bar", + }, + }, + "dry run": { + []string{"-dry-run", "test_instance.foo", "test_instance.bar"}, + &StateMv{ + DryRun: true, + BackupPath: "-", + BackupOutPath: "-", + StateLock: true, + StateLockTimeout: 0, + StatePath: "", + StateOutPath: "", + IgnoreRemoteVersion: false, + SourceAddr: "test_instance.foo", + DestAddr: "test_instance.bar", + }, + }, + "all options": { + []string{ + "-dry-run", + "-backup=backup.tfstate", + "-backup-out=backup-out.tfstate", + "-lock=false", + "-lock-timeout=5s", + "-state=state.tfstate", + "-state-out=state-out.tfstate", + "-ignore-remote-version", + "test_instance.foo", + "test_instance.bar", + }, + &StateMv{ + DryRun: true, + BackupPath: "backup.tfstate", + BackupOutPath: "backup-out.tfstate", + StateLock: false, + StateLockTimeout: 5 * time.Second, + StatePath: "state.tfstate", + StateOutPath: "state-out.tfstate", + IgnoreRemoteVersion: true, + SourceAddr: "test_instance.foo", + DestAddr: "test_instance.bar", + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseStateMv(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if *got != *tc.want { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + } + }) + } +} + +func TestParseStateMv_invalid(t *testing.T) { + testCases := map[string]struct { + args []string + want *StateMv + wantDiags tfdiags.Diagnostics + }{ + "no arguments": { + nil, + &StateMv{ + BackupPath: "-", + BackupOutPath: "-", + StateLock: true, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Required argument missing", + "Exactly two arguments expected: the source and destination addresses.", + ), + }, + }, + "one argument": { + []string{"test_instance.foo"}, + &StateMv{ + BackupPath: "-", + BackupOutPath: "-", + StateLock: true, + SourceAddr: "test_instance.foo", + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Required argument missing", + "Exactly two arguments expected: the source and destination addresses.", + ), + }, + }, + "too many arguments": { + []string{"a", "b", "c"}, + &StateMv{ + BackupPath: "-", + BackupOutPath: "-", + StateLock: true, + SourceAddr: "a", + DestAddr: "b", + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Required argument missing", + "Exactly two arguments expected: the source and destination addresses.", + ), + }, + }, + "unknown flag": { + []string{"-boop"}, + &StateMv{ + BackupPath: "-", + BackupOutPath: "-", + StateLock: true, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + "flag provided but not defined: -boop", + ), + tfdiags.Sourceless( + tfdiags.Error, + "Required argument missing", + "Exactly two arguments expected: the source and destination addresses.", + ), + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, gotDiags := ParseStateMv(tc.args) + if *got != *tc.want { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + } + tfdiags.AssertDiagnosticsMatch(t, gotDiags, tc.wantDiags) + }) + } +} diff --git a/internal/command/state_mv.go b/internal/command/state_mv.go index 7936b6c1ce..64d47e6eba 100644 --- a/internal/command/state_mv.go +++ b/internal/command/state_mv.go @@ -7,8 +7,6 @@ import ( "fmt" "strings" - "github.com/hashicorp/cli" - "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend/backendrun" "github.com/hashicorp/terraform/internal/command/arguments" @@ -26,28 +24,17 @@ type StateMvCommand struct { } func (c *StateMvCommand) Run(args []string) int { - args = c.Meta.process(args) - // We create two metas to track the two states - var backupPathOut, statePathOut string - - var dryRun bool - cmdFlags := c.Meta.ignoreRemoteVersionFlagSet("state mv") - cmdFlags.BoolVar(&dryRun, "dry-run", false, "dry run") - cmdFlags.StringVar(&c.backupPath, "backup", "-", "backup") - cmdFlags.StringVar(&backupPathOut, "backup-out", "-", "backup") - cmdFlags.BoolVar(&c.Meta.stateLock, "lock", true, "lock states") - cmdFlags.DurationVar(&c.Meta.stateLockTimeout, "lock-timeout", 0, "lock timeout") - cmdFlags.StringVar(&c.statePath, "state", "", "path") - cmdFlags.StringVar(&statePathOut, "state-out", "", "path") - if err := cmdFlags.Parse(args); err != nil { - c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error())) + parsedArgs, parseDiags := arguments.ParseStateMv(c.Meta.process(args)) + if parseDiags.HasErrors() { + c.showDiagnostics(parseDiags) return 1 } - args = cmdFlags.Args() - if len(args) != 2 { - c.Ui.Error("Exactly two arguments expected.\n") - return cli.RunResultHelp - } + + c.backupPath = parsedArgs.BackupPath + c.Meta.stateLock = parsedArgs.StateLock + c.Meta.stateLockTimeout = parsedArgs.StateLockTimeout + c.statePath = parsedArgs.StatePath + c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion if diags := c.Meta.checkRequiredVersion(); diags != nil { c.showDiagnostics(diags) @@ -58,7 +45,7 @@ func (c *StateMvCommand) Run(args []string) int { // and the state option is not set, make sure // the backend is local backupOptionSetWithoutStateOption := c.backupPath != "-" && c.statePath == "" - backupOutOptionSetWithoutStateOption := backupPathOut != "-" && c.statePath == "" + backupOutOptionSetWithoutStateOption := parsedArgs.BackupOutPath != "-" && c.statePath == "" var setLegacyLocalBackendOptions []string if backupOptionSetWithoutStateOption { @@ -127,9 +114,9 @@ func (c *StateMvCommand) Run(args []string) int { stateToMgr := stateFromMgr stateTo := stateFrom - if statePathOut != "" { - c.statePath = statePathOut - c.backupPath = backupPathOut + if parsedArgs.StateOutPath != "" { + c.statePath = parsedArgs.StateOutPath + c.backupPath = parsedArgs.BackupOutPath stateToMgr, err = c.State(view) if err != nil { @@ -162,9 +149,9 @@ func (c *StateMvCommand) Run(args []string) int { } var diags tfdiags.Diagnostics - sourceAddr, moreDiags := c.lookupSingleStateObjectAddr(stateFrom, args[0]) + sourceAddr, moreDiags := c.lookupSingleStateObjectAddr(stateFrom, parsedArgs.SourceAddr) diags = diags.Append(moreDiags) - destAddr, moreDiags := c.lookupSingleStateObjectAddr(stateFrom, args[1]) + destAddr, moreDiags := c.lookupSingleStateObjectAddr(stateFrom, parsedArgs.DestAddr) diags = diags.Append(moreDiags) if diags.HasErrors() { c.showDiagnostics(diags) @@ -172,7 +159,7 @@ func (c *StateMvCommand) Run(args []string) int { } prefix := "Move" - if dryRun { + if parsedArgs.DryRun { prefix = "Would move" } @@ -231,7 +218,7 @@ func (c *StateMvCommand) Run(args []string) int { moved++ c.Ui.Output(fmt.Sprintf("%s %q to %q", prefix, addrFrom.String(), addrTo.String())) - if !dryRun { + if !parsedArgs.DryRun { ssFrom.RemoveModule(addrFrom) // Update the address before adding it to the state. @@ -276,7 +263,7 @@ func (c *StateMvCommand) Run(args []string) int { moved++ c.Ui.Output(fmt.Sprintf("%s %q to %q", prefix, addrFrom.String(), addrTo.String())) - if !dryRun { + if !parsedArgs.DryRun { ssFrom.RemoveResource(addrFrom) // Update the address before adding it to the state. @@ -329,8 +316,8 @@ func (c *StateMvCommand) Run(args []string) int { } moved++ - c.Ui.Output(fmt.Sprintf("%s %q to %q", prefix, addrFrom.String(), args[1])) - if !dryRun { + c.Ui.Output(fmt.Sprintf("%s %q to %q", prefix, addrFrom.String(), parsedArgs.DestAddr)) + if !parsedArgs.DryRun { fromResourceAddr := addrFrom.ContainingResource() fromResource := ssFrom.Resource(fromResourceAddr) fromProviderAddr := fromResource.ProviderConfig @@ -385,7 +372,7 @@ func (c *StateMvCommand) Run(args []string) int { } } - if dryRun { + if parsedArgs.DryRun { if moved == 0 { c.Ui.Output("Would have moved nothing.") }