From 37e3d926a0e15e955070232530afedff3429d5a0 Mon Sep 17 00:00:00 2001 From: Levko Burburas <62853952+levkohimins@users.noreply.github.com> Date: Tue, 18 Feb 2025 18:11:17 +0200 Subject: [PATCH] feat: Clean up TF commands help (#3895) * feat: clean up help for tf commands * fix: args parse * fix: go-lint * chore: code improvements * fix: unit test * chore: change "Usage" text * fix: unit test --- cli/app_test.go | 14 ++----- cli/commands/commands.go | 2 +- cli/commands/help/command.go | 4 +- cli/commands/run/command.go | 15 +++---- cli/commands/run/flags.go | 23 ++++++---- cli/commands/run/help.go | 81 ++++++++++++++++++++++++++++++++++++ cli/flags/flag.go | 30 +++++++++++-- cli/help.go | 20 ++++----- internal/cli/command.go | 10 +++-- internal/cli/help.go | 30 +++++++++---- main.go | 2 +- 11 files changed, 176 insertions(+), 55 deletions(-) create mode 100644 cli/commands/run/help.go diff --git a/cli/app_test.go b/cli/app_test.go index 39100f33c..a05910ac1 100644 --- a/cli/app_test.go +++ b/cli/app_test.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "regexp" "testing" "github.com/gruntwork-io/terragrunt/cli" @@ -456,15 +455,13 @@ func TestTerragruntHelp(t *testing.T) { func TestTerraformHelp(t *testing.T) { t.Parallel() - wrappedBinary := options.DefaultWrappedPath - testCases := []struct { args []string expected string }{ - {[]string{"terragrunt", tf.CommandNamePlan, "--help"}, "Usage: " + wrappedBinary + " .* plan"}, - {[]string{"terragrunt", tf.CommandNameApply, "-help"}, "Usage: " + wrappedBinary + " .* apply"}, - {[]string{"terragrunt", tf.CommandNameApply, "-h"}, "Usage: " + wrappedBinary + " .* apply"}, + {[]string{"terragrunt", tf.CommandNamePlan, "--help"}, "(?s)Usage: terragrunt \\[global options\\] plan.*-detailed-exitcode"}, + {[]string{"terragrunt", tf.CommandNameApply, "-help"}, "(?s)Usage: terragrunt \\[global options\\] apply.*-destroy"}, + {[]string{"terragrunt", tf.CommandNameApply, "-h"}, "(?s)Usage: terragrunt \\[global options\\] apply.*-destroy"}, } for _, testCase := range testCases { @@ -474,10 +471,7 @@ func TestTerraformHelp(t *testing.T) { err := app.Run(testCase.args) require.NoError(t, err) - expectedRegex, err := regexp.Compile(testCase.expected) - require.NoError(t, err) - - assert.Regexp(t, expectedRegex, output.String()) + assert.Regexp(t, testCase.expected, output.String()) } } diff --git a/cli/commands/commands.go b/cli/commands/commands.go index 0157082fb..ff38485fd 100644 --- a/cli/commands/commands.go +++ b/cli/commands/commands.go @@ -29,7 +29,7 @@ import ( // Command category names. const ( - // MainCommandsCategoryName represents primary Terragrunt operations like run, run-all. + // MainCommandsCategoryName represents primary Terragrunt operations like run, exec. MainCommandsCategoryName = "Main commands" // CatalogCommandsCategoryName represents commands for managing Terragrunt catalogs. CatalogCommandsCategoryName = "Catalog commands" diff --git a/cli/commands/help/command.go b/cli/commands/help/command.go index dcade9a03..9eec9ea74 100644 --- a/cli/commands/help/command.go +++ b/cli/commands/help/command.go @@ -38,7 +38,7 @@ func Action(ctx *cli.Context, opts *options.TerragruntOptions) error { } } - if args.CommandName() == "" { + if cmdName := args.CommandName(); cmdName == "" || cmds.Get(cmdName) == nil { return cli.ShowAppHelp(ctx) } @@ -58,7 +58,7 @@ func Action(ctx *cli.Context, opts *options.TerragruntOptions) error { } if ctx.Command != nil { - return cli.NewExitError(cli.ShowCommandHelp(ctx), cli.ExitCodeGeneralError) + return cli.ShowCommandHelp(ctx) } return cli.NewExitError(errors.New(cli.InvalidCommandNameError(args.First())), cli.ExitCodeGeneralError) diff --git a/cli/commands/run/command.go b/cli/commands/run/command.go index 931f8c0c8..bff4290a5 100644 --- a/cli/commands/run/command.go +++ b/cli/commands/run/command.go @@ -67,15 +67,6 @@ func NewSubcommands(opts *options.TerragruntOptions) cli.Commands { return subcommands } -// ShowTFHelp prints TF help for the given `ctx.Command` command. -func ShowTFHelp(opts *options.TerragruntOptions) cli.HelpFunc { - return func(ctx *cli.Context) error { - terraformHelpCmd := append([]string{tf.FlagNameHelpLong, ctx.Command.Name}, ctx.Args()...) - - return tf.RunCommand(ctx, opts, terraformHelpCmd...) - } -} - func Action(opts *options.TerragruntOptions) cli.ActionFunc { return func(ctx *cli.Context) error { if opts.TerraformCommand == tf.CommandNameDestroy { @@ -95,9 +86,13 @@ func validateCommand(opts *options.TerragruntOptions) error { return nil } - if strings.HasSuffix(opts.TerraformPath, options.TerraformDefaultPath) { + if isTerraformPath(opts) { return WrongTerraformCommand(opts.TerraformCommand) } return WrongTofuCommand(opts.TerraformCommand) } + +func isTerraformPath(opts *options.TerragruntOptions) bool { + return strings.HasSuffix(opts.TerraformPath, options.TerraformDefaultPath) +} diff --git a/cli/commands/run/flags.go b/cli/commands/run/flags.go index a7ea04210..adec97dec 100644 --- a/cli/commands/run/flags.go +++ b/cli/commands/run/flags.go @@ -165,13 +165,7 @@ func NewFlags(opts *options.TerragruntOptions, prefix flags.Prefix) cli.Flags { }, flags.WithDeprecatedNames(terragruntPrefix.FlagNames(DeprecatedConfigFlagName), terragruntPrefixControl)), - flags.NewFlag(&cli.GenericFlag[string]{ - Name: TFPathFlagName, - EnvVars: tgPrefix.EnvVars(TFPathFlagName), - Destination: &opts.TerraformPath, - Usage: "Path to the OpenTofu/Terraform binary. Default is tofu (on PATH).", - }, - flags.WithDeprecatedNames(terragruntPrefix.FlagNames(DeprecatedTfpathFlagName), terragruntPrefixControl)), + NewTFPathFlag(opts, prefix), flags.NewFlag(&cli.BoolFlag{ Name: NoAutoInitFlagName, @@ -547,3 +541,18 @@ func NewFlags(opts *options.TerragruntOptions, prefix flags.Prefix) cli.Flags { return flags.Sort() } + +// NewTFPathFlag creates a flag for specifying the OpenTofu/Terraform binary path. +func NewTFPathFlag(opts *options.TerragruntOptions, prefix flags.Prefix) *flags.Flag { + tgPrefix := prefix.Prepend(flags.TgPrefix) + terragruntPrefix := prefix.Prepend(flags.TerragruntPrefix) + terragruntPrefixControl := flags.StrictControlsByGlobalFlags(opts.StrictControls) + + return flags.NewFlag(&cli.GenericFlag[string]{ + Name: TFPathFlagName, + EnvVars: tgPrefix.EnvVars(TFPathFlagName), + Destination: &opts.TerraformPath, + Usage: "Path to the OpenTofu/Terraform binary. Default is tofu (on PATH).", + }, + flags.WithDeprecatedNames(terragruntPrefix.FlagNames(DeprecatedTfpathFlagName), terragruntPrefixControl)) +} diff --git a/cli/commands/run/help.go b/cli/commands/run/help.go new file mode 100644 index 000000000..126e54c5a --- /dev/null +++ b/cli/commands/run/help.go @@ -0,0 +1,81 @@ +package run + +import ( + "fmt" + "io" + "strings" + + "github.com/gruntwork-io/terragrunt/internal/cli" + "github.com/gruntwork-io/terragrunt/internal/errors" + "github.com/gruntwork-io/terragrunt/options" + "github.com/gruntwork-io/terragrunt/tf" + "github.com/gruntwork-io/terragrunt/util" +) + +// TFCommandHelpTemplate is the TF command CLI help template. +const TFCommandHelpTemplate = `Usage: {{ if .Command.UsageText }}{{ wrap .Command.UsageText 3 }}{{ else }}{{ range $parent := parentCommands . }}{{ $parent.HelpName }} {{ end }}[global options] {{ .Command.HelpName }} [options]{{ if eq .Command.Name "` + tf.CommandNameApply + `" }} [PLAN]{{ end }}{{ end }}{{ $description := .Command.Usage }}{{ if .Command.Description }}{{ $description = .Command.Description }}{{ end }}{{ if $description }} + + {{ wrap $description 3 }}{{ end }}{{ if ne .Parent.Command.Name "` + CommandName + `" }} + + This is a shortcut for the command ` + "`terragrunt " + CommandName + "`" + `.{{ end }} + + It wraps the ` + "`{{ tfCommand }}`" + ` command of the binary defined by ` + "`tf-path`" + `. + +{{ if isTerraformPath }}Terraform{{ else }}OpenTofu{{ end }} ` + "`{{ tfCommand }}`" + ` help:{{ $tfHelp := runTFHelp }}{{ if $tfHelp }} + +{{ $tfHelp }}{{ end }} +` + +// ShowTFHelp prints TF help for the given `ctx.Command` command. +func ShowTFHelp(opts *options.TerragruntOptions) cli.HelpFunc { + return func(ctx *cli.Context) error { + if err := NewTFPathFlag(opts, nil).Parse(ctx.Args()); err != nil { + return err + } + + cli.HelpPrinterCustom(ctx, TFCommandHelpTemplate, map[string]any{ + "isTerraformPath": func() bool { + return isTerraformPath(opts) + }, + "runTFHelp": func() string { + return runTFHelp(ctx, opts) + }, + "tfCommand": func() string { + return ctx.Command.Name + }, + }) + + return nil + } +} + +func runTFHelp(ctx *cli.Context, opts *options.TerragruntOptions) string { + opts = opts.Clone() + opts.Writer = io.Discard + + terraformHelpCmd := []string{tf.FlagNameHelpLong, ctx.Command.Name} + + out, err := tf.RunCommandWithOutput(ctx, opts, terraformHelpCmd...) + if err != nil { + var processError util.ProcessExecutionError + if ok := errors.As(err, &processError); ok { + err = processError.Err + } + + return fmt.Sprintf("Failed to execute \"%s %s\": %s", opts.TerraformPath, strings.Join(terraformHelpCmd, " "), err.Error()) + } + + result := out.Stdout.String() + lines := strings.Split(result, "\n") + + // Trim first empty lines or that has prefix "Usage:". + for i := 0; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "" || strings.HasPrefix(lines[i], "Usage:") { + continue + } + + return strings.Join(lines[i:], "\n") + } + + return result +} diff --git a/cli/flags/flag.go b/cli/flags/flag.go index 23bfb2bc5..933ad24f5 100644 --- a/cli/flags/flag.go +++ b/cli/flags/flag.go @@ -4,6 +4,8 @@ package flags import ( "context" "flag" + "io" + "strings" "github.com/gruntwork-io/terragrunt/internal/cli" ) @@ -111,9 +113,31 @@ func (newFlag *Flag) RunAction(ctx *cli.Context) error { return newFlag.Flag.RunAction(ctx) } -// ParseEnvVars parses env vars values specified in the flag. +// Parse parses the given `args` for the flag value and env vars values specified in the flag. // The value will be assigned to the `Destination` field. // The value can also be retrieved using `flag.Value().Get()`. -func (newFlag *Flag) ParseEnvVars() error { - return newFlag.Apply(new(flag.FlagSet)) +func (newFlag *Flag) Parse(args cli.Args) error { + flagSet := flag.NewFlagSet("", flag.ContinueOnError) + flagSet.SetOutput(io.Discard) + + if err := newFlag.Apply(flagSet); err != nil { + return err + } + + const maxFlagsParse = 1000 // Maximum flags parse + + for range maxFlagsParse { + err := flagSet.Parse(args) + if err == nil { + break + } + + if errStr := err.Error(); !strings.HasPrefix(errStr, cli.ErrFlagUndefined) { + break + } + + args = flagSet.Args() + } + + return nil } diff --git a/cli/help.go b/cli/help.go index 18c386172..198b4baa0 100644 --- a/cli/help.go +++ b/cli/help.go @@ -19,25 +19,25 @@ Author: {{ range .App.Authors }}{{ . }}{{ end }} {{ end }} ` // CommandHelpTemplate is the command CLI help template. -const CommandHelpTemplate = `Usage: {{if .Command.UsageText}}{{wrap .Command.UsageText 3}}{{else}}{{range $parent := parentCommands . }}{{$parent.HelpName}} {{end}}{{.Command.HelpName}}{{if .Command.VisibleSubcommands}} {{end}}{{if .Command.VisibleFlags}} [options]{{end}}{{end}}{{$description := .Command.Usage}}{{if .Command.Description}}{{$description = .Command.Description}}{{end}}{{if $description}} +const CommandHelpTemplate = `Usage: {{ if .Command.UsageText }}{{ wrap .Command.UsageText 3 }}{{ else }}{{ range $parent := parentCommands . }}{{ $parent.HelpName }} {{ end }}{{ .Command.HelpName }}{{ if .Command.VisibleSubcommands }} {{ end }}{{ if .Command.VisibleFlags }} [options]{{ end }}{{ end }}{{ $description := .Command.Usage }}{{ if .Command.Description }}{{ $description = .Command.Description }}{{ end }}{{ if $description }} - {{wrap $description 3}}{{end}}{{if .Command.Examples}} + {{ wrap $description 3 }}{{ end }}{{ if .Command.Examples }} Examples: - {{$s := join .Command.Examples "\n\n"}}{{wrap $s 3}}{{end}}{{if .Command.VisibleSubcommands}} + {{ $s := join .Command.Examples "\n\n" }}{{ wrap $s 3 }}{{ end }}{{ if .Command.VisibleSubcommands }} -Subcommands:{{ $cv := offsetCommands .Command.VisibleSubcommands 5}}{{range .Command.VisibleSubcommands}} - {{$s := .HelpName}}{{$s}}{{ $sp := subtract $cv (offset $s 3) }}{{ indent $sp ""}} {{wrap .Usage $cv}}{{end}}{{end}}{{if .Command.VisibleFlags}} +Subcommands:{{ $cv := offsetCommands .Command.VisibleSubcommands 5 }}{{ range .Command.VisibleSubcommands }} + {{ $s := .HelpName }}{{ $s }}{{ $sp := subtract $cv (offset $s 3) }}{{ indent $sp ""}} {{ wrap .Usage $cv }}{{ end }}{{ end }}{{ if .Command.VisibleFlags }} Options: - {{range $index, $option := .Command.VisibleFlags}}{{if $index}} - {{end}}{{wrap $option.String 6}}{{end}}{{end}}{{if .App.VisibleFlags}} + {{ range $index, $option := .Command.VisibleFlags }}{{ if $index }} + {{ end }}{{ wrap $option.String 6 }}{{ end }}{{ end }}{{ if .App.VisibleFlags }} Global Options: - {{range $index, $option := .App.VisibleFlags}}{{if $index}} - {{end}}{{wrap $option.String 6}}{{end}}{{end}} + {{ range $index, $option := .App.VisibleFlags }}{{ if $index }} + {{ end }}{{ wrap $option.String 6 }}{{ end }}{{ end }} ` -const AppVersionTemplate = `{{.App.Name}} version {{.App.Version}} +const AppVersionTemplate = `{{ .App.Name }} version {{ .App.Version }} ` diff --git a/internal/cli/command.go b/internal/cli/command.go index b1d9d4b78..4fdf56697 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -6,7 +6,7 @@ import ( "strings" ) -const errFlagUndefined = "flag provided but not defined:" +const ErrFlagUndefined = "flag provided but not defined:" type Command struct { // Name is the command name. @@ -211,7 +211,9 @@ func (cmd *Command) flagSetParse(ctx *Context, flagSet *libflag.FlagSet, args Ar return undefArgs, nil } - for { + const maxFlagsParse = 1000 // Maximum flags parse + + for range maxFlagsParse { // check if the error is due to an undefArgs flag var undefArg string @@ -220,9 +222,9 @@ func (cmd *Command) flagSetParse(ctx *Context, flagSet *libflag.FlagSet, args Ar break } - if errStr := err.Error(); strings.HasPrefix(errStr, errFlagUndefined) { + if errStr := err.Error(); strings.HasPrefix(errStr, ErrFlagUndefined) { err = UndefinedFlagError(errStr) - undefArg = strings.Trim(strings.TrimPrefix(errStr, errFlagUndefined), " -") + undefArg = strings.Trim(strings.TrimPrefix(errStr, ErrFlagUndefined), " -") } else { break } diff --git a/internal/cli/help.go b/internal/cli/help.go index eae09a51a..3002b4066 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -4,6 +4,8 @@ import ( "slices" "strings" + "maps" + "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/urfave/cli/v2" ) @@ -44,8 +46,16 @@ func ShowAppHelp(ctx *Context) error { // ShowCommandHelp prints command help for the given `ctx`. func ShowCommandHelp(ctx *Context) error { + if ctx.Command.HelpName == "" { + ctx.Command.HelpName = ctx.Command.Name + } + if ctx.Command.CustomHelp != nil { - return ctx.Command.CustomHelp(ctx) + if err := ctx.Command.CustomHelp(ctx); err != nil { + return err + } + + return NewExitError(nil, ExitCodeSuccess) } tpl := ctx.Command.CustomHelpTemplate @@ -57,16 +67,22 @@ func ShowCommandHelp(ctx *Context) error { return errors.Errorf("command help template not defined") } - if ctx.Command.HelpName == "" { - ctx.Command.HelpName = ctx.Command.Name - } + HelpPrinterCustom(ctx, tpl, nil) - cli.HelpPrinterCustom(ctx.App.Writer, tpl, ctx, map[string]any{ + return NewExitError(nil, ExitCodeSuccess) +} + +func HelpPrinterCustom(ctx *Context, tpl string, customFuncs map[string]any) { + var funcs = map[string]any{ "parentCommands": parentCommands, "offsetCommands": offsetCommands, - }) + } - return NewExitError(nil, ExitCodeSuccess) + if customFuncs != nil { + maps.Copy(funcs, customFuncs) + } + + cli.HelpPrinterCustom(ctx.App.Writer, tpl, ctx, funcs) } func ShowVersion(ctx *Context) error { diff --git a/main.go b/main.go index d2ca7e955..47f9aaa7d 100644 --- a/main.go +++ b/main.go @@ -21,7 +21,7 @@ func main() { opts := options.NewTerragruntOptions() // Immediately parse the `TG_LOG_LEVEL` environment variable, e.g. to set the TRACE level. - if err := global.NewLogLevelFlag(opts, nil).ParseEnvVars(); err != nil { + if err := global.NewLogLevelFlag(opts, nil).Parse(os.Args); err != nil { opts.Logger.Error(err.Error()) os.Exit(1) }