Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Handling of incorrectly used flag #3901

Merged
merged 6 commits into from
Feb 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (

"github.com/gruntwork-io/terragrunt/cli/commands"
"github.com/gruntwork-io/terragrunt/cli/commands/graph"
"github.com/gruntwork-io/terragrunt/cli/flags"
"github.com/gruntwork-io/terragrunt/cli/flags/global"

"github.com/gruntwork-io/go-commons/version"
Expand Down Expand Up @@ -49,6 +50,8 @@ type App struct {

// NewApp creates the Terragrunt CLI App.
func NewApp(opts *options.TerragruntOptions) *App {
terragruntCommands := commands.New(opts)

app := cli.NewApp()
app.Name = "terragrunt"
app.Usage = "Terragrunt is a flexible orchestration tool that allows Infrastructure as Code written in OpenTofu/Terraform to scale.\nFor documentation, see https://terragrunt.gruntwork.io/."
Expand All @@ -57,10 +60,11 @@ func NewApp(opts *options.TerragruntOptions) *App {
app.Writer = opts.Writer
app.ErrWriter = opts.ErrWriter
app.Flags = global.NewFlagsWithDeprecatedMovedFlags(opts)
app.Commands = commands.New(opts).WrapAction(WrapWithTelemetry(opts))
app.Commands = terragruntCommands.WrapAction(WrapWithTelemetry(opts))
app.Before = beforeAction(opts)
app.OsExiter = OSExiter
app.ExitErrHandler = ExitErrHandler
app.FlagErrHandler = flags.ErrorHandler(terragruntCommands)

return &App{app, opts}
}
Expand Down
4 changes: 2 additions & 2 deletions cli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,13 @@ func TestParseTerragruntOptionsFromArgs(t *testing.T) {
{
[]string{"--foo", "--bar"},
mockOptions(t, util.JoinPath(workingDir, config.DefaultTerragruntConfigPath), workingDir, []string{"-foo", "-bar"}, false, "", false, false, defaultLogLevel, false),
clipkg.UndefinedFlagError("flag provided but not defined: -foo"),
clipkg.UndefinedFlagError("foo"),
},

{
[]string{"--foo", "apply", "--bar"},
mockOptions(t, util.JoinPath(workingDir, config.DefaultTerragruntConfigPath), workingDir, []string{"apply", "-foo", "-bar"}, false, "", false, false, defaultLogLevel, false),
clipkg.UndefinedFlagError("flag provided but not defined: -foo"),
clipkg.UndefinedFlagError("foo"),
},

{
Expand Down
64 changes: 64 additions & 0 deletions cli/flags/error_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package flags

import (
"slices"
"strings"

"github.com/gruntwork-io/terragrunt/internal/cli"
"github.com/gruntwork-io/terragrunt/internal/errors"
"github.com/gruntwork-io/terragrunt/util"
)

// ErrorHandler returns `FlagErrHandlerFunc` which takes a flag parsing error
// and tries to suggest the correct command to use with this flag. Otherwise returns the error as is.
func ErrorHandler(commands cli.Commands) cli.FlagErrHandlerFunc {
return func(ctx *cli.Context, err error) error {
var undefinedFlagErr cli.UndefinedFlagError
if !errors.As(err, &undefinedFlagErr) {
return err
}

undefFlag := string(undefinedFlagErr)

if cmds, flag := findFlagInCommands(commands, undefFlag); cmds != nil {
var (
flagHint = util.FirstElement(util.RemoveEmptyElements(flag.Names()))
cmdHint = strings.Join(cmds.Names(), " ")
)

if ctx.Parent().Command == nil {
return NewGlobalFlagHintError(undefFlag, cmdHint, flagHint)
}

return NewCommandFlagHintError(ctx.Command.Name, undefFlag, cmdHint, flagHint)
}

return err
}
}

func findFlagInCommands(commands cli.Commands, undefFlag string) (cli.Commands, cli.Flag) {
if len(commands) == 0 {
return nil, nil
}

for _, cmd := range commands {
for _, flag := range cmd.Flags {
flagNames := flag.Names()

if flag, ok := flag.(interface{ DeprecatedNames() []string }); ok {
flagNames = append(flagNames, flag.DeprecatedNames()...)
}

if slices.Contains(flagNames, undefFlag) {
return cli.Commands{cmd}, flag
}
}

if cmds, flag := findFlagInCommands(cmd.Subcommands, undefFlag); cmds != nil {
return append(cli.Commands{cmd}, cmds...), flag
}
}

return nil, nil
}
45 changes: 45 additions & 0 deletions cli/flags/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package flags

import "fmt"

var _ error = new(GlobalFlagHintError)

type GlobalFlagHintError struct {
undefFlag string
cmdHint string
flagHint string
}

func NewGlobalFlagHintError(undefFlag, cmdHint, flagHint string) *GlobalFlagHintError {
return &GlobalFlagHintError{
undefFlag: undefFlag,
cmdHint: cmdHint,
flagHint: flagHint,
}
}

func (err GlobalFlagHintError) Error() string {
return fmt.Sprintf("flag `--%s` is not a valid global flag. Did you mean to use `%s --%s`?", err.undefFlag, err.cmdHint, err.flagHint)
}

var _ error = new(CommandFlagHintError)

type CommandFlagHintError struct {
undefFlag string
wrongCmd string
cmdHint string
flagHint string
}

func NewCommandFlagHintError(wrongCmd, undefFlag, cmdHint, flagHint string) *CommandFlagHintError {
return &CommandFlagHintError{
undefFlag: undefFlag,
wrongCmd: wrongCmd,
cmdHint: cmdHint,
flagHint: flagHint,
}
}

func (err CommandFlagHintError) Error() string {
return fmt.Sprintf("flag `--%s` is not a valid flag for `%s`. Did you mean to use `%s --%s`?", err.undefFlag, err.wrongCmd, err.cmdHint, err.flagHint)
}
15 changes: 15 additions & 0 deletions cli/flags/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ func (newFlag *Flag) TakesValue() bool {
return !ok || !val
}

// DeprecatedNames returns all deprecated names for this flag.
func (newFlag *Flag) DeprecatedNames() []string {
var names []string

if flag, ok := newFlag.Flag.(interface{ DeprecatedNames() []string }); ok {
names = flag.DeprecatedNames()
}

for _, deprecated := range newFlag.deprecatedFlags {
names = append(names, deprecated.Names()...)
}

return names
}

// Value implements `cli.Flag` interface.
func (newFlag *Flag) Value() cli.FlagValue {
for _, deprecatedFlag := range newFlag.deprecatedFlags {
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ type App struct {
// is used as the default behavior.
ExitErrHandler ExitErrHandlerFunc

// FlagErrHandler processes any error encountered while parsing flags.
FlagErrHandler FlagErrHandlerFunc

// Autocomplete enables or disables subcommand auto-completion support.
// This is enabled by default when NewApp is called. Otherwise, this
// must enabled explicitly.
Expand Down
12 changes: 7 additions & 5 deletions internal/cli/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import (
"strings"
)

const ErrFlagUndefined = "flag provided but not defined:"

type Command struct {
// Name is the command name.
Name string
Expand Down Expand Up @@ -102,12 +100,16 @@ func (cmd *Command) VisibleSubcommands() Commands {
// If this is the final command, starts its execution.
func (cmd *Command) Run(ctx *Context, args Args) (err error) {
args, err = cmd.parseFlags(ctx, args.Slice())
ctx = ctx.NewCommandContext(cmd, args)

if err != nil {
if flagErrHandler := ctx.App.FlagErrHandler; flagErrHandler != nil {
err = flagErrHandler(ctx, err)
}

return NewExitError(err, ExitCodeGeneralError)
}

ctx = ctx.NewCommandContext(cmd, args)

subCmdName := ctx.Args().CommandName()
subCmdArgs := ctx.Args().Remove(subCmdName)
subCmd := cmd.Subcommand(subCmdName)
Expand Down Expand Up @@ -223,8 +225,8 @@ func (cmd *Command) flagSetParse(ctx *Context, flagSet *libflag.FlagSet, args Ar
}

if errStr := err.Error(); strings.HasPrefix(errStr, ErrFlagUndefined) {
err = UndefinedFlagError(errStr)
undefArg = strings.Trim(strings.TrimPrefix(errStr, ErrFlagUndefined), " -")
err = UndefinedFlagError(undefArg)
} else {
break
}
Expand Down
11 changes: 11 additions & 0 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ func (commands Commands) Get(name string) *Command {
return nil
}

// Names returns names of the commands.
func (commands Commands) Names() []string {
var names = make([]string, len(commands))

for i, cmd := range commands {
names[i] = cmd.Name
}

return names
}

// Add adds a new cmd to the list.
func (commands *Commands) Add(cmd *Command) {
*commands = append(*commands, cmd)
Expand Down
6 changes: 4 additions & 2 deletions internal/cli/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,10 @@ func (err InvalidValueError) Unwrap() error {
return err.underlyingError
}

const ErrFlagUndefined = "flag provided but not defined:"

type UndefinedFlagError string

func (err UndefinedFlagError) Error() string {
return string(err)
func (flag UndefinedFlagError) Error() string {
return ErrFlagUndefined + " -" + string(flag)
}
3 changes: 3 additions & 0 deletions internal/cli/funcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ type SplitterFunc func(s, sep string) []string
// ExitErrHandlerFunc is executed if provided in order to handle exitError values
// returned by Actions and Before/After functions.
type ExitErrHandlerFunc func(ctx *Context, err error) error

// FlagErrHandlerFunc is executed if an error occurs while parsing flags.
type FlagErrHandlerFunc func(ctx *Context, err error) error
1 change: 1 addition & 0 deletions test/fixtures/cli-flag-hints/main.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Intentionally empty
1 change: 1 addition & 0 deletions test/fixtures/cli-flag-hints/terragrunt.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Intentionally empty
34 changes: 34 additions & 0 deletions test/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/gruntwork-io/terragrunt/cli/commands/run"
runall "github.com/gruntwork-io/terragrunt/cli/commands/run-all"
terragruntinfo "github.com/gruntwork-io/terragrunt/cli/commands/terragrunt-info"
"github.com/gruntwork-io/terragrunt/cli/flags"
"github.com/gruntwork-io/terragrunt/codegen"
"github.com/gruntwork-io/terragrunt/config"
"github.com/gruntwork-io/terragrunt/internal/errors"
Expand Down Expand Up @@ -107,6 +108,7 @@ const (
testFixtureExecCmd = "fixtures/exec-cmd"
textFixtureDisjointSymlinks = "fixtures/stack/disjoint-symlinks"
testFixtureLogStreaming = "fixtures/streaming"
testFixtureCLIFlagHints = "fixtures/cli-flag-hints"

terraformFolder = ".terraform"

Expand All @@ -116,6 +118,38 @@ const (
terragruntCache = ".terragrunt-cache"
)

func TestCLIFlagHints(t *testing.T) {
t.Parallel()

testCases := []struct {
args string
expectedError error
}{
{
"-raw init",
flags.NewGlobalFlagHintError("raw", "stack output", "raw"),
},
{
"run --no-include-root",
flags.NewCommandFlagHintError("run", "no-include-root", "catalog", "no-include-root"),
},
}

for i, testCase := range testCases {
t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) {
t.Parallel()

helpers.CleanupTerraformFolder(t, testFixtureCLIFlagHints)
rootPath := helpers.CopyEnvironment(t, testFixtureCLIFlagHints)
rootPath, err := filepath.EvalSymlinks(rootPath)
require.NoError(t, err)

_, _, err = helpers.RunTerragruntCommandWithOutput(t, "terragrunt "+testCase.args+" --working-dir "+rootPath)
assert.EqualError(t, err, testCase.expectedError.Error())
})
}
}

func TestExecCommand(t *testing.T) {
t.Parallel()

Expand Down