diff --git a/internal/command/arguments/state_push.go b/internal/command/arguments/state_push.go new file mode 100644 index 0000000000..5c6fed7397 --- /dev/null +++ b/internal/command/arguments/state_push.go @@ -0,0 +1,69 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "time" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// StatePush represents the command-line arguments for the state push command. +type StatePush struct { + // Force writes the state even if lineages don't match or the remote + // serial is higher. + Force bool + + // 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 + + // IgnoreRemoteVersion, if true, continues even if remote and local + // Terraform versions are incompatible. + IgnoreRemoteVersion bool + + // Path is the path to the state file to push, or "-" for stdin. + Path string +} + +// ParseStatePush processes CLI arguments, returning a StatePush value and +// diagnostics. If errors are encountered, a StatePush value is still returned +// representing the best effort interpretation of the arguments. +func ParseStatePush(args []string) (*StatePush, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + push := &StatePush{ + StateLock: true, + } + + cmdFlags := defaultFlagSet("state push") + cmdFlags.BoolVar(&push.Force, "force", false, "") + cmdFlags.BoolVar(&push.StateLock, "lock", true, "lock state") + cmdFlags.DurationVar(&push.StateLockTimeout, "lock-timeout", 0, "lock timeout") + cmdFlags.BoolVar(&push.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) != 1 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Required argument missing", + "Exactly one argument expected: the path to a Terraform state file.", + )) + return push, diags + } + + push.Path = args[0] + + return push, diags +} diff --git a/internal/command/arguments/state_push_test.go b/internal/command/arguments/state_push_test.go new file mode 100644 index 0000000000..5368ba1007 --- /dev/null +++ b/internal/command/arguments/state_push_test.go @@ -0,0 +1,155 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "testing" + "time" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestParseStatePush_valid(t *testing.T) { + testCases := map[string]struct { + args []string + want *StatePush + }{ + "path only": { + []string{"replace.tfstate"}, + &StatePush{ + Force: false, + StateLock: true, + StateLockTimeout: 0, + IgnoreRemoteVersion: false, + Path: "replace.tfstate", + }, + }, + "stdin": { + []string{"-"}, + &StatePush{ + Force: false, + StateLock: true, + StateLockTimeout: 0, + IgnoreRemoteVersion: false, + Path: "-", + }, + }, + "force": { + []string{"-force", "replace.tfstate"}, + &StatePush{ + Force: true, + StateLock: true, + StateLockTimeout: 0, + IgnoreRemoteVersion: false, + Path: "replace.tfstate", + }, + }, + "lock disabled": { + []string{"-lock=false", "replace.tfstate"}, + &StatePush{ + Force: false, + StateLock: false, + StateLockTimeout: 0, + IgnoreRemoteVersion: false, + Path: "replace.tfstate", + }, + }, + "lock timeout": { + []string{"-lock-timeout=5s", "replace.tfstate"}, + &StatePush{ + Force: false, + StateLock: true, + StateLockTimeout: 5 * time.Second, + IgnoreRemoteVersion: false, + Path: "replace.tfstate", + }, + }, + "ignore remote version": { + []string{"-ignore-remote-version", "replace.tfstate"}, + &StatePush{ + Force: false, + StateLock: true, + StateLockTimeout: 0, + IgnoreRemoteVersion: true, + Path: "replace.tfstate", + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseStatePush(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 TestParseStatePush_invalid(t *testing.T) { + testCases := map[string]struct { + args []string + want *StatePush + wantDiags tfdiags.Diagnostics + }{ + "no arguments": { + nil, + &StatePush{ + StateLock: true, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Required argument missing", + "Exactly one argument expected: the path to a Terraform state file.", + ), + }, + }, + "too many arguments": { + []string{"foo.tfstate", "bar.tfstate"}, + &StatePush{ + StateLock: true, + Path: "", + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Required argument missing", + "Exactly one argument expected: the path to a Terraform state file.", + ), + }, + }, + "unknown flag": { + []string{"-boop"}, + &StatePush{ + 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 one argument expected: the path to a Terraform state file.", + ), + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, gotDiags := ParseStatePush(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_push.go b/internal/command/state_push.go index ea4e805a77..b99ca05cf3 100644 --- a/internal/command/state_push.go +++ b/internal/command/state_push.go @@ -9,7 +9,6 @@ import ( "os" "strings" - "github.com/hashicorp/cli" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" @@ -26,22 +25,15 @@ type StatePushCommand struct { } func (c *StatePushCommand) Run(args []string) int { - args = c.Meta.process(args) - var flagForce bool - cmdFlags := c.Meta.ignoreRemoteVersionFlagSet("state push") - cmdFlags.BoolVar(&flagForce, "force", false, "") - cmdFlags.BoolVar(&c.Meta.stateLock, "lock", true, "lock state") - cmdFlags.DurationVar(&c.Meta.stateLockTimeout, "lock-timeout", 0, "lock timeout") - if err := cmdFlags.Parse(args); err != nil { - c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error())) + parsedArgs, diags := arguments.ParseStatePush(c.Meta.process(args)) + if diags.HasErrors() { + c.showDiagnostics(diags) return 1 } - args = cmdFlags.Args() - if len(args) != 1 { - c.Ui.Error("Exactly one argument expected.\n") - return cli.RunResultHelp - } + c.Meta.stateLock = parsedArgs.StateLock + c.Meta.stateLockTimeout = parsedArgs.StateLockTimeout + c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion if diags := c.Meta.checkRequiredVersion(); diags != nil { c.showDiagnostics(diags) @@ -51,8 +43,8 @@ func (c *StatePushCommand) Run(args []string) int { // Determine our reader for the input state. This is the filepath // or stdin if "-" is given. var r io.Reader = os.Stdin - if args[0] != "-" { - f, err := os.Open(args[0]) + if parsedArgs.Path != "-" { + f, err := os.Open(parsedArgs.Path) if err != nil { c.Ui.Error(err.Error()) return 1 @@ -71,7 +63,7 @@ func (c *StatePushCommand) Run(args []string) int { c.Close() } if err != nil { - c.Ui.Error(fmt.Sprintf("Error reading source state %q: %s", args[0], err)) + c.Ui.Error(fmt.Sprintf("Error reading source state %q: %s", parsedArgs.Path, err)) return 1 } @@ -128,7 +120,7 @@ func (c *StatePushCommand) Run(args []string) int { } // Import it, forcing through the lineage/serial if requested and possible. - if err := statemgr.Import(srcStateFile, stateMgr, flagForce); err != nil { + if err := statemgr.Import(srcStateFile, stateMgr, parsedArgs.Force); err != nil { c.Ui.Error(fmt.Sprintf("Failed to write state: %s", err)) return 1 }