diff --git a/internal/command/arguments/taint.go b/internal/command/arguments/taint.go new file mode 100644 index 0000000000..0741e7a268 --- /dev/null +++ b/internal/command/arguments/taint.go @@ -0,0 +1,88 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "time" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// Taint represents the command-line arguments for the taint command. +type Taint struct { + // Address is the address of the resource instance to taint. + Address string + + // AllowMissing, if true, means the command will succeed even if the + // resource is not found in state. + AllowMissing bool + + // BackupPath is the path to backup the existing state file before + // modifying. + BackupPath string + + // StateLock, if true, locks the state file during operations. + StateLock bool + + // StateLockTimeout is the duration to retry a state lock. + StateLockTimeout time.Duration + + // StatePath is the path to the state file to read and modify. + StatePath string + + // StateOutPath is the path to write the updated state file. + StateOutPath string + + // IgnoreRemoteVersion, if true, continues even if remote and local + // Terraform versions are incompatible. + IgnoreRemoteVersion bool +} + +// ParseTaint processes CLI arguments, returning a Taint value and errors. +// If errors are encountered, a Taint value is still returned representing +// the best effort interpretation of the arguments. +func ParseTaint(args []string) (*Taint, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + taint := &Taint{ + StateLock: true, + } + + cmdFlags := defaultFlagSet("taint") + cmdFlags.BoolVar(&taint.AllowMissing, "allow-missing", false, "allow missing") + cmdFlags.StringVar(&taint.BackupPath, "backup", "", "path") + cmdFlags.BoolVar(&taint.StateLock, "lock", true, "lock state") + cmdFlags.DurationVar(&taint.StateLockTimeout, "lock-timeout", 0, "lock timeout") + cmdFlags.StringVar(&taint.StatePath, "state", "", "path") + cmdFlags.StringVar(&taint.StateOutPath, "state-out", "", "path") + cmdFlags.BoolVar(&taint.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) == 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Required argument missing", + "The taint command expects exactly one argument: the address of the resource to taint.", + )) + } else if len(args) > 1 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "The taint command expects exactly one argument: the address of the resource to taint.", + )) + } + + if len(args) > 0 { + taint.Address = args[0] + } + + return taint, diags +} diff --git a/internal/command/arguments/taint_test.go b/internal/command/arguments/taint_test.go new file mode 100644 index 0000000000..a33314ee99 --- /dev/null +++ b/internal/command/arguments/taint_test.go @@ -0,0 +1,179 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "testing" + "time" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestParseTaint_valid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Taint + }{ + "defaults with address": { + []string{"test_instance.foo"}, + &Taint{ + Address: "test_instance.foo", + StateLock: true, + }, + }, + "allow-missing": { + []string{"-allow-missing", "test_instance.foo"}, + &Taint{ + Address: "test_instance.foo", + AllowMissing: true, + StateLock: true, + }, + }, + "backup": { + []string{"-backup", "backup.tfstate", "test_instance.foo"}, + &Taint{ + Address: "test_instance.foo", + BackupPath: "backup.tfstate", + StateLock: true, + }, + }, + "lock disabled": { + []string{"-lock=false", "test_instance.foo"}, + &Taint{ + Address: "test_instance.foo", + StateLock: false, + }, + }, + "lock-timeout": { + []string{"-lock-timeout=10s", "test_instance.foo"}, + &Taint{ + Address: "test_instance.foo", + StateLock: true, + StateLockTimeout: 10 * time.Second, + }, + }, + "state": { + []string{"-state=foo.tfstate", "test_instance.foo"}, + &Taint{ + Address: "test_instance.foo", + StateLock: true, + StatePath: "foo.tfstate", + }, + }, + "state-out": { + []string{"-state-out=foo.tfstate", "test_instance.foo"}, + &Taint{ + Address: "test_instance.foo", + StateLock: true, + StateOutPath: "foo.tfstate", + }, + }, + "ignore-remote-version": { + []string{"-ignore-remote-version", "test_instance.foo"}, + &Taint{ + Address: "test_instance.foo", + StateLock: true, + IgnoreRemoteVersion: true, + }, + }, + "all flags": { + []string{ + "-allow-missing", + "-backup=backup.tfstate", + "-lock=false", + "-lock-timeout=10s", + "-state=foo.tfstate", + "-state-out=bar.tfstate", + "-ignore-remote-version", + "module.child.test_instance.foo", + }, + &Taint{ + Address: "module.child.test_instance.foo", + AllowMissing: true, + BackupPath: "backup.tfstate", + StateLock: false, + StateLockTimeout: 10 * time.Second, + StatePath: "foo.tfstate", + StateOutPath: "bar.tfstate", + IgnoreRemoteVersion: true, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseTaint(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 TestParseTaint_invalid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Taint + wantDiags tfdiags.Diagnostics + }{ + "unknown flag": { + []string{"-unknown"}, + &Taint{ + StateLock: true, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + "flag provided but not defined: -unknown", + ), + tfdiags.Sourceless( + tfdiags.Error, + "Required argument missing", + "The taint command expects exactly one argument: the address of the resource to taint.", + ), + }, + }, + "missing address": { + nil, + &Taint{ + StateLock: true, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Required argument missing", + "The taint command expects exactly one argument: the address of the resource to taint.", + ), + }, + }, + "too many arguments": { + []string{"test_instance.foo", "test_instance.bar"}, + &Taint{ + Address: "test_instance.foo", + StateLock: true, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "The taint command expects exactly one argument: the address of the resource to taint.", + ), + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, gotDiags := ParseTaint(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/arguments/untaint.go b/internal/command/arguments/untaint.go new file mode 100644 index 0000000000..d4e1a4f005 --- /dev/null +++ b/internal/command/arguments/untaint.go @@ -0,0 +1,88 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "time" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// Untaint represents the command-line arguments for the untaint command. +type Untaint struct { + // Address is the address of the resource instance to untaint. + Address string + + // AllowMissing, if true, means the command will succeed even if the + // resource is not found in state. + AllowMissing bool + + // BackupPath is the path to backup the existing state file before + // modifying. + BackupPath string + + // StateLock, if true, locks the state file during operations. + StateLock bool + + // StateLockTimeout is the duration to retry a state lock. + StateLockTimeout time.Duration + + // StatePath is the path to the state file to read and modify. + StatePath string + + // StateOutPath is the path to write the updated state file. + StateOutPath string + + // IgnoreRemoteVersion, if true, continues even if remote and local + // Terraform versions are incompatible. + IgnoreRemoteVersion bool +} + +// ParseUntaint processes CLI arguments, returning an Untaint value and errors. +// If errors are encountered, an Untaint value is still returned representing +// the best effort interpretation of the arguments. +func ParseUntaint(args []string) (*Untaint, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + untaint := &Untaint{ + StateLock: true, + } + + cmdFlags := defaultFlagSet("untaint") + cmdFlags.BoolVar(&untaint.AllowMissing, "allow-missing", false, "allow missing") + cmdFlags.StringVar(&untaint.BackupPath, "backup", "", "path") + cmdFlags.BoolVar(&untaint.StateLock, "lock", true, "lock state") + cmdFlags.DurationVar(&untaint.StateLockTimeout, "lock-timeout", 0, "lock timeout") + cmdFlags.StringVar(&untaint.StatePath, "state", "", "path") + cmdFlags.StringVar(&untaint.StateOutPath, "state-out", "", "path") + cmdFlags.BoolVar(&untaint.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) == 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Required argument missing", + "The untaint command expects exactly one argument: the address of the resource to untaint.", + )) + } else if len(args) > 1 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "The untaint command expects exactly one argument: the address of the resource to untaint.", + )) + } + + if len(args) > 0 { + untaint.Address = args[0] + } + + return untaint, diags +} diff --git a/internal/command/arguments/untaint_test.go b/internal/command/arguments/untaint_test.go new file mode 100644 index 0000000000..3d82c3bbfb --- /dev/null +++ b/internal/command/arguments/untaint_test.go @@ -0,0 +1,179 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package arguments + +import ( + "testing" + "time" + + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func TestParseUntaint_valid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Untaint + }{ + "defaults with address": { + []string{"test_instance.foo"}, + &Untaint{ + Address: "test_instance.foo", + StateLock: true, + }, + }, + "allow-missing": { + []string{"-allow-missing", "test_instance.foo"}, + &Untaint{ + Address: "test_instance.foo", + AllowMissing: true, + StateLock: true, + }, + }, + "backup": { + []string{"-backup", "backup.tfstate", "test_instance.foo"}, + &Untaint{ + Address: "test_instance.foo", + BackupPath: "backup.tfstate", + StateLock: true, + }, + }, + "lock disabled": { + []string{"-lock=false", "test_instance.foo"}, + &Untaint{ + Address: "test_instance.foo", + StateLock: false, + }, + }, + "lock-timeout": { + []string{"-lock-timeout=10s", "test_instance.foo"}, + &Untaint{ + Address: "test_instance.foo", + StateLock: true, + StateLockTimeout: 10 * time.Second, + }, + }, + "state": { + []string{"-state=foo.tfstate", "test_instance.foo"}, + &Untaint{ + Address: "test_instance.foo", + StateLock: true, + StatePath: "foo.tfstate", + }, + }, + "state-out": { + []string{"-state-out=foo.tfstate", "test_instance.foo"}, + &Untaint{ + Address: "test_instance.foo", + StateLock: true, + StateOutPath: "foo.tfstate", + }, + }, + "ignore-remote-version": { + []string{"-ignore-remote-version", "test_instance.foo"}, + &Untaint{ + Address: "test_instance.foo", + StateLock: true, + IgnoreRemoteVersion: true, + }, + }, + "all flags": { + []string{ + "-allow-missing", + "-backup=backup.tfstate", + "-lock=false", + "-lock-timeout=10s", + "-state=foo.tfstate", + "-state-out=bar.tfstate", + "-ignore-remote-version", + "module.child.test_instance.foo", + }, + &Untaint{ + Address: "module.child.test_instance.foo", + AllowMissing: true, + BackupPath: "backup.tfstate", + StateLock: false, + StateLockTimeout: 10 * time.Second, + StatePath: "foo.tfstate", + StateOutPath: "bar.tfstate", + IgnoreRemoteVersion: true, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseUntaint(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 TestParseUntaint_invalid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Untaint + wantDiags tfdiags.Diagnostics + }{ + "unknown flag": { + []string{"-unknown"}, + &Untaint{ + StateLock: true, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + "flag provided but not defined: -unknown", + ), + tfdiags.Sourceless( + tfdiags.Error, + "Required argument missing", + "The untaint command expects exactly one argument: the address of the resource to untaint.", + ), + }, + }, + "missing address": { + nil, + &Untaint{ + StateLock: true, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Required argument missing", + "The untaint command expects exactly one argument: the address of the resource to untaint.", + ), + }, + }, + "too many arguments": { + []string{"test_instance.foo", "test_instance.bar"}, + &Untaint{ + Address: "test_instance.foo", + StateLock: true, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "The untaint command expects exactly one argument: the address of the resource to untaint.", + ), + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, gotDiags := ParseUntaint(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/taint.go b/internal/command/taint.go index c666aabac2..ba9ef4c82d 100644 --- a/internal/command/taint.go +++ b/internal/command/taint.go @@ -22,33 +22,24 @@ type TaintCommand struct { Meta } -func (c *TaintCommand) Run(args []string) int { - args = c.Meta.process(args) - var allowMissing bool - cmdFlags := c.Meta.ignoreRemoteVersionFlagSet("taint") - cmdFlags.BoolVar(&allowMissing, "allow-missing", false, "allow missing") - cmdFlags.StringVar(&c.Meta.backupPath, "backup", "", "path") - cmdFlags.BoolVar(&c.Meta.stateLock, "lock", true, "lock state") - cmdFlags.DurationVar(&c.Meta.stateLockTimeout, "lock-timeout", 0, "lock timeout") - cmdFlags.StringVar(&c.Meta.statePath, "state", "", "path") - cmdFlags.StringVar(&c.Meta.stateOutPath, "state-out", "", "path") - cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } - if err := cmdFlags.Parse(args); err != nil { - c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error())) +func (c *TaintCommand) Run(rawArgs []string) int { + parsedArgs, parseDiags := arguments.ParseTaint(c.Meta.process(rawArgs)) + if parseDiags.HasErrors() { + c.showDiagnostics(parseDiags) return 1 } + // Copy parsed flags to Meta + c.Meta.backupPath = parsedArgs.BackupPath + c.Meta.stateLock = parsedArgs.StateLock + c.Meta.stateLockTimeout = parsedArgs.StateLockTimeout + c.Meta.statePath = parsedArgs.StatePath + c.Meta.stateOutPath = parsedArgs.StateOutPath + c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion + var diags tfdiags.Diagnostics - // Require the one argument for the resource to taint - args = cmdFlags.Args() - if len(args) != 1 { - c.Ui.Error("The taint command expects exactly one argument.") - cmdFlags.Usage() - return 1 - } - - addr, addrDiags := addrs.ParseAbsResourceInstanceStr(args[0]) + addr, addrDiags := addrs.ParseAbsResourceInstanceStr(parsedArgs.Address) diags = diags.Append(addrDiags) if addrDiags.HasErrors() { c.showDiagnostics(diags) @@ -117,7 +108,7 @@ func (c *TaintCommand) Run(args []string) int { // Get the actual state structure state := stateMgr.State() if state.Empty() { - if allowMissing { + if parsedArgs.AllowMissing { return c.allowMissingExit(addr) } @@ -144,7 +135,7 @@ func (c *TaintCommand) Run(args []string) int { rs := ss.Resource(addr.ContainingResource()) is := ss.ResourceInstance(addr) if is == nil { - if allowMissing { + if parsedArgs.AllowMissing { return c.allowMissingExit(addr) } diff --git a/internal/command/untaint.go b/internal/command/untaint.go index 3e01829686..b8d95b04f7 100644 --- a/internal/command/untaint.go +++ b/internal/command/untaint.go @@ -22,33 +22,24 @@ type UntaintCommand struct { Meta } -func (c *UntaintCommand) Run(args []string) int { - args = c.Meta.process(args) - var allowMissing bool - cmdFlags := c.Meta.ignoreRemoteVersionFlagSet("untaint") - cmdFlags.BoolVar(&allowMissing, "allow-missing", false, "allow missing") - cmdFlags.StringVar(&c.Meta.backupPath, "backup", "", "path") - cmdFlags.BoolVar(&c.Meta.stateLock, "lock", true, "lock state") - cmdFlags.DurationVar(&c.Meta.stateLockTimeout, "lock-timeout", 0, "lock timeout") - cmdFlags.StringVar(&c.Meta.statePath, "state", "", "path") - cmdFlags.StringVar(&c.Meta.stateOutPath, "state-out", "", "path") - cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } - if err := cmdFlags.Parse(args); err != nil { - c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error())) +func (c *UntaintCommand) Run(rawArgs []string) int { + parsedArgs, parseDiags := arguments.ParseUntaint(c.Meta.process(rawArgs)) + if parseDiags.HasErrors() { + c.showDiagnostics(parseDiags) return 1 } + // Copy parsed flags to Meta + c.Meta.backupPath = parsedArgs.BackupPath + c.Meta.stateLock = parsedArgs.StateLock + c.Meta.stateLockTimeout = parsedArgs.StateLockTimeout + c.Meta.statePath = parsedArgs.StatePath + c.Meta.stateOutPath = parsedArgs.StateOutPath + c.Meta.ignoreRemoteVersion = parsedArgs.IgnoreRemoteVersion + var diags tfdiags.Diagnostics - // Require the one argument for the resource to untaint - args = cmdFlags.Args() - if len(args) != 1 { - c.Ui.Error("The untaint command expects exactly one argument.") - cmdFlags.Usage() - return 1 - } - - addr, addrDiags := addrs.ParseAbsResourceInstanceStr(args[0]) + addr, addrDiags := addrs.ParseAbsResourceInstanceStr(parsedArgs.Address) diags = diags.Append(addrDiags) if addrDiags.HasErrors() { c.showDiagnostics(diags) @@ -107,7 +98,7 @@ func (c *UntaintCommand) Run(args []string) int { // Get the actual state structure state := stateMgr.State() if state.Empty() { - if allowMissing { + if parsedArgs.AllowMissing { return c.allowMissingExit(addr) } @@ -126,7 +117,7 @@ func (c *UntaintCommand) Run(args []string) int { rs := ss.Resource(addr.ContainingResource()) is := ss.ResourceInstance(addr) if is == nil { - if allowMissing { + if parsedArgs.AllowMissing { return c.allowMissingExit(addr) }