Skip to content

Commit

Permalink
feat: Clean up TF commands help (#3895)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
levkohimins authored Feb 18, 2025
1 parent e14d7bf commit 37e3d92
Show file tree
Hide file tree
Showing 11 changed files with 176 additions and 55 deletions.
14 changes: 4 additions & 10 deletions cli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"testing"

"github.com/gruntwork-io/terragrunt/cli"
Expand Down Expand Up @@ -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 {
Expand All @@ -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())
}
}

Expand Down
2 changes: 1 addition & 1 deletion cli/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions cli/commands/help/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
Expand Down
15 changes: 5 additions & 10 deletions cli/commands/run/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
23 changes: 16 additions & 7 deletions cli/commands/run/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
}
81 changes: 81 additions & 0 deletions cli/commands/run/help.go
Original file line number Diff line number Diff line change
@@ -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
}
30 changes: 27 additions & 3 deletions cli/flags/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package flags
import (
"context"
"flag"
"io"
"strings"

"github.com/gruntwork-io/terragrunt/internal/cli"
)
Expand Down Expand Up @@ -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
}
20 changes: 10 additions & 10 deletions cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}} <command>{{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 }} <command>{{ 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 }}
`
10 changes: 6 additions & 4 deletions internal/cli/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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
}
Expand Down
30 changes: 23 additions & 7 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"slices"
"strings"

"maps"

"github.com/gruntwork-io/terragrunt/internal/errors"
"github.com/urfave/cli/v2"
)
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down

0 comments on commit 37e3d92

Please sign in to comment.