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

feat: add logger package and tests #3108

Merged
merged 5 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
159 changes: 159 additions & 0 deletions src/pkg/logger/logger.go
Original file line number Diff line number Diff line change
@@ -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.
AustinAbro321 marked this conversation as resolved.
Show resolved Hide resolved
type Level int

// Store names for Levels
var (
mkcp marked this conversation as resolved.
Show resolved Hide resolved
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"
mkcp marked this conversation as resolved.
Show resolved Hide resolved
// 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 {
mkcp marked this conversation as resolved.
Show resolved Hide resolved
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)
}
216 changes: 216 additions & 0 deletions src/pkg/logger/logger_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}