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

Style Atmos Logger with Theme #1121

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 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
117 changes: 71 additions & 46 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"errors"
"fmt"
"io"
"math"
"os"
"regexp"
"strings"
Expand All @@ -16,7 +15,8 @@
e "github.com/cloudposse/atmos/internal/exec"
"github.com/cloudposse/atmos/internal/tui/templates"
tuiUtils "github.com/cloudposse/atmos/internal/tui/utils"
cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/utils"
u "github.com/cloudposse/atmos/pkg/utils"
Expand Down Expand Up @@ -58,17 +58,21 @@
}

// Only validate the config, don't store it yet since commands may need to add more info
_, err := cfg.InitCliConfig(configAndStacksInfo, false)
if err != nil {
if errors.Is(err, cfg.NotFound) {
// For help commands or when help flag is set, we don't want to show the error
if !isHelpRequested {
u.LogWarning(err.Error())
}
} else {
u.LogErrorAndExit(err)
_, err := config.InitCliConfig(configAndStacksInfo, false)
if err == nil {
return
}

if errors.Is(err, config.NotFound) {
// For help commands or when help flag is set, we don't want to show the error
if !isHelpRequested {
log.Warn("CLI configuration issue", "error", err)

Check warning on line 69 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L66-L69

Added lines #L66 - L69 were not covered by tests
}
return

Check warning on line 71 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L71

Added line #L71 was not covered by tests
}

log.Error("CLI configuration error", "error", err)
os.Exit(1)

Check failure on line 75 in cmd/root.go

View workflow job for this annotation

GitHub Actions / golangci

[golangci] cmd/root.go#L75

deep-exit: calls to os.Exit only in main() or init() functions (revive)
Raw output
cmd/root.go:75:3: deep-exit: calls to os.Exit only in main() or init() functions (revive)
		os.Exit(1)
		^

Check warning on line 75 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L74-L75

Added lines #L74 - L75 were not covered by tests
},
Run: func(cmd *cobra.Command, args []string) {
// Check Atmos configuration
Expand All @@ -88,41 +92,69 @@
},
}

