refactor state-mv command argument parsing

This commit is contained in:
Daniel Schmidt 2026-02-13 14:43:54 +01:00
parent 52bbc57c62
commit 9f3c282096
No known key found for this signature in database
GPG key ID: 377C3A4D62FBBBE2
3 changed files with 288 additions and 34 deletions

View file

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

View file

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

View file

@ -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.")
}