refactor state-push command argument parsing

This commit is contained in:
Daniel Schmidt 2026-02-13 14:43:54 +01:00
parent b9f6f14003
commit 442126553b
No known key found for this signature in database
GPG key ID: 377C3A4D62FBBBE2
3 changed files with 234 additions and 18 deletions

View file

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

View file

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

View file

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