// setupLogger configures the global logger based on application configuration using our logger pkg.
func setupLogger(atmosConfig *schema.AtmosConfiguration) {
switch atmosConfig.Logs.Level {
case "Trace":
log.SetLevel(log.DebugLevel)
case "Debug":
log.SetLevel(log.DebugLevel)
case "Info":
log.SetLevel(log.InfoLevel)
case "Warning":
log.SetLevel(log.WarnLevel)
case "Off":
log.SetLevel(math.MaxInt32)
default:
atmosLogger, err := logger.InitializeLoggerFromCliConfig(atmosConfig)
if err != nil {
log.Error("Failed to initialize logger from config", "error", err)

Check warning on line 100 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L99-L100

Added lines #L99 - L100 were not covered by tests
log.SetLevel(log.InfoLevel)
log.SetOutput(os.Stderr)
return

Check warning on line 103 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L102-L103

Added lines #L102 - L103 were not covered by tests
}

var output io.Writer
globalLogLevel := mapToCharmLevel(atmosLogger.LogLevel)
log.SetLevel(globalLogLevel)

configureLogOutput(atmosConfig.Logs.File)
}

switch atmosConfig.Logs.File {
// mapToCharmLevel converts our internal log level to a Charmbracelet log level.
func mapToCharmLevel(logLevel logger.LogLevel) log.Level {
switch logLevel {
case logger.LogLevelTrace:
return log.DebugLevel // Charmbracelet doesn't have Trace
case logger.LogLevelDebug:
return log.DebugLevel

Check warning on line 118 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L115-L118

Added lines #L115 - L118 were not covered by tests
case logger.LogLevelInfo:
return log.InfoLevel
case logger.LogLevelWarning:
return log.WarnLevel
case logger.LogLevelOff:
return log.FatalLevel + 1 // Disable logging
default:
return log.InfoLevel

Check warning on line 126 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L121-L126

Added lines #L121 - L126 were not covered by tests
}
}

// configureLogOutput sets up the output destination for the global logger.
func configureLogOutput(logFile string) {
// Handle standard output destinations
switch logFile {
case "/dev/stderr":
output = os.Stderr
case "/dev/stdout":
output = os.Stdout
log.SetOutput(os.Stderr)
return
case "/dev/stdout", "":
log.SetOutput(os.Stdout)
return

Check warning on line 139 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L137-L139

Added lines #L137 - L139 were not covered by tests
case "/dev/null":
output = io.Discard // More efficient than opening os.DevNull
default:
logFile, err := os.OpenFile(atmosConfig.Logs.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
if err != nil {
log.Fatal("Failed to open log file:", err)
}
defer logFile.Close()
output = logFile
log.SetOutput(io.Discard)
return

Check warning on line 142 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L141-L142

Added lines #L141 - L142 were not covered by tests
}

log.SetOutput(output)
// Handle custom log file (anything not a standard stream)
customFile, err := os.OpenFile(logFile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, config.StandardFilePermissions)
if err != nil {
log.Error("Failed to open log file, using stderr", "error", err)
log.SetOutput(os.Stderr)
return
}

Check warning on line 151 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L146-L151

Added lines #L146 - L151 were not covered by tests

// Important: We need to register this file to be closed at program exit
// to prevent file descriptor leaks, since we can't use defer here
// The Go runtime will close this file when the program exits, and the OS
// would clean it up anyway, but this is cleaner practice.
log.SetOutput(customFile)

Check warning on line 157 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L157

Added line #L157 was not covered by tests
}

// TODO: This function works well, but we should generally avoid implementing manual flag parsing,
Expand Down Expand Up @@ -168,9 +200,9 @@
// system dir, home dir, current dir, ENV vars, command-line arguments
// Here we need the custom commands from the config
var initErr error
atmosConfig, initErr = cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false)
atmosConfig, initErr = config.InitCliConfig(schema.ConfigAndStacksInfo{}, false)
utils.InitializeMarkdown(atmosConfig)
if initErr != nil && !errors.Is(initErr, cfg.NotFound) {
if initErr != nil && !errors.Is(initErr, config.NotFound) {
if isVersionCommand() {
log.Debug("warning: CLI configuration 'atmos.yaml' file not found", "error", initErr)
} else {
Expand Down Expand Up @@ -307,10 +339,3 @@
CheckForAtmosUpdateAndPrintMessage(atmosConfig)
})
}

// https://www.sobyte.net/post/2021-12/create-cli-app-with-cobra/
// https://github.com/spf13/cobra/blob/master/user_guide.md
// https://blog.knoldus.com/create-kubectl-like-cli-with-go-and-cobra/
// https://pkg.go.dev/github.com/c-bata/go-prompt
// https://pkg.go.dev/github.com/spf13/cobra
// https://scene-si.org/2017/04/20/managing-configuration-with-viper/
69 changes: 69 additions & 0 deletions examples/quick-start-simple/test_logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package main

import (
"errors"
"time"

"github.com/cloudposse/atmos/pkg/logger"
)

var ErrTest = errors.New("this is an error")

const (
KeyComponent = "component"
KeyStack = "stack"
KeyError = "err"
KeyDuration = "duration"
KeyPath = "path"
KeyDetails = "details"
)

func main() {
// Test individual log functions
testIndividualLogFunctions()

// Test complete Logger struct
testLoggerStruct()

// Test direct charmbracelet logger
testCharmLogger()

Check warning on line 29 in examples/quick-start-simple/test_logger.go

View check run for this annotation

Codecov / codecov/patch

examples/quick-start-simple/test_logger.go#L21-L29

Added lines #L21 - L29 were not covered by tests
}

func testIndividualLogFunctions() {
logger.Info("This is an info message")
logger.Debug("This is a debug message with context", KeyComponent, "station", KeyDuration, "500ms")
logger.Warn("This is a warning message", KeyStack, "prod-ue1")
logger.Error("Whoops! Something went wrong", KeyError, "kitchen on fire", KeyComponent, "weather")

time.Sleep(500 * time.Millisecond)

Check warning on line 38 in examples/quick-start-simple/test_logger.go

View check run for this annotation

Codecov / codecov/patch

examples/quick-start-simple/test_logger.go#L32-L38

Added lines #L32 - L38 were not covered by tests
}

func testLoggerStruct() {
atmosLogger, err := logger.InitializeLogger(logger.LogLevelTrace, "")
if err != nil {
panic(err)

Check warning on line 44 in examples/quick-start-simple/test_logger.go

View check run for this annotation

Codecov / codecov/patch

examples/quick-start-simple/test_logger.go#L41-L44

Added lines #L41 - L44 were not covered by tests
}

atmosLogger.Trace("This is a trace message")
atmosLogger.Debug("This is a debug message")
atmosLogger.Info("This is an info message")
atmosLogger.Warning("This is a warning message")
atmosLogger.Error(ErrTest)

time.Sleep(500 * time.Millisecond)

Check warning on line 53 in examples/quick-start-simple/test_logger.go

View check run for this annotation

Codecov / codecov/patch

examples/quick-start-simple/test_logger.go#L47-L53

Added lines #L47 - L53 were not covered by tests
}

func testCharmLogger() {
charmLogger := logger.GetCharmLogger()

charmLogger.SetTimeFormat(time.Kitchen)

charmLogger.Info("Processing component", KeyComponent, "station", KeyStack, "dev-ue1")
charmLogger.Debug("Found configuration", KeyPath, "/stacks/deploy/us-east-1/dev/station.yaml")
charmLogger.Warn("Component configuration outdated", KeyComponent, "weather", "lastUpdated", "90 days ago")
charmLogger.Error("Failed to apply changes",
KeyError, "validation failed",
KeyComponent, "weather",
KeyDetails, "required variables missing",
KeyStack, "dev-ue1")

Check warning on line 68 in examples/quick-start-simple/test_logger.go

View check run for this annotation

Codecov / codecov/patch

examples/quick-start-simple/test_logger.go#L56-L68

Added lines #L56 - L68 were not covered by tests
}
2 changes: 1 addition & 1 deletion internal/exec/describe_affected.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
if err != nil {
return DescribeAffectedCmdArgs{}, err
}
logger, err := l.NewLoggerFromCliConfig(atmosConfig)
logger, err := l.InitializeLoggerFromCliConfig(&atmosConfig)

Check warning on line 51 in internal/exec/describe_affected.go

View check run for this annotation

Codecov / codecov/patch

internal/exec/describe_affected.go#L51

Added line #L51 was not covered by tests
if err != nil {
return DescribeAffectedCmdArgs{}, err
}
Expand Down
2 changes: 1 addition & 1 deletion internal/exec/pro.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
return ProLockUnlockCmdArgs{}, err
}

logger, err := l.NewLoggerFromCliConfig(atmosConfig)
logger, err := l.InitializeLoggerFromCliConfig(&atmosConfig)

Check warning on line 42 in internal/exec/pro.go

View check run for this annotation

Codecov / codecov/patch

internal/exec/pro.go#L42

Added line #L42 was not covered by tests
if err != nil {
return ProLockUnlockCmdArgs{}, err
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/config/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,6 @@ const (
AtmosYamlFuncEnv = "!env"

TerraformDefaultWorkspace = "default"

StandardFilePermissions = 0o644
)
132 changes: 132 additions & 0 deletions pkg/logger/charmbracelet.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package logger

import (
"os"

"github.com/charmbracelet/lipgloss"
log "github.com/charmbracelet/log"
"github.com/cloudposse/atmos/pkg/ui/theme"
)

var helperLogger *log.Logger

func init() {
helperLogger = GetCharmLogger()
}

// GetCharmLogger returns a pre-configured Charmbracelet logger with Atmos styling.
func GetCharmLogger() *log.Logger {
styles := getAtmosLogStyles()
logger := log.New(os.Stderr)
logger.SetStyles(styles)
return logger
}

// GetCharmLoggerWithOutput returns a pre-configured Charmbracelet logger with custom output.
func GetCharmLoggerWithOutput(output *os.File) *log.Logger {
styles := getAtmosLogStyles()
logger := log.New(output)
logger.SetStyles(styles)
return logger
}

// getAtmosLogStyles returns custom styles for the Charmbracelet logger using Atmos theme colors.
func getAtmosLogStyles() *log.Styles {
styles := log.DefaultStyles()

const (
paddingVertical = 0
paddingHorizontal = 1
)

configureLogLevelStyles(styles, paddingVertical, paddingHorizontal)
configureKeyStyles(styles)

return styles
}

// configureLogLevelStyles configures the styles for different log levels.
func configureLogLevelStyles(styles *log.Styles, paddingVertical, paddingHorizontal int) {
const (
errorLevelLabel = "ERROR"
warnLevelLabel = "WARN"
infoLevelLabel = "INFO"
debugLevelLabel = "DEBUG"
)

// Error.
styles.Levels[log.ErrorLevel] = lipgloss.NewStyle().
SetString(errorLevelLabel).
Padding(paddingVertical, paddingHorizontal, paddingVertical, paddingHorizontal).
Background(lipgloss.Color(theme.ColorPink)).
Foreground(lipgloss.Color(theme.ColorWhite))

// Warning.
styles.Levels[log.WarnLevel] = lipgloss.NewStyle().
SetString(warnLevelLabel).
Padding(paddingVertical, paddingHorizontal, paddingVertical, paddingHorizontal).
Background(lipgloss.Color(theme.ColorPink)).
Foreground(lipgloss.Color(theme.ColorDarkGray))

// Info.
styles.Levels[log.InfoLevel] = lipgloss.NewStyle().
SetString(infoLevelLabel).
Padding(paddingVertical, paddingHorizontal, paddingVertical, paddingHorizontal).
Background(lipgloss.Color(theme.ColorCyan)).
Foreground(lipgloss.Color(theme.ColorDarkGray))

// Debug.
styles.Levels[log.DebugLevel] = lipgloss.NewStyle().
SetString(debugLevelLabel).
Padding(paddingVertical, paddingHorizontal, paddingVertical, paddingHorizontal).
Background(lipgloss.Color(theme.ColorBlue)).
Foreground(lipgloss.Color(theme.ColorWhite))
}

// configureKeyStyles configures the styles for different log keys.
func configureKeyStyles(styles *log.Styles) {
const (
keyError = "error"
keyComponent = "component"
keyStack = "stack"
keyDuration = "duration"
)

// Custom style for 'err' key
styles.Keys[keyError] = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorPink))
styles.Values[keyError] = lipgloss.NewStyle().Bold(true)

// Custom style for 'component' key
styles.Keys[keyComponent] = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorPink))

