diff --git a/src/pkg/logger/logger.go b/src/pkg/logger/logger.go new file mode 100644 index 0000000000..41400666d8 --- /dev/null +++ b/src/pkg/logger/logger.go @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +// Package logger implements a log/slog based logger in Zarf. +package logger + +import ( + "fmt" + "io" + "log/slog" + "os" + "strings" + "sync/atomic" +) + +var defaultLogger atomic.Pointer[slog.Logger] + +// init sets a logger with default config when the package is initialized. +func init() { + l, _ := New(ConfigDefault()) //nolint:errcheck + SetDefault(l) +} + +// Level declares each supported log level. These are 1:1 what log/slog supports by default. Info is the default level. +type Level int + +// Store names for Levels +var ( + Debug = Level(slog.LevelDebug) // -4 + Info = Level(slog.LevelInfo) // 0 + Warn = Level(slog.LevelWarn) // 4 + Error = Level(slog.LevelError) // 8 +) + +// validLevels is a set that provides an ergonomic way to check if a level is a member of the set. +var validLevels = map[Level]bool{ + Debug: true, + Info: true, + Warn: true, + Error: true, +} + +// strLevels maps a string to its Level. +var strLevels = map[string]Level{ + "debug": Debug, + "info": Info, + "warn": Warn, + "error": Error, +} + +// ParseLevel takes a string representation of a Level, ensure it exists, and then converts it into a Level. +func ParseLevel(s string) (Level, error) { + k := strings.ToLower(s) + l, ok := strLevels[k] + if !ok { + return 0, fmt.Errorf("invalid log level: %s", k) + } + return l, nil +} + +// Format declares the kind of logging handler to use. An empty Format defaults to text. +type Format string + +// ToLower takes a Format string and converts it to lowercase for case-agnostic validation. Users shouldn't have to care +// about "json" vs. "JSON" for example - they should both work. +func (f Format) ToLower() Format { + return Format(strings.ToLower(string(f))) +} + +// TODO(mkcp): Add dev format +var ( + // FormatText uses the standard slog TextHandler + FormatText Format = "text" + // FormatJSON uses the standard slog JSONHandler + FormatJSON Format = "json" + // FormatNone sends log writes to DestinationNone / io.Discard + FormatNone Format = "none" +) + +// More printers would be great, like dev format https://github.com/golang-cz/devslog +// and a pretty console slog https://github.com/phsym/console-slog + +// Destination declares an io.Writer to send logs to. +type Destination io.Writer + +var ( + // DestinationDefault points to Stderr + DestinationDefault Destination = os.Stderr + // DestinationNone discards logs as they are received + DestinationNone Destination = io.Discard +) + +// Config is configuration for a logger. +type Config struct { + // Level sets the log level. An empty value corresponds to Info aka 0. + Level + Format + Destination +} + +// ConfigDefault returns a Config with defaults like Text formatting at Info level writing to Stderr. +func ConfigDefault() Config { + return Config{ + Level: Info, + Format: FormatText, + Destination: DestinationDefault, // Stderr + } +} + +// New takes a Config and returns a validated logger. +func New(cfg Config) (*slog.Logger, error) { + var handler slog.Handler + opts := slog.HandlerOptions{} + + // Use default destination if none + if cfg.Destination == nil { + cfg.Destination = DestinationDefault + } + + // Check that we have a valid log level. + if !validLevels[cfg.Level] { + return nil, fmt.Errorf("unsupported log level: %d", cfg.Level) + } + opts.Level = slog.Level(cfg.Level) + + switch cfg.Format.ToLower() { + // Use Text handler if no format provided + case "", FormatText: + handler = slog.NewTextHandler(cfg.Destination, &opts) + case FormatJSON: + handler = slog.NewJSONHandler(cfg.Destination, &opts) + // TODO(mkcp): Add dev format + // case FormatDev: + // handler = slog.NewTextHandler(DestinationNone, &slog.HandlerOptions{ + // AddSource: true, + // }) + case FormatNone: + handler = slog.NewTextHandler(DestinationNone, &slog.HandlerOptions{}) + // Format not found, let's error out + default: + return nil, fmt.Errorf("unsupported log format: %s", cfg.Format) + } + + log := slog.New(handler) + return log, nil +} + +// Default retrieves a logger from the package default. This is intended as a fallback when a logger cannot easily be +// passed in as a dependency, like when developing a new function. Use it like you would use context.TODO(). +func Default() *slog.Logger { + return defaultLogger.Load() +} + +// SetDefault takes a logger and atomically stores it as the package default. This is intended to be called when the +// application starts to override the default config with application-specific config. See Default() for more usage +// details. +func SetDefault(l *slog.Logger) { + defaultLogger.Store(l) +} diff --git a/src/pkg/logger/logger_test.go b/src/pkg/logger/logger_test.go new file mode 100644 index 0000000000..db8851e25c --- /dev/null +++ b/src/pkg/logger/logger_test.go @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +// Package logger implements a log/slog based logger in Zarf. +package logger + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_New(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + cfg Config + }{ + { + name: "Empty level, format, and destination are ok", + cfg: Config{}, + }, + { + name: "Default config is ok", + cfg: ConfigDefault(), + }, + { + name: "Debug logs are ok", + cfg: Config{ + Level: Debug, + }, + }, + { + name: "Info logs are ok", + cfg: Config{ + Level: Info, + }, + }, + { + name: "Warn logs are ok", + cfg: Config{ + Level: Warn, + }, + }, + { + name: "Error logs are ok", + cfg: Config{ + Level: Error, + }, + }, + { + name: "Text format is supported", + cfg: Config{ + Format: FormatText, + }, + }, + { + name: "JSON format is supported", + cfg: Config{ + Format: FormatJSON, + }, + }, + { + name: "FormatNone is supported to disable logs", + cfg: Config{ + Format: FormatNone, + }, + }, + { + name: "DestinationNone is supported to disable logs", + cfg: Config{ + Destination: DestinationNone, + }, + }, + { + name: "users can send logs to any io.Writer", + cfg: Config{ + Destination: os.Stdout, + }, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + res, err := New(tc.cfg) + require.NoError(t, err) + require.NotNil(t, res) + }) + } +} + +func Test_NewErrors(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + cfg Config + }{ + { + name: "unsupported log level errors", + cfg: Config{ + Level: 3, + }, + }, + { + name: "wildly unsupported log level errors", + cfg: Config{ + Level: 42389412389213489, + }, + }, + { + name: "unsupported format errors", + cfg: Config{ + Format: "foobar", + }, + }, + { + name: "wildly unsupported format errors", + cfg: Config{ + Format: "^\\w+([-+.']\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*$ lorem ipsum dolor sit amet 243897 )*&($#", + }, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + res, err := New(tc.cfg) + require.Error(t, err) + require.Nil(t, res) + }) + } +} + +func Test_ParseLevel(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + s string + expect Level + }{ + { + name: "can parse debug", + s: "debug", + expect: Debug, + }, + { + name: "can parse info", + s: "Info", + expect: Info, + }, + { + name: "can parse warn", + s: "warn", + expect: Warn, + }, + { + name: "can parse error", + s: "error", + expect: Error, + }, + { + name: "can handle uppercase", + s: "ERROR", + expect: Error, + }, + { + name: "can handle inconsistent uppercase", + s: "errOR", + expect: Error, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + res, err := ParseLevel(tc.s) + require.NoError(t, err) + require.Equal(t, tc.expect, res) + }) + } +} + +func Test_ParseLevelErrors(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + s string + }{ + { + name: "errors out on unknown level", + s: "SUPER-DEBUG-10x-supremE", + }, + { + name: "is precise about character variations", + s: "érrør", + }, + { + name: "does not partial match level", + s: "error-info", + }, + { + name: "does not partial match level 2", + s: "info-error", + }, + { + name: "does not partial match level 3", + s: "info2", + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + _, err := ParseLevel(tc.s) + require.Error(t, err) + }) + } +}