diff --git a/docs/_docs/04_reference/log-formatting.md b/docs/_docs/04_reference/log-formatting.md index afc398e79..040320411 100644 --- a/docs/_docs/04_reference/log-formatting.md +++ b/docs/_docs/04_reference/log-formatting.md @@ -60,13 +60,15 @@ Placeholders have preset names: * `%tf-path` - Path to the OpenTofu/Terraform executable (as defined by [terragrunt-tfpath](https://terragrunt.gruntwork.io/docs/reference/cli-options/#terragrunt-tfpath)). -* `%tf-command-args` - Arguments of the executed OpenTofu/Terraform command. +* `%tf-command` - Executed OpenTofu/Terraform command, e.g. `apply`. -* `%t` - Tab. +* `%tf-command-args` - Arguments of the executed OpenTofu/Terraform command, e.g. `apply -auto-approve`. + +* `%t` - Indent. * `%n` - Newline. -Any other text is considered plain text. +Any other text is considered plain text. The parser always tries to find the longest name. For example, tofu command "apply -auto-approve" with format "%tf-command-args" will be replaced with "apply -auto-approve", but not "apply-args". If you need to replace it with "apply-args", use empty brackets "%tf-command()-args". More examples: "%tf-path" will be replaced with "tofu", `%t()-path` will be replaced with " -path". e.g. diff --git a/pkg/log/format/options/color.go b/pkg/log/format/options/color.go index 1e88fce21..0d778d683 100644 --- a/pkg/log/format/options/color.go +++ b/pkg/log/format/options/color.go @@ -3,6 +3,7 @@ package options import ( "strconv" "strings" + "sync" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" @@ -212,6 +213,7 @@ type gradientColor struct { // We use [xsync.MapOf](https://github.com/puzpuzpuz/xsync?tab=readme-ov-file#map) instead of standard `sync.Map` since it's faster and has generic types. cache *xsync.MapOf[string, ColorValue] values []ColorValue + mu sync.Mutex // nextStyleIndex is used to get the next style from the `codes` list for a newly discovered text. nextStyleIndex int @@ -225,6 +227,9 @@ func newGradientColor() *gradientColor { } func (color *gradientColor) Value(text string) ColorValue { + color.mu.Lock() + defer color.mu.Unlock() + if colorCode, ok := color.cache.Load(text); ok { return colorCode } diff --git a/pkg/log/format/options/common.go b/pkg/log/format/options/common.go index f9dbb8dea..32351da7b 100644 --- a/pkg/log/format/options/common.go +++ b/pkg/log/format/options/common.go @@ -2,6 +2,7 @@ package options import ( "fmt" + "sort" "strconv" "strings" @@ -110,7 +111,10 @@ func (val *MapValue[T]) Parse(str string) error { } } - return errors.Errorf("available values: %s", strings.Join(maps.Values(val.list), ",")) + list := maps.Values(val.list) + sort.Strings(list) + + return errors.Errorf("available values: %s", strings.Join(list, ",")) } func (val *MapValue[T]) Filter(vals ...T) MapValue[T] { diff --git a/pkg/log/format/options/errors.go b/pkg/log/format/options/errors.go new file mode 100644 index 000000000..1b284ab54 --- /dev/null +++ b/pkg/log/format/options/errors.go @@ -0,0 +1,80 @@ +package options + +import ( + "fmt" + "strings" +) + +// InvalidOptionError is an invalid `option` syntax error. +type InvalidOptionError struct { + str string +} + +// NewInvalidOptionError returns a new `InvalidOptionError` instance. +func NewInvalidOptionError(str string) *InvalidOptionError { + return &InvalidOptionError{ + str: str, + } +} + +func (err InvalidOptionError) Error() string { + return fmt.Sprintf("invalid option syntax %q", err.str) +} + +// EmptyOptionNameError is an empty `option` name error. +type EmptyOptionNameError struct { + str string +} + +// NewEmptyOptionNameError returns a new `EmptyOptionNameError` instance. +func NewEmptyOptionNameError(str string) *EmptyOptionNameError { + return &EmptyOptionNameError{ + str: str, + } +} + +func (err EmptyOptionNameError) Error() string { + return fmt.Sprintf("empty option name %q", err.str) +} + +// InvalidOptionNameError is an invalid `option` name error. +type InvalidOptionNameError struct { + name string + opts Options +} + +// NewInvalidOptionNameError returns a new `InvalidOptionNameError` instance. +func NewInvalidOptionNameError(name string, opts Options) *InvalidOptionNameError { + return &InvalidOptionNameError{ + name: name, + opts: opts, + } +} + +func (err InvalidOptionNameError) Error() string { + return fmt.Sprintf("invalid option name %q, available names: %s", err.name, strings.Join(err.opts.Names(), ",")) +} + +// InvalidOptionValueError is an invalid `option` value error. +type InvalidOptionValueError struct { + val string + opt Option + err error +} + +// NewInvalidOptionValueError returns a new `InvalidOptionValueError` instance. +func NewInvalidOptionValueError(opt Option, val string, err error) *InvalidOptionValueError { + return &InvalidOptionValueError{ + val: val, + opt: opt, + err: err, + } +} + +func (err InvalidOptionValueError) Error() string { + return fmt.Sprintf("option %q, invalid value %q, %v", err.opt.Name(), err.val, err.err) +} + +func (err InvalidOptionValueError) Unwrap() error { + return err.err +} diff --git a/pkg/log/format/options/option.go b/pkg/log/format/options/option.go index 8ded8a60e..3fac367f6 100644 --- a/pkg/log/format/options/option.go +++ b/pkg/log/format/options/option.go @@ -3,13 +3,26 @@ package options import ( "reflect" + "strings" + "unicode" + "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log" ) +// Constants for parsing options. +const ( + OptNameValueSep = "=" + OptSep = "," + OptStartSign = "(" + OptEndSign = ")" +) + +const splitIntoNameAndValue = 2 + // OptionValue contains the value of the option. type OptionValue[T any] interface { - // Parse parses and sets the value of the option. + // Configure parses and sets the value of the option. Parse(str string) error // Get returns the value of the option. Get() T @@ -20,7 +33,7 @@ type Option interface { // Name returns the name of the option. Name() string // Format formats the given string. - Format(data *Data, val any) (any, error) + Format(data *Data, str any) (any, error) // ParseValue parses and sets the value of the option. ParseValue(str string) error } @@ -76,15 +89,132 @@ func (opts Options) Merge(withOpts ...Option) Options { } // Format returns the formatted value. -func (opts Options) Format(data *Data, val any) (string, error) { +func (opts Options) Format(data *Data, str any) (string, error) { var err error for _, opt := range opts { - val, err = opt.Format(data, val) - if val == "" || err != nil { + str, err = opt.Format(data, str) + if str == "" || err != nil { return "", err } } - return toString(val), nil + return toString(str), nil +} + +// Configure parsers the given `str` to configure the `opts` and returns the rest of the given `str`. +// +// e.g. (color=green, case=upper) some-text" sets `color` option to `green`, `case` option to `upper` and returns " some-text". +func (opts Options) Configure(str string) (string, error) { + if len(str) == 0 || !strings.HasPrefix(str, OptStartSign) { + return str, nil + } + + str = str[1:] + + for { + var ( + ok bool + err error + ) + + if str, ok = nextOption(str); !ok { + return str, nil + } + + parts := strings.SplitN(str, OptNameValueSep, splitIntoNameAndValue) + if len(parts) != splitIntoNameAndValue { + return "", errors.New(NewInvalidOptionError(str)) + } + + name := strings.TrimSpace(parts[0]) + + if name == "" { + return "", errors.New(NewEmptyOptionNameError(str)) + } + + opt := opts.Get(name) + if opt == nil { + return "", errors.New(NewInvalidOptionNameError(name, opts)) + } + + if str, err = setOptionValue(opt, parts[1]); err != nil { + return "", err + } + } +} + +// setOptionValue parses the given `str` and sets the value for the given `opt` and returns the rest of the given `str`. +// +// e.g. "green, case=upper) some-text" sets "green" to the option and returns ", case=upper) some-text". +// e.g. "' quoted value ') some-text" sets " quoted value " to the option and returns ") some-text". +func setOptionValue(opt Option, str string) (string, error) { + var quoteChar byte + + for index := range str { + if quoteOpened(str[:index], "eChar) { + continue + } + + lastSign := str[index : index+1] + if !strings.HasSuffix(lastSign, OptSep) && !strings.HasSuffix(lastSign, OptEndSign) { + continue + } + + val := strings.TrimSpace(str[:index]) + val = strings.Trim(val, "'") + val = strings.Trim(val, "\"") + + if err := opt.ParseValue(val); err != nil { + return "", errors.New(NewInvalidOptionValueError(opt, val, err)) + } + + return str[index:], nil + } + + return str, nil +} + +// nextOption returns true if the given `str` contains one more option +// and returns the given `str` without separator sign "," or ")". +// +// e.g. ",color=green) some-text" returns "color=green) some-text" and `true`. +// e.g. "(color=green) some-text" returns "color=green) some-text" and `true`. +// e.g. ") some-text" returns " some-text" and `false`. +func nextOption(str string) (string, bool) { + str = strings.TrimLeftFunc(str, unicode.IsSpace) + + switch { + case strings.HasPrefix(str, OptEndSign): + return str[1:], false + case strings.HasPrefix(str, OptSep): + return str[1:], true + } + + return str, true +} + +// quoteOpened returns true if the given `str` contains an unclosed quote. +// +// e.g. "%(content=' level" return `true`. +// e.g. "%(content=' level '" return `false`. +// e.g. "%(content=\" level" return `true`. +func quoteOpened(str string, quoteChar *byte) bool { + strlen := len(str) + + if strlen == 0 { + return false + } + + char := str[strlen-1] + + if char == '"' || char == '\'' { + if *quoteChar == 0 { + *quoteChar = char + } else if *quoteChar == char && (strlen < 2 || str[strlen-2] != '\\') { + *quoteChar = 0 + } + } + + return *quoteChar != 0 } diff --git a/pkg/log/format/placeholders/common.go b/pkg/log/format/placeholders/common.go index 8b5a5afb3..017ea82b5 100644 --- a/pkg/log/format/placeholders/common.go +++ b/pkg/log/format/placeholders/common.go @@ -1,9 +1,6 @@ package placeholders import ( - "strings" - - "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/pkg/log/format/options" ) @@ -39,13 +36,9 @@ func (common *CommonPlaceholder) Name() string { return common.name } -// GetOption implements `Placeholder` interface. -func (common *CommonPlaceholder) GetOption(str string) (options.Option, error) { - if opt := common.opts.Get(str); opt != nil { - return opt, nil - } - - return nil, errors.Errorf("available values: %s", strings.Join(common.opts.Names(), ",")) +// Options implements `Placeholder` interface. +func (common *CommonPlaceholder) Options() options.Options { + return common.opts } // Format implements `Placeholder` interface. diff --git a/pkg/log/format/placeholders/errors.go b/pkg/log/format/placeholders/errors.go new file mode 100644 index 000000000..681765f7f --- /dev/null +++ b/pkg/log/format/placeholders/errors.go @@ -0,0 +1,56 @@ +package placeholders + +import ( + "fmt" + "strings" +) + +// InvalidPlaceholderNameError is an invalid `placeholder` name error. +type InvalidPlaceholderNameError struct { + str string + opts Placeholders +} + +// NewInvalidPlaceholderNameError returns a new `InvalidPlaceholderNameError` instance. +func NewInvalidPlaceholderNameError(str string, opts Placeholders) *InvalidPlaceholderNameError { + return &InvalidPlaceholderNameError{ + str: str, + opts: opts, + } +} + +func (err InvalidPlaceholderNameError) Error() string { + var name string + + for index := range len(err.str) { + if !isPlaceholderNameCharacter(err.str[index]) { + break + } + + name = err.str[:index+1] + } + + return fmt.Sprintf("invalid placeholder name %q, available names: %s", name, strings.Join(err.opts.Names(), ",")) +} + +// InvalidPlaceholderOptionError is an invalid `placeholder` option error. +type InvalidPlaceholderOptionError struct { + ph Placeholder + err error +} + +// NewInvalidPlaceholderOptionError returns a new `InvalidPlaceholderOptionError` instance. +func NewInvalidPlaceholderOptionError(ph Placeholder, err error) *InvalidPlaceholderOptionError { + return &InvalidPlaceholderOptionError{ + ph: ph, + err: err, + } +} + +func (err InvalidPlaceholderOptionError) Error() string { + return fmt.Sprintf("placeholder %q, %v", err.ph.Name(), err.err) +} + +func (err InvalidPlaceholderOptionError) Unwrap() error { + return err.err +} diff --git a/pkg/log/format/placeholders/field.go b/pkg/log/format/placeholders/field.go index 8008b15b4..f43c1dff9 100644 --- a/pkg/log/format/placeholders/field.go +++ b/pkg/log/format/placeholders/field.go @@ -9,6 +9,7 @@ const ( DownloadDirKeyName = "download-dir" TFPathKeyName = "tf-path" TFCmdArgsKeyName = "tf-command-args" + TFCmdKeyName = "tf-command" ) type fieldPlaceholder struct { diff --git a/pkg/log/format/placeholders/placeholder.go b/pkg/log/format/placeholders/placeholder.go index 93fc6c3d2..6102add8c 100644 --- a/pkg/log/format/placeholders/placeholder.go +++ b/pkg/log/format/placeholders/placeholder.go @@ -8,14 +8,17 @@ import ( "github.com/gruntwork-io/terragrunt/pkg/log/format/options" ) -const placeholderSign = '%' +const ( + placeholderSign = "%" + splitIntoTextAndPlaceholder = 2 +) // Placeholder is part of the log message, used to format different log values. type Placeholder interface { // Name returns a placeholder name. Name() string - // GetOption returns the option with the given option name. - GetOption(name string) (options.Option, error) + // Options returns the placeholder options. + Options() options.Options // Format returns the formatted value. Format(data *options.Data) (string, error) } @@ -23,6 +26,20 @@ type Placeholder interface { // Placeholders are a set of Placeholders. type Placeholders []Placeholder +// NewPlaceholderRegister returns a new `Placeholder` collection instance available for use in a custom format string. +func NewPlaceholderRegister() Placeholders { + return Placeholders{ + Interval(), + Time(), + Level(), + Message(), + Field(WorkDirKeyName, options.PathFormat(options.NonePath, options.RelativePath, options.ShortRelativePath, options.ShortPath)), + Field(TFPathKeyName, options.PathFormat(options.NonePath, options.FilenamePath, options.DirectoryPath)), + Field(TFCmdArgsKeyName), + Field(TFCmdKeyName), + } +} + // Get returns the placeholder by its name. func (phs Placeholders) Get(name string) Placeholder { for _, ph := range phs { @@ -61,161 +78,110 @@ func (phs Placeholders) Format(data *options.Data) (string, error) { return str, nil } -// Parse parses the given string and returns a set of placeholders that are then used to format log data. -func Parse(str string) (Placeholders, error) { +// findPlaceholder parses the given `str` to find a placeholder name present in the `phs` collection, +// returns that placeholder, and the rest of the given `str`. +// +// e.g. "level(color=green, case=upper) some-text" returns the instance of the `level` placeholder +// and "(color=green, case=upper) some-text" string. +func (phs Placeholders) findPlaceholder(str string) (Placeholder, string) { //nolint:ireturn var ( - registered = newPlaceholders() - placeholders Placeholders - next int + placeholder Placeholder + optIndex int ) - for index := 0; index < len(str); index++ { - char := str[index] - - if char == placeholderSign { - if index+1 >= len(str) { - return nil, errors.Errorf("empty placeholder name") - } - - if str[index+1] == placeholderSign { - str = str[:index] + str[index+1:] - - continue - } - - if next != index { - placeholder := PlainText(str[next:index]) - placeholders = append(placeholders, placeholder) - } + // We don't stop at the first one we find, we look for the longest name. + // Of these two `%tf-command` `%tf-command-args` we need to find the second one. + for index := range len(str) { + if !isPlaceholderNameCharacter(str[index]) { + break + } - placeholder, num, err := parsePlaceholder(str[index+1:], registered) - if err != nil { - return nil, err - } + name := str[:index+1] - placeholders = append(placeholders, placeholder) - index += num + 1 - next = index + 1 + if pl := phs.Get(name); pl != nil { + placeholder = pl + optIndex = index + 1 } } - if next != len(str) { - placeholder := PlainText(str[next:]) - placeholders = append(placeholders, placeholder) + if placeholder != nil { + return placeholder, str[optIndex:] } - return placeholders, nil + return findPlaintextPlaceholder(str) } -func newPlaceholders() Placeholders { - return Placeholders{ - Interval(), - Time(), - Level(), - Message(), - Field(WorkDirKeyName, options.PathFormat(options.NonePath, options.RelativePath, options.ShortRelativePath, options.ShortPath)), - Field(TFPathKeyName, options.PathFormat(options.NonePath, options.FilenamePath, options.DirectoryPath)), - Field(TFCmdArgsKeyName), - } -} - -func parsePlaceholder(str string, registered Placeholders) (Placeholder, int, error) { +// Parse parses the given `str` and returns a set of placeholders that are then used to format log data. +func Parse(str string) (Placeholders, error) { var ( - next int - quoted byte - placeholder Placeholder - option options.Option + placeholders Placeholders + placeholder Placeholder + err error ) - for index := range len(str) { - char := str[index] - - if char == '"' || char == '\'' { - if quoted == 0 { - quoted = char - } else if quoted == char && index > 0 && str[index-1] != '\\' { - quoted = 0 - } - } + for { + // We need to create a new placeholders collection to avoid overriding options + // if the custom format string contains two or more same placeholders. + // e.g. "%level(format=full) some-text %level(format=tiny)" + placeholderRegister := NewPlaceholderRegister() - if quoted != 0 { - continue - } + parts := strings.SplitN(str, placeholderSign, splitIntoTextAndPlaceholder) - if index == 0 && char == '(' { - placeholder = PlainText("") - next = index + 1 + if plaintext := parts[0]; plaintext != "" { + placeholders = append(placeholders, PlainText(plaintext)) } - if placeholder == nil { - if !isPlaceholderNameCharacter(char) { - str = str[next:index] - - switch str[0] { - case 't': - return PlainText("\t"), next, nil - case 'n': - return PlainText("\n"), next, nil - } - - break - } - - name := str[next : index+1] - - if placeholder = registered.Get(name); placeholder != nil { - next = index + 2 //nolint:mnd - } - - continue + if len(parts) == 1 { + return placeholders, nil } - if next-1 == index && char != '(' { - return placeholder, index - 1, nil - } + str = parts[1] - if char == '=' || char == ',' || char == ')' { - val := str[next:index] - val = strings.TrimSpace(val) - val = strings.Trim(val, "'") - val = strings.Trim(val, "\"") - - if str[next-1] == '=' { - if option == nil { - return nil, 0, errors.Errorf("empty option name for placeholder %q", placeholder.Name()) - } - - if err := option.ParseValue(val); err != nil { - return nil, 0, errors.Errorf("invalid value %q for option %q, placeholder %q: %w", val, option.Name(), placeholder.Name(), err) - } - } else if val != "" { - opt, err := placeholder.GetOption(val) - if err != nil { - return nil, 0, errors.Errorf("invalid option name %q for placeholder %q: %w", val, placeholder.Name(), err) - } - - option = opt - } - - next = index + 1 + placeholder, str = placeholderRegister.findPlaceholder(str) + if placeholder == nil { + return nil, errors.New(NewInvalidPlaceholderNameError(str, placeholderRegister)) } - if char == ')' { - return placeholder, index, nil + str, err = placeholder.Options().Configure(str) + if err != nil { + return nil, errors.New(NewInvalidPlaceholderOptionError(placeholder, err)) } + + placeholders = append(placeholders, placeholder) } +} - if placeholder == nil { - return nil, 0, errors.Errorf("invalid placeholder name %q, available values: %s", str, strings.Join(registered.Names(), ",")) +func findPlaintextPlaceholder(str string) (Placeholder, string) { //nolint:ireturn + if len(str) == 0 { + return nil, str } - if next < len(str) { - return nil, 0, errors.Errorf("invalid option %q for placeholder %q", str[next:], placeholder.Name()) + switch str[0:1] { + case options.OptStartSign: + // Unnamed placeholder, format `%(content='...')`. + return PlainText(""), str + case " ": + // Single `%` character, format `% `. + return PlainText(placeholderSign), str + case placeholderSign: + // Escaped `%`, format `%%`. + return PlainText(placeholderSign), str[1:] + case "t": + // Indent, format `%t`. + return PlainText("\t"), str[1:] + case "n": + // Newline, format `%n`. + return PlainText("\n"), str[1:] } - return placeholder, len(str) - 1, nil + return nil, str } +// isPlaceholderNameCharacter returns true if the given character `c` does not contain any restricted characters for placeholder names. +// +// e.g. "time" return `true`. +// e.g. "time " return `false`. +// e.g. "time(" return `false`. func isPlaceholderNameCharacter(c byte) bool { // Check if the byte value falls within the range of alphanumeric characters return c == '-' || c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') diff --git a/shell/run_shell_cmd.go b/shell/run_shell_cmd.go index 6cf315cc1..7aa065801 100644 --- a/shell/run_shell_cmd.go +++ b/shell/run_shell_cmd.go @@ -136,7 +136,8 @@ func RunShellCommandWithOutput( if command == opts.TerraformPath && !opts.ForwardTFStdout { logger := opts.Logger. WithField(placeholders.TFPathKeyName, filepath.Base(opts.TerraformPath)). - WithField(placeholders.TFCmdArgsKeyName, args) + WithField(placeholders.TFCmdArgsKeyName, args). + WithField(placeholders.TFCmdKeyName, cli.Args(args).CommandName()) if opts.JSONLogFormat && !cli.Args(args).Normalize(cli.SingleDashFlag).Contains(terraform.FlagNameJSON) { outWriter = buildOutWriter( diff --git a/test/integration_test.go b/test/integration_test.go index 3c3fd7edc..d7debb672 100644 --- a/test/integration_test.go +++ b/test/integration_test.go @@ -22,6 +22,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/view/diagnostic" "github.com/gruntwork-io/terragrunt/pkg/log" + "github.com/gruntwork-io/terragrunt/pkg/log/format/placeholders" "github.com/gruntwork-io/terragrunt/shell" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/util" @@ -202,40 +203,95 @@ func TestLogCustomFormatOutput(t *testing.T) { logCustomFormat string expectedStdOutRegs []*regexp.Regexp expectedStdErrRegs []*regexp.Regexp + expectedErr error }{ { - logCustomFormat: "%time %level %prefix %msg", - expectedStdOutRegs: []*regexp.Regexp{}, - expectedStdErrRegs: []*regexp.Regexp{ + "%time %level %prefix %msg", + []*regexp.Regexp{}, + []*regexp.Regexp{ regexp.MustCompile(`\d{2}:\d{2}:\d{2}\.\d{3} debug ` + absPathReg + regexp.QuoteMeta(" Terragrunt Version:")), regexp.MustCompile(`\d{2}:\d{2}:\d{2}\.\d{3} debug ` + absPathReg + `/dep Module ` + absPathReg + `/dep must wait for 0 dependencies to finish`), regexp.MustCompile(`\d{2}:\d{2}:\d{2}\.\d{3} debug ` + absPathReg + `/app Module ` + absPathReg + `/app must wait for 1 dependencies to finish`), }, + nil, }, { - logCustomFormat: "%interval %level(case=upper) %prefix(path=short-relative,prefix='[',suffix='] ')%msg(path=relative)", - expectedStdOutRegs: []*regexp.Regexp{}, - expectedStdErrRegs: []*regexp.Regexp{ + "%interval %level(case=upper) %prefix(path=short-relative,prefix='[',suffix='] ')%msg(path=relative)", + []*regexp.Regexp{}, + []*regexp.Regexp{ regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" DEBUG Terragrunt Version:")), regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" DEBUG [dep] Module ./dep must wait for 0 dependencies to finish")), regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" DEBUG [app] Module ./app must wait for 1 dependencies to finish")), }, + nil, }, { - logCustomFormat: "%interval%(content=' plain-text ')%level(case=upper,width=6) %prefix(path=short-relative,suffix=' ')%tf-path(suffix=' ')%tf-command-args(suffix=': ')%msg(path=relative)", - expectedStdOutRegs: []*regexp.Regexp{ + "%interval%(content=' plain-text ')%level(case=upper,width=6) %prefix(path=short-relative,suffix=' ')%tf-path(suffix=' ')%tf-command-args(suffix=': ')%msg(path=relative)", + []*regexp.Regexp{ regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT dep "+wrappedBinary()+" init -input=false -no-color: Initializing the backend...")), regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT app "+wrappedBinary()+" init -input=false -no-color: Initializing the backend...")), }, - expectedStdErrRegs: []*regexp.Regexp{ + []*regexp.Regexp{ regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text DEBUG Terragrunt Version:")), }, + nil, + }, + { + "%interval%(content=' plain-text ')%level(case=upper,width=6) %prefix(path=short-relative,suffix=' ')%tf-path(suffix=' ')%tf-command(suffix=': ')%msg(path=relative)", + []*regexp.Regexp{ + regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT dep "+wrappedBinary()+" init: Initializing the backend...")), + regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT app "+wrappedBinary()+" init: Initializing the backend...")), + }, + []*regexp.Regexp{ + regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text DEBUG Terragrunt Version:")), + }, + nil, + }, + { + "%interval%(content=' plain-text ')%level(case=upper,width=6) %prefix(path=short-relative,suffix=' ')%tf-path(suffix=' ')%tf-command()-args %msg(path=relative)", + []*regexp.Regexp{ + regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT dep "+wrappedBinary()+" init-args Initializing the backend...")), + regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT app "+wrappedBinary()+" init-args Initializing the backend...")), + }, + []*regexp.Regexp{ + regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text DEBUG -args Terragrunt Version:")), + }, + nil, + }, + { + "%interval%(content=' plain-text ')%level(case=upper,width=6) %prefix(path=short-relative,suffix=' ')%tf-path(suffix=' ')%tf-command()-args % aaa %msg(path=relative) %%bbb % ccc", + []*regexp.Regexp{ + regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT dep "+wrappedBinary()+" init-args % aaa Initializing the backend... %bbb % ccc")), + regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text STDOUT app "+wrappedBinary()+" init-args % aaa Initializing the backend... %bbb % ccc")), + }, + []*regexp.Regexp{ + regexp.MustCompile(`\d{4}` + regexp.QuoteMeta(" plain-text DEBUG -args % aaa Terragrunt Version:")), + }, + nil, + }, + { + "%time(color=green) %level %wrong", + nil, nil, + errors.Errorf(`flag --terragrunt-log-custom-format, invalid placeholder name "wrong", available names: %s`, strings.Join(placeholders.NewPlaceholderRegister().Names(), ",")), + }, + { + "%time(colorr=green) %level", + nil, nil, + errors.Errorf(`flag --terragrunt-log-custom-format, placeholder "time", invalid option name "colorr", available names: %s`, strings.Join(placeholders.Time().Options().Names(), ",")), + }, + { + "%time(color=green) %level(format=tinyy)", + nil, nil, + errors.New(`flag --terragrunt-log-custom-format, placeholder "level", option "format", invalid value "tinyy", available values: full,short,tiny`), + }, + { + "%time(=green) %level(format=tiny)", + nil, nil, + errors.New(`flag --terragrunt-log-custom-format, placeholder "time", empty option name "=green) %level(format=tiny)\""`), }, } for i, testCase := range testCases { - testCase := testCase - t.Run(fmt.Sprintf("testCase-%d", i), func(t *testing.T) { t.Parallel() @@ -247,6 +303,13 @@ func TestLogCustomFormatOutput(t *testing.T) { require.NoError(t, err) stdout, stderr, err := helpers.RunTerragruntCommandWithOutput(t, fmt.Sprintf("terragrunt run-all init --terragrunt-log-level trace --terragrunt-non-interactive -no-color --terragrunt-no-color --terragrunt-log-custom-format=%q --terragrunt-working-dir %s", testCase.logCustomFormat, rootPath)) + + if testCase.expectedErr != nil { + assert.EqualError(t, err, testCase.expectedErr.Error()) + + return + } + require.NoError(t, err) for _, reg := range testCase.expectedStdOutRegs {