diff --git a/internal/exec/describe_affected.go b/internal/exec/describe_affected.go index 288c6943fb..8a467b85e6 100644 --- a/internal/exec/describe_affected.go +++ b/internal/exec/describe_affected.go @@ -48,7 +48,7 @@ func parseDescribeAffectedCliArgs(cmd *cobra.Command, args []string) (DescribeAf if err != nil { return DescribeAffectedCmdArgs{}, err } - logger, err := l.NewLoggerFromCliConfig(atmosConfig) + logger, err := l.NewLoggerFromCliConfig(&atmosConfig) if err != nil { return DescribeAffectedCmdArgs{}, err } diff --git a/internal/exec/pro.go b/internal/exec/pro.go index 73fc914691..163a2ff351 100644 --- a/internal/exec/pro.go +++ b/internal/exec/pro.go @@ -39,7 +39,7 @@ func parseLockUnlockCliArgs(cmd *cobra.Command, args []string) (ProLockUnlockCmd return ProLockUnlockCmdArgs{}, err } - logger, err := l.NewLoggerFromCliConfig(atmosConfig) + logger, err := l.NewLoggerFromCliConfig(&atmosConfig) if err != nil { return ProLockUnlockCmdArgs{}, err } diff --git a/pkg/config/const.go b/pkg/config/const.go index 0ea626704f..0c3f87aecc 100644 --- a/pkg/config/const.go +++ b/pkg/config/const.go @@ -86,4 +86,6 @@ const ( AtmosYamlFuncEnv = "!env" TerraformDefaultWorkspace = "default" + + StandardFilePermissions = 0o644 ) diff --git a/pkg/logger/charmbracelet.go b/pkg/logger/charmbracelet.go new file mode 100644 index 0000000000..563b7bec3c --- /dev/null +++ b/pkg/logger/charmbracelet.go @@ -0,0 +1,147 @@ +package logger + +import ( + "os" + + "github.com/charmbracelet/lipgloss" + log "github.com/charmbracelet/log" + "github.com/cloudposse/atmos/pkg/ui/theme" +) + +const TraceLevel log.Level = log.DebugLevel - 1 + +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 = "DEBU" + traceLevelLabel = "TRCE" + ) + + // Style `Error` log messages. + styles.Levels[log.ErrorLevel] = lipgloss.NewStyle(). + SetString(errorLevelLabel). + Padding(paddingVertical, paddingHorizontal, paddingVertical, paddingHorizontal). + Background(lipgloss.Color(theme.ColorPink)). + Foreground(lipgloss.Color(theme.ColorWhite)) + + // Style `Warning` log messages. + styles.Levels[log.WarnLevel] = lipgloss.NewStyle(). + SetString(warnLevelLabel). + Padding(paddingVertical, paddingHorizontal, paddingVertical, paddingHorizontal). + Background(lipgloss.Color(theme.ColorPink)). + Foreground(lipgloss.Color(theme.ColorDarkGray)) + + // Style `Info` log messages. + styles.Levels[log.InfoLevel] = lipgloss.NewStyle(). + SetString(infoLevelLabel). + Padding(paddingVertical, paddingHorizontal, paddingVertical, paddingHorizontal). + Background(lipgloss.Color(theme.ColorCyan)). + Foreground(lipgloss.Color(theme.ColorDarkGray)) + + // Style `Debug` log messages. + styles.Levels[log.DebugLevel] = lipgloss.NewStyle(). + SetString(debugLevelLabel). + Padding(paddingVertical, paddingHorizontal, paddingVertical, paddingHorizontal). + Background(lipgloss.Color(theme.ColorBlue)). + Foreground(lipgloss.Color(theme.ColorWhite)) + + // Style `Trace` log messages. + styles.Levels[TraceLevel] = lipgloss.NewStyle(). + SetString(traceLevelLabel). + Padding(paddingVertical, paddingHorizontal, paddingVertical, paddingHorizontal). + Background(lipgloss.Color(theme.ColorDarkGray)). + 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...) +} + +// Warn logs a warning message with context. +func Warn(message string, keyvals ...interface{}) { + helperLogger.Warn(message, keyvals...) +} + +// Info logs an informational message with context. +func Info(message string, keyvals ...interface{}) { + helperLogger.Info(message, keyvals...) +} + +// Debug logs a debug message with context. +func Debug(message string, keyvals ...interface{}) { + helperLogger.Debug(message, keyvals...) +} + +// Trace logs a trace message with context. +func Trace(message string, keyvals ...interface{}) { + helperLogger.Log(TraceLevel, message, keyvals...) +} + +// Fatal logs an error message and exits with status code 1. +func Fatal(message string, keyvals ...interface{}) { + helperLogger.Fatal(message, keyvals...) +} diff --git a/pkg/logger/charmbracelet_test.go b/pkg/logger/charmbracelet_test.go new file mode 100644 index 0000000000..1ede0a9006 --- /dev/null +++ b/pkg/logger/charmbracelet_test.go @@ -0,0 +1,92 @@ +package logger + +import ( + "os" + "path/filepath" + "testing" + + "github.com/charmbracelet/lipgloss" + log "github.com/charmbracelet/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetCharmLogger(t *testing.T) { + logger := GetCharmLogger() + require.NotNil(t, logger, "Should return a non-nil logger") + + // These should not panic. + assert.NotPanics(t, func() { + logger.SetLevel(log.InfoLevel) + logger.SetTimeFormat("") + }) +} + +func TestGetCharmLoggerWithOutput(t *testing.T) { + tempDir := os.TempDir() + logFile := filepath.Join(tempDir, "charm_test.log") + defer os.Remove(logFile) + + f, err := os.Create(logFile) + require.NoError(t, err, "Should create log file without error") + + logger := GetCharmLoggerWithOutput(f) + require.NotNil(t, logger, "Should return a non-nil logger") + + logger.SetTimeFormat("") + logger.Info("File test message") + + f.Close() + + data, err := os.ReadFile(logFile) + require.NoError(t, err, "Should read log file without error") + + content := string(data) + assert.Contains(t, content, "INFO", "Should have INFO level in file") + assert.Contains(t, content, "File test message", "Should contain the message") +} + +// Test the actual styling implementation. +func TestCharmLoggerStylingDetails(t *testing.T) { + styles := getAtmosLogStyles() + + assert.NotEqual(t, lipgloss.Style{}, styles.Levels[log.ErrorLevel], "ERROR level should have styling") + assert.NotEqual(t, lipgloss.Style{}, styles.Levels[log.WarnLevel], "WARN level should have styling") + assert.NotEqual(t, lipgloss.Style{}, styles.Levels[log.InfoLevel], "INFO level should have styling") + assert.NotEqual(t, lipgloss.Style{}, styles.Levels[log.DebugLevel], "DEBUG level should have styling") + + assert.Contains(t, styles.Levels[log.ErrorLevel].Render("ERROR"), "ERROR", "ERROR label should be styled") + assert.Contains(t, styles.Levels[log.WarnLevel].Render("WARN"), "WARN", "WARN label should be styled") + assert.Contains(t, styles.Levels[log.InfoLevel].Render("INFO"), "INFO", "INFO label should be styled") + assert.Contains(t, styles.Levels[log.DebugLevel].Render("DEBUG"), "DEBUG", "DEBUG label should be styled") + + assert.NotNil(t, styles.Keys["err"], "err key should have styling") + assert.NotNil(t, styles.Values["err"], "err value should have styling") + assert.NotNil(t, styles.Keys["component"], "component key should have styling") + assert.NotNil(t, styles.Keys["stack"], "stack key should have styling") +} + +func ExampleGetCharmLogger() { + logger := GetCharmLogger() + + logger.SetTimeFormat("2006-01-02 15:04:05") + logger.SetLevel(log.InfoLevel) + + logger.Info("User logged in", "user_id", "12345", "component", "auth") + + logger.Error("Failed to process request", + "err", "connection timeout", + "component", "api", + "duration", "1.5s") + + logger.Warn("Resource utilization high", + "component", "database", + "stack", "prod-ue1", + "usage", "95%") + + logger.Debug("Processing request", + "request_id", "abc123", + "component", "api", + "endpoint", "/users", + "method", "GET") +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 0caba0045d..802699b847 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + log "github.com/charmbracelet/log" "github.com/fatih/color" "github.com/cloudposse/atmos/pkg/schema" @@ -20,7 +21,7 @@ const ( LogLevelWarning LogLevel = "Warning" ) -// logLevelOrder defines the order of log levels from most verbose to least verbose +// logLevelOrder defines the order of log levels from most verbose to least verbose. var logLevelOrder = map[LogLevel]int{ LogLevelTrace: 0, LogLevelDebug: 1, @@ -32,16 +33,46 @@ var logLevelOrder = map[LogLevel]int{ type Logger struct { LogLevel LogLevel File string + charm *log.Logger } func NewLogger(logLevel LogLevel, file string) (*Logger, error) { + charm := GetCharmLogger() + + // Set log level + charmLevel := log.InfoLevel + switch logLevel { + case LogLevelTrace: + charmLevel = TraceLevel + case LogLevelDebug: + charmLevel = log.DebugLevel + case LogLevelInfo: + charmLevel = log.InfoLevel + case LogLevelWarning: + charmLevel = log.WarnLevel + case LogLevelOff: + charmLevel = log.FatalLevel + 1 // Set to a level higher than any defined level. + } + charm.SetLevel(charmLevel) + + if shouldUseCustomLogFile(file) { + logFile, err := openLogFile(file) + if err != nil { + return nil, err + } + charm = GetCharmLoggerWithOutput(logFile) + charm.SetLevel(charmLevel) + } + return &Logger{ LogLevel: logLevel, File: file, + charm: charm, }, nil } -func NewLoggerFromCliConfig(cfg schema.AtmosConfiguration) (*Logger, error) { +// NewLoggerFromCliConfig creates a logger based on Atmos CLI configuration. +func NewLoggerFromCliConfig(cfg *schema.AtmosConfiguration) (*Logger, error) { logLevel, err := ParseLogLevel(cfg.Logs.Level) if err != nil { return nil, err @@ -110,6 +141,8 @@ func (l *Logger) SetLogLevel(logLevel LogLevel) error { func (l *Logger) Error(err error) { if err != nil && l.LogLevel != LogLevelOff { + l.charm.Error("Error occurred", "error", err) + _, err2 := theme.Colors.Error.Fprintln(color.Error, err.Error()+"\n") if err2 != nil { color.Red("Error logging the error:") @@ -120,7 +153,24 @@ func (l *Logger) Error(err error) { } } -// isLevelEnabled checks if a given log level should be enabled based on the logger's current level +// shouldUseCustomLogFile returns true if a custom log file should be used instead of standard streams. +func shouldUseCustomLogFile(file string) bool { + return file != "" && file != "/dev/stdout" && file != "/dev/stderr" +} + +// FilePermDefault is the default permission for log files (0644 in octal). TODO: refactor this later. +const FilePermDefault = 0o644 + +// openLogFile opens a log file for writing with appropriate flags. +func openLogFile(file string) (*os.File, error) { + f, err := os.OpenFile(file, os.O_WRONLY|os.O_APPEND|os.O_CREATE, FilePermDefault) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + return f, nil +} + +// isLevelEnabled checks if a given log level should be enabled based on the logger's current level. func (l *Logger) isLevelEnabled(level LogLevel) bool { if l.LogLevel == LogLevelOff { return false @@ -130,24 +180,24 @@ func (l *Logger) isLevelEnabled(level LogLevel) bool { func (l *Logger) Trace(message string) { if l.isLevelEnabled(LogLevelTrace) { - l.log(theme.Colors.Info, message) + l.charm.Log(TraceLevel, message) } } func (l *Logger) Debug(message string) { if l.isLevelEnabled(LogLevelDebug) { - l.log(theme.Colors.Info, message) + l.charm.Debug(message) } } func (l *Logger) Info(message string) { if l.isLevelEnabled(LogLevelInfo) { - l.log(theme.Colors.Info, message) + l.charm.Info(message) } } func (l *Logger) Warning(message string) { if l.isLevelEnabled(LogLevelWarning) { - l.log(theme.Colors.Warning, message) + l.charm.Warn(message) } } diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index 694728da71..9d4b40e3b3 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -2,7 +2,6 @@ package logger import ( "bytes" - "fmt" "io" "os" "path/filepath" @@ -46,6 +45,8 @@ func TestNewLogger(t *testing.T) { assert.NotNil(t, logger) assert.Equal(t, LogLevelDebug, logger.LogLevel) assert.Equal(t, "/dev/stdout", logger.File) + + assert.NotNil(t, logger.charm, "Charmbracelet logger should be initialized") } func TestNewLoggerFromCliConfig(t *testing.T) { @@ -56,7 +57,7 @@ func TestNewLoggerFromCliConfig(t *testing.T) { }, } - logger, err := NewLoggerFromCliConfig(atmosConfig) + logger, err := NewLoggerFromCliConfig(&atmosConfig) assert.NoError(t, err) assert.NotNil(t, logger) assert.Equal(t, LogLevelInfo, logger.LogLevel) @@ -149,8 +150,7 @@ func TestLogger_Error(t *testing.T) { color.Error = &buf logger, _ := NewLogger(LogLevelWarning, "/dev/stderr") - err := fmt.Errorf("This is an error") - logger.Error(err) + logger.Error(ErrTest) assert.Contains(t, buf.String(), "This is an error") } @@ -320,7 +320,7 @@ func TestLoggerFromCliConfig(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - logger, err := NewLoggerFromCliConfig(test.config) + logger, err := NewLoggerFromCliConfig(&test.config) if test.expectError { assert.Error(t, err) assert.Nil(t, logger) diff --git a/pkg/logger/test_logger.go b/pkg/logger/test_logger.go new file mode 100644 index 0000000000..48327786fa --- /dev/null +++ b/pkg/logger/test_logger.go @@ -0,0 +1,67 @@ +package logger + +import ( + "errors" + "time" +) + +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() +} + +func testIndividualLogFunctions() { + Info("This is an info message") + Debug("This is a debug message with context", KeyComponent, "station", KeyDuration, "500ms") + Warn("This is a warning message", KeyStack, "prod-ue1") + Error("Whoops! Something went wrong", KeyError, "kitchen on fire", KeyComponent, "weather") + + time.Sleep(500 * time.Millisecond) +} + +func testLoggerStruct() { + atmosLogger, err := NewLogger(LogLevelTrace, "") + if err != nil { + panic(err) + } + + 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) +} + +func testCharmLogger() { + charmLogger := 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") +}