diff --git a/changelogs/unreleased/9317-itrooz b/changelogs/unreleased/9317-itrooz new file mode 100644 index 0000000000..87e7fd841f --- /dev/null +++ b/changelogs/unreleased/9317-itrooz @@ -0,0 +1 @@ +add color to velero logs diff --git a/go.mod b/go.mod index d32340b2ba..2f7d273c97 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/bombsimon/logrusr/v3 v3.0.0 github.com/evanphx/json-patch/v5 v5.9.11 github.com/fatih/color v1.18.0 + github.com/go-logfmt/logfmt v0.4.0 github.com/gobwas/glob v0.2.3 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 @@ -135,6 +136,7 @@ require ( github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/klauspost/reedsolomon v1.12.4 // indirect + github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mailru/easyjson v0.7.7 // indirect diff --git a/go.sum b/go.sum index 84a94ed323..66f3370008 100644 --- a/go.sum +++ b/go.sum @@ -270,6 +270,7 @@ github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3 github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0 h1:MP4Eh7ZCb31lleYCFuwm0oe4/YGak+5l1vA2NOE80nA= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= @@ -499,6 +500,7 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv github.com/kopia/htmluibuild v0.0.1-0.20250607181534-77e0f3f9f557 h1:je1C/xnmKxnaJsIgj45me5qA51TgtK9uMwTxgDw+9H0= github.com/kopia/htmluibuild v0.0.1-0.20250607181534-77e0f3f9f557/go.mod h1:h53A5JM3t2qiwxqxusBe+PFgGcgZdS+DWCQvG5PTlto= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= diff --git a/pkg/cmd/cli/backup/logs.go b/pkg/cmd/cli/backup/logs.go index a0149acf17..4318f6e9c5 100644 --- a/pkg/cmd/cli/backup/logs.go +++ b/pkg/cmd/cli/backup/logs.go @@ -32,6 +32,7 @@ import ( "github.com/vmware-tanzu/velero/pkg/cmd" "github.com/vmware-tanzu/velero/pkg/cmd/util/cacert" "github.com/vmware-tanzu/velero/pkg/cmd/util/downloadrequest" + "github.com/vmware-tanzu/velero/pkg/cmd/util/output" ) type LogsOptions struct { @@ -86,7 +87,10 @@ func (l *LogsOptions) Run(c *cobra.Command, f client.Factory) error { bslCACert = "" } - err = downloadrequest.StreamWithBSLCACert(context.Background(), l.Client, f.Namespace(), l.BackupName, velerov1api.DownloadTargetKindBackupLog, os.Stdout, l.Timeout, l.InsecureSkipTLSVerify, l.CaCertFile, bslCACert) + w, wg := output.PrintLogsWithColor() + err = downloadrequest.StreamWithBSLCACert(context.Background(), l.Client, f.Namespace(), l.BackupName, velerov1api.DownloadTargetKindBackupLog, w, l.Timeout, l.InsecureSkipTLSVerify, l.CaCertFile, bslCACert) + w.Close() // signal we're done writing + wg.Wait() // wait for all logs to be processed and printed return err } diff --git a/pkg/cmd/cli/restore/logs.go b/pkg/cmd/cli/restore/logs.go index f4315c917f..b100c5871f 100644 --- a/pkg/cmd/cli/restore/logs.go +++ b/pkg/cmd/cli/restore/logs.go @@ -31,6 +31,7 @@ import ( "github.com/vmware-tanzu/velero/pkg/cmd" "github.com/vmware-tanzu/velero/pkg/cmd/util/cacert" "github.com/vmware-tanzu/velero/pkg/cmd/util/downloadrequest" + "github.com/vmware-tanzu/velero/pkg/cmd/util/output" ) func NewLogsCommand(f client.Factory) *cobra.Command { @@ -77,7 +78,10 @@ func NewLogsCommand(f client.Factory) *cobra.Command { bslCACert = "" } - err = downloadrequest.StreamWithBSLCACert(context.Background(), kbClient, f.Namespace(), restoreName, velerov1api.DownloadTargetKindRestoreLog, os.Stdout, timeout, insecureSkipTLSVerify, caCertFile, bslCACert) + w, wg := output.PrintLogsWithColor() + err = downloadrequest.StreamWithBSLCACert(context.Background(), kbClient, f.Namespace(), restoreName, velerov1api.DownloadTargetKindRestoreLog, w, timeout, insecureSkipTLSVerify, caCertFile, bslCACert) + w.Close() // signal we're done writing + wg.Wait() // wait for all logs to be processed and printed cmd.CheckError(err) }, } diff --git a/pkg/cmd/util/output/logs_color.go b/pkg/cmd/util/output/logs_color.go new file mode 100644 index 0000000000..c2fd41dbe4 --- /dev/null +++ b/pkg/cmd/util/output/logs_color.go @@ -0,0 +1,127 @@ +/* +Copyright the Velero contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package output + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + "unicode/utf8" + + "github.com/fatih/color" + "github.com/go-logfmt/logfmt" +) + +func getLevelColor(level string) *color.Color { + switch level { + case "info": + return color.New(color.FgGreen) + case "warning": + return color.New(color.FgYellow) + case "error": + return color.New(color.FgRed) + case "debug": + return color.New(color.FgBlue) + default: + return color.New() + } +} + +// https://github.com/go-logfmt/logfmt/blob/e5396c6ee35145aead27da56e7921a7656f69624/encode.go#L235 +func needsQuotedValueRune(r rune) bool { + return r <= ' ' || r == '=' || r == '"' || r == 0x7f || r == utf8.RuneError +} + +// Process logs (by adding color) before printing them +func processAndPrintLogs(r io.Reader, w io.Writer) error { + d := logfmt.NewDecoder(r) + for d.ScanRecord() { // get record (line) + // Scan fields and get color + var fields [][2][]byte + var lineColor *color.Color + for d.ScanKeyval() { + fields = append(fields, [2][]byte{d.Key(), d.Value()}) + if string(d.Key()) == "level" { + lineColor = getLevelColor(string(d.Value())) + } + } + + // Re-encode with color. We do not use logfmt Encoder because it does not support color + for i, field := range fields { + key := string(field[0]) + value := string(field[1]) + + // Quote if needed + if strings.IndexFunc(value, needsQuotedValueRune) != -1 { + value = fmt.Sprintf("%q", value) + } + + // Add color + if lineColor != nil { // handle case where no color (log level) was found + if key == "level" { + colorCopy := *lineColor + value = colorCopy.Add(color.Bold).Sprintf("%s", value) + } + key = lineColor.Sprintf("%s", field[0]) + } + if i != 0 { + fmt.Fprint(w, " ") + } + fmt.Fprintf(w, "%s=%s", key, value) + } + fmt.Fprintln(w) + } + if err := d.Err(); err != nil { + return fmt.Errorf("error processing logs: %v", err) + } + return nil +} + +type nopCloser struct { + io.Writer +} + +func (nopCloser) Close() error { return nil } + +// Print logfmt-formatted logs to stdout with color based on log level +// if color.NoColor is set, logs will be directly piped to stdout without processing +// Returns the writer to write logs to, and a waitgroup to wait for processing to finish +// Writer must be closed once all logs have been written +// Note: this function is a wrapper around processAndPrintLogs to avoid always creating a goroutine +func PrintLogsWithColor() (io.WriteCloser, *sync.WaitGroup) { + // If NoColor, do not parse logs and directly fall back to stdout + var wg sync.WaitGroup + if color.NoColor { + return nopCloser{os.Stdout}, &wg + } else { + // Else, create a goroutine to process logs. + wg.Add(1) + pr, pw := io.Pipe() + + // Create coroutine to process logs + go func(pr *io.PipeReader) { + defer wg.Done() + err := processAndPrintLogs(pr, os.Stdout) + if err != nil { + fmt.Fprintf(os.Stderr, "error processing logs: %v\n", err) + } + }(pr) + return pw, &wg + } +} diff --git a/pkg/cmd/util/output/logs_color_test.go b/pkg/cmd/util/output/logs_color_test.go new file mode 100644 index 0000000000..9db93a1556 --- /dev/null +++ b/pkg/cmd/util/output/logs_color_test.go @@ -0,0 +1,76 @@ +/* +Copyright the Velero contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package output + +import ( + "bytes" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +var LOG_LINE = "level=info msg=\"This is a test log\" key1=value1 key2=\"value with spaces\"" +var LOG_LINE_2 = "level=INVALID msg=\"This is a second test log\" key1=value1 key2=\"value 2 with spaces\"" +var LOG_LINE_3 = "level=error msg=\"This is a thirdtest log\" key1=value1 key2=\"value 3 with spaces\"" + +// Note that all comparisons in this file work because color.NoColor is set to false by default, and thus no colors are added, even +// through the color adding code is run. + +func TestColoredLogHasLog(t *testing.T) { + inputBuf := &bytes.Buffer{} + inputBuf.WriteString(LOG_LINE) + + outputBuf := &bytes.Buffer{} + err := processAndPrintLogs(inputBuf, outputBuf) + if err != nil { + t.Fatalf("processAndPrintLogs returned error: %v", err) + } + + assert.Contains(t, outputBuf.String(), "This is a test log") +} + +// Test log line is unchanged since log is decomposed and re-composed +func TestColoredLogIsSameAsUncoloredLog(t *testing.T) { + inputBuf := &bytes.Buffer{} + inputBuf.WriteString(LOG_LINE) + + outputBuf := &bytes.Buffer{} + err := processAndPrintLogs(inputBuf, outputBuf) + if err != nil { + t.Fatalf("processAndPrintLogs returned error: %v", err) + } + + assert.Equal(t, LOG_LINE+"\n", outputBuf.String()) +} + +// Test all log lines are sent correctly (and unchanged) +func TestMultipleColoredLogs(t *testing.T) { + inputBuf := &bytes.Buffer{} + inputBuf.WriteString(LOG_LINE) + inputBuf.WriteString("\n") + inputBuf.WriteString(LOG_LINE_2) + inputBuf.WriteString("\n") + inputBuf.WriteString(LOG_LINE_3) + + outputBuf := &bytes.Buffer{} + err := processAndPrintLogs(inputBuf, outputBuf) + if err != nil { + t.Fatalf("processAndPrintLogs returned error: %v", err) + } + + assert.Equal(t, fmt.Sprintf("%v\n%v\n%v\n", LOG_LINE, LOG_LINE_2, LOG_LINE_3), outputBuf.String()) +} diff --git a/pkg/cmd/velero/velero.go b/pkg/cmd/velero/velero.go index a74c68fbcb..0e5ffae126 100644 --- a/pkg/cmd/velero/velero.go +++ b/pkg/cmd/velero/velero.go @@ -66,7 +66,7 @@ func NewCommand(name string) *cobra.Command { // Declare cmdFeatures and cmdColorzied here so we can access them in the PreRun hooks // without doing a chain of calls into the command's FlagSet var cmdFeatures veleroflag.StringArray - var cmdColorzied veleroflag.OptionalBool + var cmdColorized veleroflag.OptionalBool c := &cobra.Command{ Use: name, @@ -86,8 +86,8 @@ operations can also be performed as 'velero backup get' and 'velero schedule cre features.Enable(cmdFeatures...) switch { - case cmdColorzied.Value != nil: - color.NoColor = !*cmdColorzied.Value + case cmdColorized.Value != nil: + color.NoColor = !*cmdColorized.Value default: color.NoColor = !config.Colorized() } @@ -101,7 +101,7 @@ operations can also be performed as 'velero backup get' and 'velero schedule cre c.PersistentFlags().Var(&cmdFeatures, "features", "Comma-separated list of features to enable for this Velero process. Combines with values from $HOME/.config/velero/config.json if present") // Color will be enabled or disabled for all subcommands - c.PersistentFlags().Var(&cmdColorzied, "colorized", "Show colored output in TTY. Overrides 'colorized' value from $HOME/.config/velero/config.json if present. Enabled by default") + c.PersistentFlags().Var(&cmdColorized, "colorized", "Show colored output in TTY. Overrides 'colorized' value from $HOME/.config/velero/config.json if present. Enabled by default") c.AddCommand( backup.NewCommand(f),