Skip to content

Commit 39d0812

Browse files
authored
feat: add logger package and tests (#3108)
Signed-off-by: Kit Patella <[email protected]>
1 parent 5d71319 commit 39d0812

File tree

2 files changed

+375
-0
lines changed

2 files changed

+375
-0
lines changed

src/pkg/logger/logger.go

+159
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: 2021-Present The Zarf Authors
3+
4+
// Package logger implements a log/slog based logger in Zarf.
5+
package logger
6+
7+
import (
8+
"fmt"
9+
"io"
10+
"log/slog"
11+
"os"
12+
"strings"
13+
"sync/atomic"
14+
)
15+
16+
var defaultLogger atomic.Pointer[slog.Logger]
17+
18+
// init sets a logger with default config when the package is initialized.
19+
func init() {
20+
l, _ := New(ConfigDefault()) //nolint:errcheck
21+
SetDefault(l)
22+
}
23+
24+
// Level declares each supported log level. These are 1:1 what log/slog supports by default. Info is the default level.
25+
type Level int
26+
27+
// Store names for Levels
28+
var (
29+
Debug = Level(slog.LevelDebug) // -4
30+
Info = Level(slog.LevelInfo) // 0
31+
Warn = Level(slog.LevelWarn) // 4
32+
Error = Level(slog.LevelError) // 8
33+
)
34+
35+
// validLevels is a set that provides an ergonomic way to check if a level is a member of the set.
36+
var validLevels = map[Level]bool{
37+
Debug: true,
38+
Info: true,
39+
Warn: true,
40+
Error: true,
41+
}
42+
43+
// strLevels maps a string to its Level.
44+
var strLevels = map[string]Level{
45+
"debug": Debug,
46+
"info": Info,
47+
"warn": Warn,
48+
"error": Error,
49+
}
50+
51+
// ParseLevel takes a string representation of a Level, ensure it exists, and then converts it into a Level.
52+
func ParseLevel(s string) (Level, error) {
53+
k := strings.ToLower(s)
54+
l, ok := strLevels[k]
55+
if !ok {
56+
return 0, fmt.Errorf("invalid log level: %s", k)
57+
}
58+
return l, nil
59+
}
60+
61+
// Format declares the kind of logging handler to use. An empty Format defaults to text.
62+
type Format string
63+
64+
// ToLower takes a Format string and converts it to lowercase for case-agnostic validation. Users shouldn't have to care
65+
// about "json" vs. "JSON" for example - they should both work.
66+
func (f Format) ToLower() Format {
67+
return Format(strings.ToLower(string(f)))
68+
}
69+
70+
// TODO(mkcp): Add dev format
71+
var (
72+
// FormatText uses the standard slog TextHandler
73+
FormatText Format = "text"
74+
// FormatJSON uses the standard slog JSONHandler
75+
FormatJSON Format = "json"
76+
// FormatNone sends log writes to DestinationNone / io.Discard
77+
FormatNone Format = "none"
78+
)
79+
80+
// More printers would be great, like dev format https://github.com/golang-cz/devslog
81+
// and a pretty console slog https://github.com/phsym/console-slog
82+
83+
// Destination declares an io.Writer to send logs to.
84+
type Destination io.Writer
85+
86+
var (
87+
// DestinationDefault points to Stderr
88+
DestinationDefault Destination = os.Stderr
89+
// DestinationNone discards logs as they are received
90+
DestinationNone Destination = io.Discard
91+
)
92+
93+
// Config is configuration for a logger.
94+
type Config struct {
95+
// Level sets the log level. An empty value corresponds to Info aka 0.
96+
Level
97+
Format
98+
Destination
99+
}
100+
101+
// ConfigDefault returns a Config with defaults like Text formatting at Info level writing to Stderr.
102+
func ConfigDefault() Config {
103+
return Config{
104+
Level: Info,
105+
Format: FormatText,
106+
Destination: DestinationDefault, // Stderr
107+
}
108+
}
109+
110+
// New takes a Config and returns a validated logger.
111+
func New(cfg Config) (*slog.Logger, error) {
112+
var handler slog.Handler
113+
opts := slog.HandlerOptions{}
114+
115+
// Use default destination if none
116+
if cfg.Destination == nil {
117+
cfg.Destination = DestinationDefault
118+
}
119+
120+
// Check that we have a valid log level.
121+
if !validLevels[cfg.Level] {
122+
return nil, fmt.Errorf("unsupported log level: %d", cfg.Level)
123+
}
124+
opts.Level = slog.Level(cfg.Level)
125+
126+
switch cfg.Format.ToLower() {
127+
// Use Text handler if no format provided
128+
case "", FormatText:
129+
handler = slog.NewTextHandler(cfg.Destination, &opts)
130+
case FormatJSON:
131+
handler = slog.NewJSONHandler(cfg.Destination, &opts)
132+
// TODO(mkcp): Add dev format
133+
// case FormatDev:
134+
// handler = slog.NewTextHandler(DestinationNone, &slog.HandlerOptions{
135+
// AddSource: true,
136+
// })
137+
case FormatNone:
138+
handler = slog.NewTextHandler(DestinationNone, &slog.HandlerOptions{})
139+
// Format not found, let's error out
140+
default:
141+
return nil, fmt.Errorf("unsupported log format: %s", cfg.Format)
142+
}
143+
144+
log := slog.New(handler)
145+
return log, nil
146+
}
147+
148+
// Default retrieves a logger from the package default. This is intended as a fallback when a logger cannot easily be
149+
// passed in as a dependency, like when developing a new function. Use it like you would use context.TODO().
150+
func Default() *slog.Logger {
151+
return defaultLogger.Load()
152+
}
153+
154+
// SetDefault takes a logger and atomically stores it as the package default. This is intended to be called when the
155+
// application starts to override the default config with application-specific config. See Default() for more usage
156+
// details.
157+
func SetDefault(l *slog.Logger) {
158+
defaultLogger.Store(l)
159+
}

src/pkg/logger/logger_test.go

+216
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: 2021-Present The Zarf Authors
3+
4+
// Package logger implements a log/slog based logger in Zarf.
5+
package logger
6+
7+
import (
8+
"os"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func Test_New(t *testing.T) {
15+
t.Parallel()
16+
17+
tt := []struct {
18+
name string
19+
cfg Config
20+
}{
21+
{
22+
name: "Empty level, format, and destination are ok",
23+
cfg: Config{},
24+
},
25+
{
26+
name: "Default config is ok",
27+
cfg: ConfigDefault(),
28+
},
29+
{
30+
name: "Debug logs are ok",
31+
cfg: Config{
32+
Level: Debug,
33+
},
34+
},
35+
{
36+
name: "Info logs are ok",
37+
cfg: Config{
38+
Level: Info,
39+
},
40+
},
41+
{
42+
name: "Warn logs are ok",
43+
cfg: Config{
44+
Level: Warn,
45+
},
46+
},
47+
{
48+
name: "Error logs are ok",
49+
cfg: Config{
50+
Level: Error,
51+
},
52+
},
53+
{
54+
name: "Text format is supported",
55+
cfg: Config{
56+
Format: FormatText,
57+
},
58+
},
59+
{
60+
name: "JSON format is supported",
61+
cfg: Config{
62+
Format: FormatJSON,
63+
},
64+
},
65+
{
66+
name: "FormatNone is supported to disable logs",
67+
cfg: Config{
68+
Format: FormatNone,
69+
},
70+
},
71+
{
72+
name: "DestinationNone is supported to disable logs",
73+
cfg: Config{
74+
Destination: DestinationNone,
75+
},
76+
},
77+
{
78+
name: "users can send logs to any io.Writer",
79+
cfg: Config{
80+
Destination: os.Stdout,
81+
},
82+
},
83+
}
84+
for _, tc := range tt {
85+
t.Run(tc.name, func(t *testing.T) {
86+
res, err := New(tc.cfg)
87+
require.NoError(t, err)
88+
require.NotNil(t, res)
89+
})
90+
}
91+
}
92+
93+
func Test_NewErrors(t *testing.T) {
94+
t.Parallel()
95+
96+
tt := []struct {
97+
name string
98+
cfg Config
99+
}{
100+
{
101+
name: "unsupported log level errors",
102+
cfg: Config{
103+
Level: 3,
104+
},
105+
},
106+
{
107+
name: "wildly unsupported log level errors",
108+
cfg: Config{
109+
Level: 42389412389213489,
110+
},
111+
},
112+
{
113+
name: "unsupported format errors",
114+
cfg: Config{
115+
Format: "foobar",
116+
},
117+
},
118+
{
119+
name: "wildly unsupported format errors",
120+
cfg: Config{
121+
Format: "^\\w+([-+.']\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*$ lorem ipsum dolor sit amet 243897 )*&($#",
122+
},
123+
},
124+
}
125+
for _, tc := range tt {
126+
t.Run(tc.name, func(t *testing.T) {
127+
res, err := New(tc.cfg)
128+
require.Error(t, err)
129+
require.Nil(t, res)
130+
})
131+
}
132+
}
133+
134+
func Test_ParseLevel(t *testing.T) {
135+
t.Parallel()
136+
137+
tt := []struct {
138+
name string
139+
s string
140+
expect Level
141+
}{
142+
{
143+
name: "can parse debug",
144+
s: "debug",
145+
expect: Debug,
146+
},
147+
{
148+
name: "can parse info",
149+
s: "Info",
150+
expect: Info,
151+
},
152+
{
153+
name: "can parse warn",
154+
s: "warn",
155+
expect: Warn,
156+
},
157+
{
158+
name: "can parse error",
159+
s: "error",
160+
expect: Error,
161+
},
162+
{
163+
name: "can handle uppercase",
164+
s: "ERROR",
165+
expect: Error,
166+
},
167+
{
168+
name: "can handle inconsistent uppercase",
169+
s: "errOR",
170+
expect: Error,
171+
},
172+
}
173+
for _, tc := range tt {
174+
t.Run(tc.name, func(t *testing.T) {
175+
res, err := ParseLevel(tc.s)
176+
require.NoError(t, err)
177+
require.Equal(t, tc.expect, res)
178+
})
179+
}
180+
}
181+
182+
func Test_ParseLevelErrors(t *testing.T) {
183+
t.Parallel()
184+
185+
tt := []struct {
186+
name string
187+
s string
188+
}{
189+
{
190+
name: "errors out on unknown level",
191+
s: "SUPER-DEBUG-10x-supremE",
192+
},
193+
{
194+
name: "is precise about character variations",
195+
s: "érrør",
196+
},
197+
{
198+
name: "does not partial match level",
199+
s: "error-info",
200+
},
201+
{
202+
name: "does not partial match level 2",
203+
s: "info-error",
204+
},
205+
{
206+
name: "does not partial match level 3",
207+
s: "info2",
208+
},
209+
}
210+
for _, tc := range tt {
211+
t.Run(tc.name, func(t *testing.T) {
212+
_, err := ParseLevel(tc.s)
213+
require.Error(t, err)
214+
})
215+
}
216+
}

0 commit comments

Comments
 (0)