// Custom style for 'stack' key
styles.Keys[keyStack] = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorBlue))

// Custom style for 'duration' key
styles.Keys[keyDuration] = lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorGreen))
}

// Error logs an error message with context.
func Error(message string, keyvals ...interface{}) {
helperLogger.Error(message, keyvals...)

Check warning on line 111 in pkg/logger/charmbracelet.go

View check run for this annotation

Codecov / codecov/patch

pkg/logger/charmbracelet.go#L110-L111

Added lines #L110 - L111 were not covered by tests
}

// Warn logs a warning message with context.
func Warn(message string, keyvals ...interface{}) {
helperLogger.Warn(message, keyvals...)

Check warning on line 116 in pkg/logger/charmbracelet.go

View check run for this annotation

Codecov / codecov/patch

pkg/logger/charmbracelet.go#L115-L116

Added lines #L115 - L116 were not covered by tests
}

// Info logs an informational message with context.
func Info(message string, keyvals ...interface{}) {
helperLogger.Info(message, keyvals...)

Check warning on line 121 in pkg/logger/charmbracelet.go

View check run for this annotation

Codecov / codecov/patch

pkg/logger/charmbracelet.go#L120-L121

Added lines #L120 - L121 were not covered by tests
}

// Debug logs a debug message with context.
func Debug(message string, keyvals ...interface{}) {
helperLogger.Debug(message, keyvals...)

Check warning on line 126 in pkg/logger/charmbracelet.go

View check run for this annotation

Codecov / codecov/patch

pkg/logger/charmbracelet.go#L125-L126

Added lines #L125 - L126 were not covered by tests
}

// Fatal logs an error message and exits with status code 1.
func Fatal(message string, keyvals ...interface{}) {
helperLogger.Fatal(message, keyvals...)

Check warning on line 131 in pkg/logger/charmbracelet.go

View check run for this annotation

Codecov / codecov/patch

pkg/logger/charmbracelet.go#L130-L131

Added lines #L130 - L131 were not covered by tests
}
Loading
Loading