From 7a4bd5836d85c14d5da26856c5a346329da46f9f Mon Sep 17 00:00:00 2001 From: Nikolay Mitrofanov Date: Mon, 29 Dec 2025 17:59:03 +0300 Subject: [PATCH 1/5] Add log package --- go.mod | 24 +++ go.sum | 46 ++++++ pkg/log/common_test.go | 31 ++++ pkg/log/dummy.go | 116 ++++++++++++++ pkg/log/in_memory.go | 316 +++++++++++++++++++++++++++++++++++++++ pkg/log/json.go | 33 ++++ pkg/log/klog.go | 192 ++++++++++++++++++++++++ pkg/log/klog_test.go | 227 ++++++++++++++++++++++++++++ pkg/log/logger.go | 197 ++++++++++++++++++++++++ pkg/log/pretty.go | 244 ++++++++++++++++++++++++++++++ pkg/log/pretty_test.go | 228 ++++++++++++++++++++++++++++ pkg/log/process.go | 131 ++++++++++++++++ pkg/log/process_test.go | 102 +++++++++++++ pkg/log/provider.go | 46 ++++++ pkg/log/provider_test.go | 39 +++++ pkg/log/silent.go | 136 +++++++++++++++++ pkg/log/simple.go | 128 ++++++++++++++++ pkg/log/tee.go | 217 +++++++++++++++++++++++++++ pkg/log/tee_test.go | 178 ++++++++++++++++++++++ 19 files changed, 2631 insertions(+) create mode 100644 go.sum create mode 100644 pkg/log/common_test.go create mode 100644 pkg/log/dummy.go create mode 100644 pkg/log/in_memory.go create mode 100644 pkg/log/json.go create mode 100644 pkg/log/klog.go create mode 100644 pkg/log/klog_test.go create mode 100644 pkg/log/logger.go create mode 100644 pkg/log/pretty.go create mode 100644 pkg/log/pretty_test.go create mode 100644 pkg/log/process.go create mode 100644 pkg/log/process_test.go create mode 100644 pkg/log/provider.go create mode 100644 pkg/log/provider_test.go create mode 100644 pkg/log/silent.go create mode 100644 pkg/log/simple.go create mode 100644 pkg/log/tee.go create mode 100644 pkg/log/tee_test.go diff --git a/go.mod b/go.mod index b0278e7..213448c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,27 @@ module github.com/deckhouse/lib-dhctl go 1.25.3 + +require ( + github.com/deckhouse/deckhouse/pkg/log v0.1.0 + github.com/gookit/color v1.5.2 + github.com/name212/govalue v1.0.2 + github.com/stretchr/testify v1.9.0 + github.com/werf/logboek v0.5.5 + k8s.io/klog/v2 v2.130.1 +) + +require ( + github.com/DataDog/gostackparse v0.7.0 // indirect + github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..23e3a56 --- /dev/null +++ b/go.sum @@ -0,0 +1,46 @@ +github.com/DataDog/gostackparse v0.7.0 h1:i7dLkXHvYzHV308hnkvVGDL3BR4FWl7IsXNPz/IGQh4= +github.com/DataDog/gostackparse v0.7.0/go.mod h1:lTfqcJKqS9KnXQGnyQMCugq3u1FP6UZMfWR0aitKFMM= +github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 h1:HrMVYtly2IVqg9EBooHsakQ256ueojP7QuG32K71X/U= +github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774/go.mod h1:5wi5YYOpfuAKwL5XLFYopbgIl/v7NZxaJpa/4X6yFKE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckhouse/deckhouse/pkg/log v0.1.0 h1:2aPfyiHHSIJlX4x7ysyPOaIb7CLmyY+hUf9uDb8TYd8= +github.com/deckhouse/deckhouse/pkg/log v0.1.0/go.mod h1:pbAxTSDcPmwyl3wwKDcEB3qdxHnRxqTV+J0K+sha8bw= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/gookit/color v1.5.2 h1:uLnfXcaFjlrDnQDT+NCBcfhrXqYTx/rcCa6xn01Y8yI= +github.com/gookit/color v1.5.2/go.mod h1:w8h4bGiHeeBpvQVePTutdbERIUf3oJE5lZ8HM0UgXyg= +github.com/name212/govalue v1.0.2 h1:NLpLfZatHyJYMohyUP8+qXtP8OriHQToxZv+DIXNrno= +github.com/name212/govalue v1.0.2/go.mod h1:3mLA4mFb82esucQHCOIAnUjN7e7AZnRYEfxeaHLKjho= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/werf/logboek v0.5.5 h1:RmtTejHJOyw0fub4pIfKsb7OTzD90ZOUyuBAXqYqJpU= +github.com/werf/logboek v0.5.5/go.mod h1:Gez5J4bxekyr6MxTmIJyId1F61rpO+0/V4vjCIEIZmk= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= diff --git a/pkg/log/common_test.go b/pkg/log/common_test.go new file mode 100644 index 0000000..f4ae430 --- /dev/null +++ b/pkg/log/common_test.go @@ -0,0 +1,31 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func assertInBuffer(t *testing.T, buf *bytes.Buffer, msg string, inOut bool) { + out := buf.String() + assert := require.NotContains + if inOut { + assert = require.Contains + } + assert(t, out, msg) +} diff --git a/pkg/log/dummy.go b/pkg/log/dummy.go new file mode 100644 index 0000000..3eea154 --- /dev/null +++ b/pkg/log/dummy.go @@ -0,0 +1,116 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "bytes" + "fmt" + "io" +) + +var ( + _ Logger = &DummyLogger{} + _ io.Writer = &DummyLogger{} +) + +type DummyLogger struct { + isDebug bool +} + +func NewDummyLogger(isDebug bool) *DummyLogger { + return &DummyLogger{ + isDebug: isDebug, + } +} + +func (d *DummyLogger) ProcessLogger() ProcessLogger { + return newWrappedProcessLogger(d) +} + +func (d *DummyLogger) SilentLogger() *SilentLogger { + return &SilentLogger{} +} + +func (d *DummyLogger) BufferLogger(buffer *bytes.Buffer) Logger { + return NewSimpleLogger(LoggerOptions{OutStream: buffer}) +} + +func (d *DummyLogger) FlushAndClose() error { + return nil +} + +func (d *DummyLogger) Process(_ Process, t string, run func() error) error { + fmt.Println(t) + err := run() + fmt.Println(t) + return err +} + +func (d *DummyLogger) InfoF(format string, a ...interface{}) { + fmt.Printf(format, a...) +} + +func (d *DummyLogger) InfoLn(a ...interface{}) { + fmt.Println(a...) +} + +func (d *DummyLogger) ErrorF(format string, a ...interface{}) { + fmt.Printf(format, a...) +} + +func (d *DummyLogger) ErrorLn(a ...interface{}) { + fmt.Println(a...) +} + +func (d *DummyLogger) DebugF(format string, a ...interface{}) { + if d.isDebug { + fmt.Printf(format, a...) + } +} + +func (d *DummyLogger) DebugLn(a ...interface{}) { + if d.isDebug { + fmt.Println(a...) + } +} + +func (d *DummyLogger) Success(l string) { + fmt.Println(l) +} + +func (d *DummyLogger) Fail(l string) { + fmt.Println(l) +} + +func (d *DummyLogger) FailRetry(l string) { + d.Fail(l) +} + +func (d *DummyLogger) WarnLn(a ...interface{}) { + fmt.Println(a...) +} + +func (d *DummyLogger) WarnF(format string, a ...interface{}) { + fmt.Printf(format, a...) +} + +func (d *DummyLogger) JSON(content []byte) { + fmt.Println(string(content)) +} + +func (d *DummyLogger) Write(content []byte) (int, error) { + fmt.Print(string(content)) + return len(content), nil +} diff --git a/pkg/log/in_memory.go b/pkg/log/in_memory.go new file mode 100644 index 0000000..c0cbbf7 --- /dev/null +++ b/pkg/log/in_memory.go @@ -0,0 +1,316 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "bytes" + "fmt" + "io" + "regexp" + "strings" + "sync" + + "github.com/name212/govalue" +) + +var ( + _ Logger = &InMemoryLogger{} + _ io.Writer = &InMemoryLogger{} +) + +// Match +// if Regex passed Prefix and Suffix will be ignored +type Match struct { + Prefix []string + Suffix []string + Regex []*regexp.Regexp +} + +func (m *Match) IsValid() error { + if m == nil { + return fmt.Errorf("Match is nil") + } + + if len(m.Regex) > 0 { + return nil + } + + if len(m.Prefix) == 0 && len(m.Suffix) == 0 { + return fmt.Errorf("Invalid Match: must pass Regex or Prefix or/and Suffix") + } + + return nil +} + +type InMemoryLogger struct { + m sync.RWMutex + entries []string + buffer *bytes.Buffer + + parent Logger + + errorPrefix string + debugPrefix string + + notDebug bool +} + +func NewInMemoryLogger() *InMemoryLogger { + return NewInMemoryLoggerWithParent(NewSilentLogger()) +} + +func NewInMemoryLoggerWithParent(parent Logger) *InMemoryLogger { + l := &InMemoryLogger{ + entries: make([]string, 0), + } + + p := parent + + if govalue.IsNil(p) { + p = NewSilentLogger() + } + + l.parent = p + + return l +} + +func (l *InMemoryLogger) WithNoDebug(f bool) *InMemoryLogger { + l.notDebug = f + return l +} + +func (l *InMemoryLogger) WithErrorPrefix(prefix string) *InMemoryLogger { + l.errorPrefix = prefix + return l +} + +func (l *InMemoryLogger) WithDebugPrefix(prefix string) *InMemoryLogger { + l.debugPrefix = prefix + return l +} + +func (l *InMemoryLogger) WithBuffer(buffer *bytes.Buffer) *InMemoryLogger { + l.m.Lock() + defer l.m.Unlock() + + l.buffer = buffer + return l +} + +func (l *InMemoryLogger) Parent() Logger { + return l.parent +} + +func (l *InMemoryLogger) FirstMatch(m *Match) (string, error) { + if err := m.IsValid(); err != nil { + return "", err + } + + l.m.RLock() + defer l.m.RUnlock() + + for _, entry := range l.entries { + if l.match(m, entry) { + return entry, nil + } + } + + return "", nil +} + +func (l *InMemoryLogger) AllMatches(m *Match) ([]string, error) { + if err := m.IsValid(); err != nil { + return nil, err + } + + l.m.RLock() + defer l.m.RUnlock() + + result := make([]string, 0) + + for _, entry := range l.entries { + if l.match(m, entry) { + result = append(result, entry) + } + } + + return result, nil +} + +func (l *InMemoryLogger) FlushAndClose() error { + return nil +} + +func (l *InMemoryLogger) Process(p Process, t string, action func() error) error { + l.writeEntityFormatted("Start process: %s/%s", p, t) + err := l.parent.Process(p, t, action) + l.writeEntityFormatted("End process: %s/%s", p, t) + return err +} + +func (l *InMemoryLogger) InfoF(format string, a ...interface{}) { + l.writeEntityFormatted(format, a...) + l.parent.InfoF(format, a...) +} +func (l *InMemoryLogger) InfoLn(a ...interface{}) { + l.writeEntityFormatted("%v\n", a) + l.parent.InfoLn(a...) +} + +func (l *InMemoryLogger) ErrorF(format string, a ...interface{}) { + l.writeEntityWithPrefix(l.errorPrefix, format, a...) + l.parent.ErrorF(format, a...) +} +func (l *InMemoryLogger) ErrorLn(a ...interface{}) { + l.writeEntityWithPrefix(l.errorPrefix, "%v\n", a) + l.parent.ErrorLn(a...) +} + +func (l *InMemoryLogger) DebugF(format string, a ...interface{}) { + if l.notDebug { + return + } + + l.writeEntityWithPrefix(l.debugPrefix, format, a...) + l.parent.DebugF(format, a...) +} + +func (l *InMemoryLogger) DebugLn(a ...interface{}) { + if l.notDebug { + return + } + + l.writeEntityWithPrefix(l.debugPrefix, "%v\n", a) + l.parent.DebugLn(a...) +} + +func (l *InMemoryLogger) WarnF(format string, a ...interface{}) { + l.writeEntityFormatted(format, a...) + l.parent.WarnF(format, a...) +} +func (l *InMemoryLogger) WarnLn(a ...interface{}) { + l.writeEntityFormatted("%v\n", a) + l.parent.WarnLn(a...) +} + +func (l *InMemoryLogger) Success(s string) { + l.writeEntityFormatted("Success: %s", s) + l.parent.Success(s) +} +func (l *InMemoryLogger) Fail(s string) { + l.writeEntityWithPrefix(l.errorPrefix, "Fail: %s", s) + l.parent.Fail(s) + +} +func (l *InMemoryLogger) FailRetry(s string) { + l.writeEntityWithPrefix(l.errorPrefix, "Fail retry: %s", s) + l.parent.FailRetry(s) +} + +func (l *InMemoryLogger) JSON(s []byte) { + l.writeEntity(string(s)) + l.parent.JSON(s) +} + +func (l *InMemoryLogger) ProcessLogger() ProcessLogger { + return l +} + +func (l *InMemoryLogger) SilentLogger() *SilentLogger { + return NewSilentLogger() +} + +func (l *InMemoryLogger) BufferLogger(buffer *bytes.Buffer) Logger { + return l.WithBuffer(buffer) +} + +func (l *InMemoryLogger) Write(s []byte) (int, error) { + l.writeEntity(string(s)) + return l.parent.Write(s) +} + +func (l *InMemoryLogger) ProcessStart(name string) { + l.writeEntityFormatted("Start process: %s", name) +} + +func (l *InMemoryLogger) ProcessFail() { + l.writeEntityWithPrefix(l.errorPrefix, "Fail process") +} + +func (l *InMemoryLogger) ProcessEnd() { + l.writeEntity("End process") +} + +func (l *InMemoryLogger) match(m *Match, entity string) bool { + if len(m.Regex) > 0 { + for _, regex := range m.Regex { + if regex.MatchString(entity) { + return true + } + } + + return false + } + + for _, prefix := range m.Prefix { + if strings.HasPrefix(entity, prefix) { + return true + } + } + + for _, suffix := range m.Suffix { + if strings.HasSuffix(entity, suffix) { + return true + } + } + + return false +} + +func (l *InMemoryLogger) writeEntity(entity string) { + l.m.Lock() + defer l.m.Unlock() + + l.entries = append(l.entries, entity) + + if l.buffer != nil { + l.buffer.WriteString(entity) + } +} + +func (l *InMemoryLogger) formatString(f string, a ...any) string { + format := f + if format == "" { + format = "%v" + } + + return fmt.Sprintf(format, a...) +} + +func (l *InMemoryLogger) writeEntityFormatted(f string, a ...any) { + l.writeEntity(l.formatString(f, a...)) +} + +func (l *InMemoryLogger) writeEntityWithPrefix(prefix, f string, a ...any) { + msg := l.formatString(f, a...) + + if prefix != "" { + l.writeEntityFormatted("%s: %s", prefix, msg) + return + } + + l.writeEntity(msg) +} diff --git a/pkg/log/json.go b/pkg/log/json.go new file mode 100644 index 0000000..5063671 --- /dev/null +++ b/pkg/log/json.go @@ -0,0 +1,33 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import "github.com/deckhouse/deckhouse/pkg/log" + +func NewJSONLogger(opts LoggerOptions) *SimpleLogger { + //json is default formatter for our slog implementation + l := log.NewLogger() + + if opts.OutStream != nil { + l.SetOutput(opts.OutStream) + } + + res := &SimpleLogger{ + logger: l, + isDebug: opts.IsDebug, + } + + return res +} diff --git a/pkg/log/klog.go b/pkg/log/klog.go new file mode 100644 index 0000000..fee7533 --- /dev/null +++ b/pkg/log/klog.go @@ -0,0 +1,192 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "flag" + "fmt" + "strings" + + "github.com/name212/govalue" + "k8s.io/klog/v2" +) + +var _ klog.LogFilter = &KeywordSanitizer{} + +type Sanitizer interface { + klog.LogFilter +} + +type KlogOpt func(opts *KlogOptions) + +type KlogOptions struct { + // verbose + // 10 by default + verbose string + + // verbose + // use defaultKeywords Sanitizer + sanitizer Sanitizer +} + +func WithKlogVerbose(v string) KlogOpt { + return func(opts *KlogOptions) { + if v != "" { + opts.verbose = v + } + } +} + +func WithKlogSanitizer(sanitizer Sanitizer) KlogOpt { + return func(opts *KlogOptions) { + if !govalue.IsNil(sanitizer) { + opts.sanitizer = sanitizer + } + } +} + +func InitKlog(logger Logger, opts ...KlogOpt) error { + if govalue.IsNil(logger) { + return fmt.Errorf("logger is not provided to init klog") + } + + optsForSet := &KlogOptions{ + verbose: "10", + sanitizer: NewKeywordSanitizer(), + } + + for _, opt := range opts { + opt(optsForSet) + } + + // we always init klog with maximal log level because we use wrapper for klog output which + // redirects all output to our logger and our logger doing all "perfect" + // (logs will out in standalone installer and dhctl-server) + flags := &flag.FlagSet{} + klog.InitFlags(flags) + + const logStdErrFlag = "logtostderr" + if err := flags.Set(logStdErrFlag, "false"); err != nil { + return flagSetError(logStdErrFlag, err) + } + + if optsForSet.verbose != "" { + const vFlag = "v" + if err := flags.Set(vFlag, optsForSet.verbose); err != nil { + return flagSetError(vFlag, err) + } + } + + if !govalue.IsNil(optsForSet.sanitizer) { + // filter sensitive keywords + klog.SetLogFilter(optsForSet.sanitizer) + } + + klog.SetOutput(newKlogWriterWrapper(logger)) + + return nil +} + +type KeywordSanitizer struct { + keywords []string +} + +func NewDummySanitizer() Sanitizer { + return &KeywordSanitizer{keywords: make([]string, 0)} +} + +func NewKeywordSanitizer() *KeywordSanitizer { + keywords := make([]string, len(defaultSensitiveKeywords)) + copy(keywords, defaultSensitiveKeywords) + + return &KeywordSanitizer{keywords: keywords} +} + +func (l *KeywordSanitizer) WithAdditionalKeywords(keywords []string) *KeywordSanitizer { + for _, keyword := range keywords { + l.keywords = append(l.keywords, keyword) + } + + return l +} + +func filteredMsg(matchedKeyword string) string { + return fmt.Sprintf(`[FILTERED - %s]`, matchedKeyword) +} + +func (l *KeywordSanitizer) Filter(args []any) []any { + for i, arg := range args { + str, ok := arg.(string) + if !ok { + continue + } + if matchedKeyword := l.isSensitive(str); matchedKeyword != "" { + args[i] = filteredMsg(matchedKeyword) + } + } + return args +} + +func (l *KeywordSanitizer) FilterF(format string, args []any) (string, []any) { + return format, l.Filter(args) +} + +func (l *KeywordSanitizer) FilterS(msg string, keysAndValues []any) (string, []any) { + return msg, l.Filter(keysAndValues) +} + +// isSensitive - returns empty if is not sensitive +func (l *KeywordSanitizer) isSensitive(msg string) (byKeyword string) { + for _, keyword := range l.keywords { + if strings.Contains(msg, keyword) { + return keyword + } + } + return "" +} + +func flagSetError(key string, err error) error { + return fmt.Errorf("Failed to set klog falg '%s': %w", key, err) +} + +type klogWriterWrapper struct { + logger Logger +} + +func newKlogWriterWrapper(logger Logger) *klogWriterWrapper { + return &klogWriterWrapper{logger: logger} +} + +func (l *klogWriterWrapper) Write(p []byte) (n int, err error) { + l.logger.DebugF("klog: %s", string(p)) + + return len(p), nil +} + +var defaultSensitiveKeywords = []string{ + `"name":"d8-cluster-terraform-state"`, + `"name":"d8-provider-cluster-configuration"`, + `"name":"d8-dhctl-converge-state"`, + `"kind":"DexProvider"`, + `"kind":"DexProviderList"`, + `"kind":"ModuleConfig"`, + `"kind":"ModuleConfigList"`, + `"kind":"Secret"`, + `"kind":"SecretList"`, + `"kind":"SSHCredentials"`, + `"kind":"SSHCredentialsList"`, + `"kind":"ClusterLogDestination"`, + `"kind":"ClusterLogDestinationList"`, +} diff --git a/pkg/log/klog_test.go b/pkg/log/klog_test.go new file mode 100644 index 0000000..9c5054b --- /dev/null +++ b/pkg/log/klog_test.go @@ -0,0 +1,227 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "fmt" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/klog/v2" +) + +func TestInitDefaultKlog(t *testing.T) { + logger := testInitKlogLogger(t) + + tests := append([]*baseKlogTest{ + newKlogTest(1), + newKlogTest(2), + newKlogTest(3), + newKlogTest(4), + newKlogTest(5), + newKlogTest(6), + newKlogTest(7), + newKlogTest(8), + newKlogTest(9), + newKlogTest(10), + newKlogTest(10).withMsg("some message").withName("some message"), + }, testGetDefaultKeywordTests(true)...) + + doKlogTests(t, "default init", tests, logger) +} + +func TestInitKlogWithVerbose(t *testing.T) { + logger := testInitKlogLogger(t, WithKlogVerbose("3")) + + tests := append([]*baseKlogTest{ + newKlogTest(1), + newKlogTest(2), + newKlogTest(3), + newKlogTest(4).withOut(false), + newKlogTest(5).withOut(false), + newKlogTest(6).withOut(false), + newKlogTest(7).withOut(false), + newKlogTest(8).withOut(false), + newKlogTest(9).withOut(false), + newKlogTest(10).withOut(false), + }, testGetDefaultKeywordTests(true)...) + + tests = append(tests, testCreateSensitive(`"kind":"ConfigMap"`, false)) + + doKlogTests(t, "with verbose", tests, logger) +} + +func TestInitKlogWithAdditionalSensitive(t *testing.T) { + additionalKeywords := []string{`"kind":"ConfigMap"`, `"kind":"SuperSecret"`, "secret string"} + sanitizer := NewKeywordSanitizer().WithAdditionalKeywords(additionalKeywords) + logger := testInitKlogLogger(t, WithKlogSanitizer(sanitizer)) + + tests := append([]*baseKlogTest{ + newKlogTest(1), + newKlogTest(10), + }, testGetDefaultKeywordTests(true)...) + + for _, keyword := range additionalKeywords { + tests = append(tests, testCreateSensitive(keyword, true)) + } + + tests = append(tests, testCreateSensitive(`"kind":"Pod"`, false)) + + doKlogTests(t, "additional sensitive", tests, logger) +} + +func TestInitKlogWithDummySanitizerAndVerbose(t *testing.T) { + sanitizer := NewDummySanitizer() + logger := testInitKlogLogger( + t, + WithKlogSanitizer(sanitizer), + WithKlogVerbose("2"), + ) + + tests := append([]*baseKlogTest{ + newKlogTest(1), + newKlogTest(3).withOut(false), + newKlogTest(10).withOut(false), + }, testGetDefaultKeywordTests(false)...) + + tests = append(tests, testCreateSensitive(`"kind":"Pod"`, false)) + + doKlogTests(t, "dummy sanitizer and verbose", tests, logger) +} + +func testInitKlogLogger(t *testing.T, opts ...KlogOpt) *InMemoryLogger { + logger := NewInMemoryLoggerWithParent(NewSimpleLogger(LoggerOptions{IsDebug: true})) + err := InitKlog(logger, opts...) + require.NoError(t, err) + + return logger +} + +type baseKlogTest struct { + name string + level klog.Level + msg string + outMsg string + shouldOut bool +} + +func (t *baseKlogTest) withMsg(msg string) *baseKlogTest { + t.msg = msg + + return t +} + +func (t *baseKlogTest) withNamePrefix(prefix string) *baseKlogTest { + t.name = fmt.Sprintf("%s: %s", prefix, t.name) + + return t +} + +func (t *baseKlogTest) withName(name string) *baseKlogTest { + t.name = name + + return t +} + +func (t *baseKlogTest) withOutMsg(msg string) *baseKlogTest { + t.outMsg = msg + + return t +} + +func (t *baseKlogTest) withOut(shouldOut bool) *baseKlogTest { + t.shouldOut = shouldOut + + return t +} + +func newKlogTest(l klog.Level) *baseKlogTest { + msg := fmt.Sprintf("klog_test level %d", l) + return &baseKlogTest{ + name: fmt.Sprintf("Test level: %d", l), + level: l, + msg: msg, + outMsg: msg, + shouldOut: true, + } +} + +func testCreateSensitive(keyword string, shouldFiltered bool) *baseKlogTest { + msg := fmt.Sprintf(`{"Test": "Yes", %s, "Sensitive": true}`, keyword) + outMsg := msg + name := "No filter default sensitive" + if shouldFiltered { + name = "Should filter default sensitive" + outMsg = filteredMsg(keyword) + } + + splitKM := strings.Split(keyword, ":") + splitKindName := splitKM[0] + if len(splitKM) > 1 { + splitKindName = splitKM[1] + } + + splitKindName = strings.Trim(splitKindName, `"`) + name = fmt.Sprintf("%s %s", name, splitKindName) + + return &baseKlogTest{ + name: name, + level: 1, + msg: msg, + outMsg: outMsg, + shouldOut: true, + } +} + +func testGetDefaultKeywordTests(shouldFiltered bool) []*baseKlogTest { + result := make([]*baseKlogTest, 0, len(defaultSensitiveKeywords)) + for _, keyword := range defaultSensitiveKeywords { + result = append(result, testCreateSensitive(keyword, shouldFiltered)) + } + + return result +} + +func doKlogTests(t *testing.T, tstPrefix string, tests []*baseKlogTest, logger *InMemoryLogger) { + for _, tst := range tests { + tst = tst.withNamePrefix(tstPrefix) + t.Run(tst.name, func(t *testing.T) { + klog.V(tst.level).Info(tst.msg) + msgEscaped := regexp.QuoteMeta(tst.outMsg) + exp := regexp.MustCompile(fmt.Sprintf("^klog\\: .*\\] %s.*", msgEscaped)) + matches, err := logger.AllMatches(&Match{ + Regex: []*regexp.Regexp{exp}, + }) + + require.NoError(t, err) + expectLen := 0 + if tst.shouldOut { + expectLen = 1 + } + + require.Len( + t, + matches, + expectLen, + "logger should contains count of message", + expectLen, + tst.msg, + matches, + ) + }) + } +} diff --git a/pkg/log/logger.go b/pkg/log/logger.go new file mode 100644 index 0000000..2d5b8b1 --- /dev/null +++ b/pkg/log/logger.go @@ -0,0 +1,197 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "bytes" + "fmt" + "io" + "maps" + "slices" + "strings" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/werf/logboek/pkg/types" +) + +var ( + emptyLogger Logger = &SilentLogger{} +) + +type Type string + +const ( + Pretty Type = "pretty" + JSON Type = "json" + Empty Type = "silent" + Simple Type = "simple" +) + +type Process string + +const ( + ProcessDefault Process = "default" + ProcessCommon Process = "common" + ProcessInfrastructure Process = "infrastructure" + ProcessConverge Process = "converge" + ProcessBootstrap Process = "bootstrap" +) + +// Deprecated: add additional processes via opts +const ( + ProcessMirror Process = "mirror" + ProcessCommanderAttach Process = "commander/attach" + ProcessCommanderDetach Process = "commander/detach" +) + +var ( + defaultProcesses = Processes{ + ProcessCommon: {"🎈 ~ Common: %s", commonOptions}, + ProcessInfrastructure: {"🌱 ~ Infrastructure: %s", InfrastructureOptions}, + ProcessConverge: {"🛸 ~ Converge: %s", convergeOptions}, + ProcessBootstrap: {"⛵ ~ Bootstrap: %s", bootstrapOptions}, + ProcessMirror: {"🪞 ~ Mirror: %s", mirrorOptions}, + ProcessCommanderAttach: {"⚓ ~ Attach to commander: %s", commanderAttachOptions}, + ProcessCommanderDetach: {"🚢 ~ Detach from commander: %s", commanderDetachOptions}, + ProcessDefault: {"%s", BoldOptions}, + } +) + +func AdditionalProcesses(processes map[string]StyleEntry) Processes { + res := make(Processes, len(processes)) + for k, v := range processes { + res[Process(k)] = v + } + + return res +} + +type ( + Processes map[Process]StyleEntry + StyleEntryOptionsSetter func(opts types.LogProcessOptionsInterface) +) + +type StyleEntry struct { + Title string + OptionsSetter StyleEntryOptionsSetter +} + +type ProcessLogger interface { + ProcessStart(name string) + ProcessFail() + ProcessEnd() +} + +type Logger interface { + FlushAndClose() error + + Process(Process, string, func() error) error + + InfoF(format string, a ...interface{}) + InfoLn(a ...interface{}) + + ErrorF(format string, a ...interface{}) + ErrorLn(a ...interface{}) + + DebugF(format string, a ...interface{}) + DebugLn(a ...interface{}) + + WarnF(format string, a ...interface{}) + WarnLn(a ...interface{}) + + Success(string) + Fail(string) + FailRetry(string) + + JSON([]byte) + Write([]byte) (int, error) + + ProcessLogger() ProcessLogger + SilentLogger() *SilentLogger + BufferLogger(buffer *bytes.Buffer) Logger +} + +type LoggerOptions struct { + OutStream io.Writer + Width int + IsDebug bool + DebugStream io.Writer + + AdditionalProcesses Processes +} + +var ( + typesMap = map[string]Type{ + string(Pretty): Pretty, + string(Simple): Simple, + string(JSON): JSON, + string(Empty): Empty, + } +) + +func ConvertType(t string) (Type, error) { + tt, ok := typesMap[t] + if !ok { + typesList := strings.Join(slices.Collect(maps.Keys(typesMap)), ", ") + return Empty, fmt.Errorf("Unknown log type: '%s'. Should be %s", t, typesList) + } + + return tt, nil +} + +// NewLogger +// do not init Klog use InitKlog for initialize Klog wrapper +func NewLogger(loggerType Type, isDebug bool) (Logger, error) { + return NewLoggerWithOptions(loggerType, LoggerOptions{IsDebug: isDebug}) +} + +// NewLoggerWithOptions +// do not init Klog use InitKlog for initialize Klog wrapper +func NewLoggerWithOptions(loggerType Type, opts LoggerOptions) (Logger, error) { + l := emptyLogger + switch loggerType { + case Pretty: + l = NewPrettyLogger(opts) + case Simple: + l = NewSimpleLogger(opts) + case JSON: + l = NewJSONLogger(opts) + case Empty: + l = emptyLogger + default: + return nil, fmt.Errorf("Unknown logger type: %s", loggerType) + } + + // Mute Shell-Operator logs + log.Default().SetLevel(log.LevelFatal) + if opts.IsDebug { + // Enable shell-operator log, because it captures klog output + // todo: capture output of klog with default logger instead + log.Default().SetLevel(log.LevelDebug) + // Wrap them with our default logger + log.Default().SetOutput(l) + } + + return l, nil +} + +func WrapWithTeeLogger(logger Logger, writer io.WriteCloser, bufSize int) (Logger, error) { + l, err := NewTeeLogger(logger, writer, bufSize) + if err != nil { + return nil, err + } + + return l, nil +} diff --git a/pkg/log/pretty.go b/pkg/log/pretty.go new file mode 100644 index 0000000..0cc0333 --- /dev/null +++ b/pkg/log/pretty.go @@ -0,0 +1,244 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "maps" + "os" + + "github.com/gookit/color" + "github.com/name212/govalue" + "github.com/werf/logboek" + "github.com/werf/logboek/pkg/level" + "github.com/werf/logboek/pkg/types" +) + +var ( + _ Logger = &PrettyLogger{} + _ io.Writer = &PrettyLogger{} +) + +type debugLogWriter struct { + DebugStream io.Writer +} + +type PrettyLogger struct { + processTitles Processes + isDebug bool + logboekLogger types.LoggerInterface + debugLogWriter *debugLogWriter +} + +func NewPrettyLogger(opts LoggerOptions) *PrettyLogger { + processes := make(Processes, len(defaultProcesses)) + maps.Copy(processes, defaultProcesses) + + if len(opts.AdditionalProcesses) > 0 { + for process, style := range opts.AdditionalProcesses { + processes[process] = style + } + } + + res := &PrettyLogger{ + processTitles: processes, + isDebug: opts.IsDebug, + } + + if opts.OutStream != nil { + res.logboekLogger = logboek.DefaultLogger().NewSubLogger(opts.OutStream, opts.OutStream) + } else { + res.logboekLogger = logboek.DefaultLogger() + } + + if !govalue.IsNil(opts.DebugStream) { + res.debugLogWriter = &debugLogWriter{DebugStream: opts.DebugStream} + } + + res.logboekLogger.SetAcceptedLevel(level.Info) + + if opts.Width != 0 { + res.logboekLogger.Streams().SetWidth(opts.Width) + } else { + res.logboekLogger.Streams().SetWidth(140) + } + + if opts.IsDebug { + res.logboekLogger.Streams().DisableProxyStreamDataFormatting() + } else { + res.logboekLogger.Streams().EnableProxyStreamDataFormatting() + } + + return res +} + +func (d *PrettyLogger) FlushAndClose() error { + return nil +} + +func (d *PrettyLogger) ProcessLogger() ProcessLogger { + return newPrettyProcessLogger(d.logboekLogger) +} + +func (d *PrettyLogger) SilentLogger() *SilentLogger { + return &SilentLogger{} +} + +func (d *PrettyLogger) Process(p Process, t string, run func() error) error { + format, ok := d.processTitles[p] + if !ok { + format = d.processTitles["default"] + } + return d.logboekLogger.LogProcess(format.Title, t).Options(format.OptionsSetter).DoError(run) +} + +func (d *PrettyLogger) InfoF(format string, a ...interface{}) { + d.logboekLogger.Info().LogF(format, a...) +} + +func (d *PrettyLogger) InfoLn(a ...interface{}) { + d.logboekLogger.Info().LogLn(a...) +} + +func (d *PrettyLogger) ErrorF(format string, a ...interface{}) { + d.logboekLogger.Error().LogF(format, a...) +} + +func (d *PrettyLogger) ErrorLn(a ...interface{}) { + d.logboekLogger.Error().LogLn(a...) +} + +func (d *PrettyLogger) DebugF(format string, a ...interface{}) { + if d.debugLogWriter != nil { + o := fmt.Sprintf(format, a...) + _, err := d.debugLogWriter.DebugStream.Write([]byte(o)) + if err != nil { + fmt.Fprintf(os.Stderr, "cannot write debug log (%s): %v", o, err) + } + } + + if d.isDebug { + d.logboekLogger.Info().LogF(format, a...) + } +} + +func (d *PrettyLogger) DebugLn(a ...interface{}) { + if d.debugLogWriter != nil { + o := fmt.Sprintln(a...) + _, err := d.debugLogWriter.DebugStream.Write([]byte(o)) + if err != nil { + d.logboekLogger.Info().LogF("cannot write debug log (%s): %v", o, err) + } + } + + if d.isDebug { + d.logboekLogger.Info().LogLn(a...) + } +} + +func (d *PrettyLogger) Success(l string) { + d.InfoF("🎉 %s", l) +} + +func (d *PrettyLogger) Fail(l string) { + d.InfoF("️⛱️️ %s", l) +} + +func (d *PrettyLogger) FailRetry(l string) { + d.Fail(l) +} + +func (d *PrettyLogger) WarnLn(a ...interface{}) { + a = append([]interface{}{"❗ ~ "}, a...) + d.InfoLn(color.New(color.Bold).Sprint(a...)) +} + +func (d *PrettyLogger) WarnF(format string, a ...interface{}) { + line := color.New(color.Bold).Sprintf("❗ ~ "+format, a...) + d.InfoF(line) +} + +func (d *PrettyLogger) JSON(content []byte) { + d.InfoLn(prettyJSON(content)) +} + +func (d *PrettyLogger) Write(content []byte) (int, error) { + d.InfoF(string(content)) + return len(content), nil +} + +func (d *PrettyLogger) BufferLogger(buffer *bytes.Buffer) Logger { + return NewPrettyLogger(LoggerOptions{OutStream: buffer}) +} + +func prettyJSON(content []byte) string { + result := &bytes.Buffer{} + if err := json.Indent(result, content, "", " "); err != nil { + panic(err) + } + + return result.String() +} + +func bootstrapOptions(opts types.LogProcessOptionsInterface) { + opts.Style(color.New(color.FgYellow, color.Bold)) +} + +func mirrorOptions(opts types.LogProcessOptionsInterface) { + opts.Style(color.New(color.FgGreen, color.Bold)) +} + +func commanderAttachOptions(opts types.LogProcessOptionsInterface) { + opts.Style(color.New(color.FgLightCyan, color.Bold)) +} + +func commanderDetachOptions(opts types.LogProcessOptionsInterface) { + opts.Style(color.New(color.FgLightCyan, color.Bold)) +} + +func commonOptions(opts types.LogProcessOptionsInterface) { + opts.Style(color.New(color.FgBlue, color.Bold)) +} + +func BoldOptions(opts types.LogProcessOptionsInterface) { + opts.Style(boldStyle()) +} + +func BoldStartOptions(opts types.LogProcessOptionsInterface) { + opts.Style(boldStyle()) +} + +func BoldEndOptions(opts types.LogProcessOptionsInterface) { + opts.Style(boldStyle()) +} + +func BoldFailOptions(opts types.LogProcessOptionsInterface) { + opts.Style(boldStyle()) +} + +func InfrastructureOptions(opts types.LogProcessOptionsInterface) { + opts.Style(color.New(color.FgGreen, color.Bold)) +} + +func convergeOptions(opts types.LogProcessOptionsInterface) { + opts.Style(color.New(color.FgLightCyan, color.Bold)) +} + +func boldStyle() color.Style { + return color.New(color.Bold) +} diff --git a/pkg/log/pretty_test.go b/pkg/log/pretty_test.go new file mode 100644 index 0000000..f5f4cc3 --- /dev/null +++ b/pkg/log/pretty_test.go @@ -0,0 +1,228 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "bytes" + "fmt" + "regexp" + "slices" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPrettyDefault(t *testing.T) { + pretty, inMemory := testNewPretty(LoggerOptions{IsDebug: true}) + testPrettyLoggerDefaultProcesses(t, "default", pretty, inMemory) +} + +func TestPrettyAdditionalProcesses(t *testing.T) { + additionalProcesses := AdditionalProcesses(map[string]StyleEntry{ + "myprocess1": { + Title: "😀 My Process %s", + OptionsSetter: BoldOptions, + }, + + "myprocess2": { + Title: "Another My Process %s", + OptionsSetter: BoldOptions, + }, + }) + + pretty, inMemory := testNewPretty(LoggerOptions{ + IsDebug: true, + AdditionalProcesses: additionalProcesses, + }) + + const tstPrefix = "additional processes" + + testPrettyLoggerDefaultProcesses(t, tstPrefix, pretty, inMemory) + + for process, style := range additionalProcesses { + testPrettyLoggerProcess(t, &testPrettyLogger{ + tstPrefix: tstPrefix, + + process: process, + style: style, + + logger: pretty, + out: inMemory, + }) + } +} + +func TestPrettyRewriteDefaultProcess(t *testing.T) { + additionalProcesses := Processes{ + ProcessCommon: { + Title: "😀 My Common %s", + OptionsSetter: BoldOptions, + }, + + ProcessInfrastructure: { + Title: "My Infrastructure %s", + OptionsSetter: BoldOptions, + }, + } + + pretty, inMemory := testNewPretty(LoggerOptions{ + IsDebug: true, + AdditionalProcesses: additionalProcesses, + }) + + const tstPrefix = "rewrite default processes" + + testPrettyLoggerDefaultProcesses(t, tstPrefix, pretty, inMemory, ProcessCommon, ProcessInfrastructure) + + for process, style := range additionalProcesses { + testPrettyLoggerProcess(t, &testPrettyLogger{ + tstPrefix: tstPrefix, + + process: process, + style: style, + + logger: pretty, + out: inMemory, + }) + } +} + +func TestPrettyDebugStream(t *testing.T) { + outBuffer := &bytes.Buffer{} + debugBuffer := &bytes.Buffer{} + + pretty := NewPrettyLogger(LoggerOptions{ + IsDebug: false, + OutStream: outBuffer, + DebugStream: debugBuffer, + }) + + const ( + infoMsg = "Info message" + infoMsgLn = "Ln info message" + debugMsg = "Debug message" + debugMsgLn = "Ln debug message" + ) + + pretty.InfoF(infoMsg) + pretty.InfoLn(infoMsgLn) + pretty.DebugF(debugMsg) + pretty.DebugLn(debugMsgLn) + + assertInOut := func(t *testing.T, msg string, inOut bool) { + assertInBuffer(t, outBuffer, msg, inOut) + } + + assertInDebug := func(t *testing.T, msg string, inOut bool) { + assertInBuffer(t, debugBuffer, msg, inOut) + } + + t.Run("info messages in out", func(t *testing.T) { + assertInOut(t, infoMsg, true) + assertInOut(t, infoMsgLn, true) + }) + + t.Run("debug messages is not in out", func(t *testing.T) { + assertInOut(t, debugMsg, false) + assertInOut(t, debugMsgLn, false) + }) + + t.Run("debug messages in debug stream", func(t *testing.T) { + assertInDebug(t, debugMsg, true) + assertInDebug(t, debugMsgLn, true) + }) + + t.Run("info messages is not in debug stream", func(t *testing.T) { + assertInDebug(t, infoMsg, false) + assertInDebug(t, infoMsgLn, false) + }) +} + +func testNewPretty(opts LoggerOptions) (*PrettyLogger, *InMemoryLogger) { + inMemoryLogger := NewInMemoryLoggerWithParent(NewSimpleLogger(opts)) + + opts.OutStream = inMemoryLogger + + return NewPrettyLogger(opts), inMemoryLogger +} + +type testPrettyLogger struct { + tstPrefix string + logger *PrettyLogger + out *InMemoryLogger + + process Process + style StyleEntry +} + +func testPrettyLoggerProcess(t *testing.T, tst *testPrettyLogger) { + require.NotNil(t, tst.logger) + require.NotNil(t, tst.out) + require.NotEmpty(t, tst.tstPrefix) + + const processName = "dummy" + + t.Run(fmt.Sprintf("%s: %s", tst.tstPrefix, string(tst.process)), func(t *testing.T) { + inRunMsg := fmt.Sprintf("run in process: %s", string(tst.process)) + + err := tst.logger.Process(tst.process, processName, func() error { + tst.logger.InfoF(inRunMsg) + return nil + }) + + require.NoError(t, err) + + inRunEscaped := regexp.QuoteMeta(inRunMsg) + expInRun := regexp.MustCompile(fmt.Sprintf("^.* %s$", inRunEscaped)) + matchesInRun, err := tst.out.AllMatches(&Match{ + Regex: []*regexp.Regexp{expInRun}, + }) + require.NoError(t, err) + require.Len(t, matchesInRun, 1, "should have in process run", tst.process) + + title := fmt.Sprintf(tst.style.Title, processName) + if title == processName { + // default process has %s title + return + } + titleEscaped := regexp.QuoteMeta(title) + expProcess := regexp.MustCompile(fmt.Sprintf("^.*%s[\\s]*$", titleEscaped)) + matchesProcessStartEnd, err := tst.out.AllMatches(&Match{ + Regex: []*regexp.Regexp{expProcess}, + }) + require.NoError(t, err) + require.Len(t, matchesProcessStartEnd, 2, "should have title start and end for", tst.process) + }) +} + +func testPrettyLoggerDefaultProcesses(t *testing.T, tstPrefix string, logger *PrettyLogger, out *InMemoryLogger, expectProcesses ...Process) { + expected := append(make([]Process, 0), expectProcesses...) + + for process, style := range defaultProcesses { + if slices.Contains(expected, process) { + t.Log(fmt.Sprintf("Default process '%s' skipped", process)) + continue + } + + testPrettyLoggerProcess(t, &testPrettyLogger{ + tstPrefix: tstPrefix, + logger: logger, + out: out, + + process: process, + style: style, + }) + } +} diff --git a/pkg/log/process.go b/pkg/log/process.go new file mode 100644 index 0000000..b968557 --- /dev/null +++ b/pkg/log/process.go @@ -0,0 +1,131 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "fmt" + "time" + + "github.com/werf/logboek/pkg/types" +) + +type processStack struct { + activeProcesses []*logProcessDescriptor +} + +func (s *processStack) push(p *logProcessDescriptor) { + s.activeProcesses = append(s.activeProcesses, p) +} + +func (s *processStack) pop() *logProcessDescriptor { + procIndx := len(s.activeProcesses) - 1 + if procIndx < 0 { + return nil + } + + logProcess := s.activeProcesses[procIndx] + s.activeProcesses = s.activeProcesses[:procIndx] + + return logProcess +} + +type prettyProcessLogger struct { + processes *processStack + logboekLogger types.LoggerInterface +} + +func newPrettyProcessLogger(logboekLogger types.LoggerInterface) *prettyProcessLogger { + return &prettyProcessLogger{ + processes: &processStack{}, + logboekLogger: logboekLogger, + } +} + +func (l *prettyProcessLogger) ProcessStart(msg string) { + // we do not need to store message and date, because logboek store it itself + // we use stack for prevent panic from logboek + proc := l.logboekLogger.LogProcess(msg).Options(BoldStartOptions) + l.processes.push(&logProcessDescriptor{LogboekProcess: proc}) + proc.Start() +} + +func (l *prettyProcessLogger) ProcessFail() { + p := l.processes.pop() + if p != nil { + p.LogboekProcess.Fail() + } +} + +func (l *prettyProcessLogger) ProcessEnd() { + p := l.processes.pop() + if p != nil { + p.LogboekProcess.End() + } +} + +type logProcessDescriptor struct { + StartedAt time.Time + Msg string + LogboekProcess types.LogProcessInterface +} + +func (d *logProcessDescriptor) formatTime() string { + return fmt.Sprintf("%.2f seconds", time.Since(d.StartedAt).Seconds()) +} + +type wrappedProcessLogger struct { + logger Logger + processes *processStack +} + +func newWrappedProcessLogger(logger Logger) *wrappedProcessLogger { + return &wrappedProcessLogger{ + logger: logger, + processes: &processStack{}, + } +} + +func (l *wrappedProcessLogger) ProcessStart(msg string) { + p := &logProcessDescriptor{ + StartedAt: time.Now(), + Msg: msg, + } + + l.processes.push(p) + + l.logger.InfoLn(msg) +} + +func (l *wrappedProcessLogger) ProcessEnd() { + p := l.processes.pop() + + msg := "SUCCESS" + if p != nil { + msg = fmt.Sprintf("%s (%s)", p.Msg, p.formatTime()) + } + + l.logger.InfoLn(msg) +} + +func (l *wrappedProcessLogger) ProcessFail() { + p := l.processes.pop() + + msg := "FAILED" + if p != nil { + msg = fmt.Sprintf("%s FAILED (%s)", p.Msg, p.formatTime()) + } + + l.logger.ErrorLn(msg) +} diff --git a/pkg/log/process_test.go b/pkg/log/process_test.go new file mode 100644 index 0000000..a25245a --- /dev/null +++ b/pkg/log/process_test.go @@ -0,0 +1,102 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/werf/logboek" +) + +func TestProcessStack(t *testing.T) { + s := &processStack{} + + s.push(&logProcessDescriptor{ + StartedAt: time.Now(), + Msg: "process1", + }) + + require.Len(t, s.activeProcesses, 1, "process1 is not added to stack") + + s.push(&logProcessDescriptor{ + StartedAt: time.Now(), + Msg: "process2", + }) + + require.Len(t, s.activeProcesses, 2, "process2 is not added to stack") + + assertPop := func(t *testing.T, len int, process string) { + p := s.pop() + + require.NotNilf(t, p, "%s does not pop", process) + require.Lenf(t, s.activeProcesses, len, "process1 does not remove from stack") + require.Equalf(t, p.Msg, process, "incorrect process %s; should be %s", p.Msg, process) + } + + assertPop(t, 1, "process2") + assertPop(t, 0, "process1") + + p := s.pop() + + require.Nil(t, p, "returns none nil process from empty stack") + require.Len(t, s.activeProcesses, 0, "pop from empty stack affect stack size") +} + +func TestProcessLoggers(t *testing.T) { + oldStdout := os.Stdout + defer func() { + os.Stdout = oldStdout + }() + + loggers := []struct { + logger ProcessLogger + name string + }{ + { + logger: newWrappedProcessLogger(&SilentLogger{}), + name: "wrapped logger", + }, + + { + logger: newPrettyProcessLogger(logboek.DefaultLogger()), + name: "pretty logger", + }, + } + + for _, l := range loggers { + t.Run(l.name, func(t *testing.T) { + t.Run("Do not panic done process without start", func(t *testing.T) { + l.logger.ProcessEnd() + + l.logger.ProcessStart("process done") + l.logger.ProcessEnd() + + l.logger.ProcessEnd() + }) + + t.Run("Do not panic failed process without start", func(t *testing.T) { + l.logger.ProcessFail() + + l.logger.ProcessStart("process fail") + l.logger.ProcessFail() + + l.logger.ProcessFail() + }) + }) + } +} diff --git a/pkg/log/provider.go b/pkg/log/provider.go new file mode 100644 index 0000000..69cc337 --- /dev/null +++ b/pkg/log/provider.go @@ -0,0 +1,46 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import "github.com/name212/govalue" + +var silentLoggerInstance = NewSilentLogger() + +type LoggerProvider func() Logger + +func SimpleLoggerProvider(logger Logger) LoggerProvider { + return func() Logger { + return logger + } +} + +func SafeProvideLogger(provider LoggerProvider) Logger { + return provideSafe(provider, silentLoggerInstance) +} + +func SilentLoggerProvider() LoggerProvider { + return SimpleLoggerProvider(silentLoggerInstance) +} + +func provideSafe(provider LoggerProvider, defaultLogger Logger) Logger { + if provider != nil { + logger := provider() + if !govalue.IsNil(logger) { + return logger + } + } + + return defaultLogger +} diff --git a/pkg/log/provider_test.go b/pkg/log/provider_test.go new file mode 100644 index 0000000..aa78b53 --- /dev/null +++ b/pkg/log/provider_test.go @@ -0,0 +1,39 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "testing" + + "github.com/name212/govalue" + "github.com/stretchr/testify/require" +) + +func TestSafeProvideLogger(t *testing.T) { + providers := []func(LoggerProvider) Logger{ + SafeProvideLogger, + } + + nilProvider := SimpleLoggerProvider(nil) + + for _, provider := range providers { + logger := provider(nil) + require.False(t, govalue.IsNil(logger)) + + logger = provider(nilProvider) + require.False(t, govalue.IsNil(logger)) + } + +} diff --git a/pkg/log/silent.go b/pkg/log/silent.go new file mode 100644 index 0000000..2ecf74f --- /dev/null +++ b/pkg/log/silent.go @@ -0,0 +1,136 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "bytes" + "fmt" + "io" +) + +var ( + _ Logger = &SilentLogger{} + _ io.Writer = &SilentLogger{} +) + +type SilentLogger struct { + t *TeeLogger +} + +func NewSilentLogger() *SilentLogger { + return &SilentLogger{ + t: nil, + } +} + +func (d *SilentLogger) ProcessLogger() ProcessLogger { + return newWrappedProcessLogger(d) +} + +func (d *SilentLogger) SilentLogger() *SilentLogger { + return &SilentLogger{} +} + +func (d *SilentLogger) BufferLogger(buffer *bytes.Buffer) Logger { + return d +} + +func (d *SilentLogger) Process(_ Process, t string, run func() error) error { + err := run() + return err +} + +func (d *SilentLogger) FlushAndClose() error { + return nil +} + +func (d *SilentLogger) InfoF(format string, a ...interface{}) { + if d.t != nil { + d.t.writeToFile(fmt.Sprintf(format, a...)) + } +} + +func (d *SilentLogger) InfoLn(a ...interface{}) { + if d.t != nil { + d.t.writeToFile(fmt.Sprintln(a...)) + } +} + +func (d *SilentLogger) ErrorF(format string, a ...interface{}) { + if d.t != nil { + d.t.writeToFile(fmt.Sprintf(format, a...)) + } +} + +func (d *SilentLogger) ErrorLn(a ...interface{}) { + if d.t != nil { + d.t.writeToFile(fmt.Sprintln(a...)) + } +} + +func (d *SilentLogger) DebugF(format string, a ...interface{}) { + if d.t != nil { + d.t.writeToFile(fmt.Sprintf(format, a...)) + } +} + +func (d *SilentLogger) DebugLn(a ...interface{}) { + if d.t != nil { + d.t.writeToFile(fmt.Sprintln(a...)) + } +} + +func (d *SilentLogger) Success(l string) { + if d.t != nil { + d.t.writeToFile(l) + } +} + +func (d *SilentLogger) Fail(l string) { + if d.t != nil { + d.t.writeToFile(l) + } +} + +func (d *SilentLogger) FailRetry(l string) { + if d.t != nil { + d.t.writeToFile(l) + } +} + +func (d *SilentLogger) WarnLn(a ...interface{}) { + if d.t != nil { + d.t.writeToFile(fmt.Sprintln(a...)) + } +} + +func (d *SilentLogger) WarnF(format string, a ...interface{}) { + if d.t != nil { + d.t.writeToFile(fmt.Sprintf(format, a...)) + } +} + +func (d *SilentLogger) JSON(content []byte) { + if d.t != nil { + d.t.writeToFile(string(content)) + } +} + +func (d *SilentLogger) Write(content []byte) (int, error) { + if d.t != nil { + d.t.writeToFile(string(content)) + } + return len(content), nil +} diff --git a/pkg/log/simple.go b/pkg/log/simple.go new file mode 100644 index 0000000..e03c9eb --- /dev/null +++ b/pkg/log/simple.go @@ -0,0 +1,128 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "bytes" + "io" + + "github.com/deckhouse/deckhouse/pkg/log" +) + +var ( + _ Logger = &SimpleLogger{} + _ io.Writer = &SimpleLogger{} +) + +type SimpleLogger struct { + logger *log.Logger + isDebug bool +} + +func NewSimpleLogger(opts LoggerOptions) *SimpleLogger { + //todo: now unused, need change formatter to text when our slog implementation will support it + l := log.NewLogger() + + if opts.OutStream != nil { + l.SetOutput(opts.OutStream) + } + + return &SimpleLogger{ + logger: l, + isDebug: opts.IsDebug, + } + +} + +func (d *SimpleLogger) BufferLogger(buffer *bytes.Buffer) Logger { + return NewJSONLogger(LoggerOptions{OutStream: buffer}) +} + +func (d *SimpleLogger) ProcessLogger() ProcessLogger { + return newWrappedProcessLogger(d) +} + +func (d *SimpleLogger) SilentLogger() *SilentLogger { + return &SilentLogger{} +} + +func (d *SimpleLogger) FlushAndClose() error { + return nil +} + +func (d *SimpleLogger) Process(p Process, t string, run func() error) error { + d.logger.With("action", "start").With("process", string(p)).Info(t) + err := run() + d.logger.With("action", "end").With("process", string(p)).Info(t) + return err +} + +func (d *SimpleLogger) InfoF(format string, a ...interface{}) { + d.logger.Infof(format, a...) +} + +func (d *SimpleLogger) InfoLn(a ...interface{}) { + d.logger.Infof("%v", a) +} + +func (d *SimpleLogger) ErrorF(format string, a ...interface{}) { + d.logger.Errorf(format, a...) +} + +func (d *SimpleLogger) ErrorLn(a ...interface{}) { + d.logger.Errorf("%v", a) +} + +func (d *SimpleLogger) DebugF(format string, a ...interface{}) { + if d.isDebug { + d.logger.Debugf(format, a...) + } +} + +func (d *SimpleLogger) DebugLn(a ...interface{}) { + if d.isDebug { + d.logger.Debugf("%v", a) + } +} + +func (d *SimpleLogger) Success(l string) { + d.logger.With("status", "SUCCESS").Info(l) +} + +func (d *SimpleLogger) Fail(l string) { + d.logger.With("status", "FAIL").Error(l) +} + +func (d *SimpleLogger) FailRetry(l string) { + // there used warn log level because in retry cycle we don't want to catch stacktraces which exist as default in Error and Fatal log level of slog logger + d.logger.With("status", "FAIL").Warn(l) +} + +func (d *SimpleLogger) WarnF(format string, a ...interface{}) { + d.logger.Warnf(format, a...) +} + +func (d *SimpleLogger) WarnLn(a ...interface{}) { + d.logger.Warnf("%v", a) +} + +func (d *SimpleLogger) JSON(content []byte) { + d.logger.Info(string(content)) +} + +func (d *SimpleLogger) Write(content []byte) (int, error) { + d.logger.Infof("%s", string(content)) + return len(content), nil +} diff --git a/pkg/log/tee.go b/pkg/log/tee.go new file mode 100644 index 0000000..d013447 --- /dev/null +++ b/pkg/log/tee.go @@ -0,0 +1,217 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "bufio" + "bytes" + "fmt" + "io" + "sync" + "time" +) + +var ( + _ Logger = &TeeLogger{} + _ io.Writer = &TeeLogger{} +) + +type TeeLogger struct { + l Logger + closed bool + + bufMutex sync.Mutex + buf *bufio.Writer + out io.WriteCloser +} + +func NewTeeLogger(l Logger, writer io.WriteCloser, bufferSize int) (*TeeLogger, error) { + buf := bufio.NewWriterSize(writer, bufferSize) + + return &TeeLogger{ + l: l, + buf: buf, + out: writer, + }, nil +} + +func (d *TeeLogger) BufferLogger(buffer *bytes.Buffer) Logger { + var l Logger + switch d.l.(type) { + case *PrettyLogger: + l = NewPrettyLogger(LoggerOptions{OutStream: buffer}) + case *SimpleLogger: + l = NewJSONLogger(LoggerOptions{OutStream: buffer}) + default: + l = d.l + } + + buf := bufio.NewWriterSize(d.out, 4096) // 1024 bytes may not be enough when executing in parallel + + return &TeeLogger{ + l: l, + buf: buf, + out: d.out, + } +} + +func (d *TeeLogger) FlushAndClose() error { + if d.closed { + return nil + } + + d.bufMutex.Lock() + defer d.bufMutex.Unlock() + + err := d.buf.Flush() + if err != nil { + d.l.WarnF("Cannot flush TeeLogger: %v \n", err) + return err + } + + d.buf = nil + + err = d.out.Close() + if err != nil { + d.l.WarnF("Cannot close TeeLogger file: %v \n", err) + return err + } + + d.closed = true + return nil +} + +func (d *TeeLogger) ProcessLogger() ProcessLogger { + return newWrappedProcessLogger(d) +} + +func (d *TeeLogger) SilentLogger() *SilentLogger { + return &SilentLogger{ + t: d, + } +} + +func (d *TeeLogger) Process(p Process, t string, run func() error) error { + d.writeToFile(fmt.Sprintf("Start process %s\n", t)) + + err := d.l.Process(p, t, run) + + d.writeToFile(fmt.Sprintf("End process %s\n", t)) + + return err +} + +func (d *TeeLogger) InfoF(format string, a ...interface{}) { + d.l.InfoF(format, a...) + + d.writeToFile(fmt.Sprintf(format, a...)) +} + +func (d *TeeLogger) InfoLn(a ...interface{}) { + d.l.InfoLn(a...) + + d.writeToFile(fmt.Sprintln(a...)) +} + +func (d *TeeLogger) ErrorF(format string, a ...interface{}) { + d.l.ErrorF(format, a...) + + d.writeToFile(fmt.Sprintf(format, a...)) +} + +func (d *TeeLogger) ErrorLn(a ...interface{}) { + d.l.ErrorLn(a...) + + d.writeToFile(fmt.Sprintln(a...)) +} + +func (d *TeeLogger) DebugF(format string, a ...interface{}) { + d.l.DebugF(format, a...) + + d.writeToFile(fmt.Sprintf(format, a...)) +} + +func (d *TeeLogger) DebugLn(a ...interface{}) { + d.l.DebugLn(a...) + + d.writeToFile(fmt.Sprintln(a...)) +} + +func (d *TeeLogger) Success(l string) { + d.l.Success(l) + + d.writeToFile(l) +} + +func (d *TeeLogger) Fail(l string) { + d.l.Fail(l) + + d.writeToFile(l) +} + +func (d *TeeLogger) FailRetry(l string) { + d.l.FailRetry(l) + + d.writeToFile(l) +} + +func (d *TeeLogger) WarnLn(a ...interface{}) { + d.l.WarnLn(a...) + + d.writeToFile(fmt.Sprintln(a...)) +} + +func (d *TeeLogger) WarnF(format string, a ...interface{}) { + d.l.WarnF(format, a...) + + d.writeToFile(fmt.Sprintf(format, a...)) +} + +func (d *TeeLogger) JSON(content []byte) { + d.l.JSON(content) + + d.writeToFile(string(content)) +} + +func (d *TeeLogger) Write(content []byte) (int, error) { + ln, err := d.l.Write(content) + if err != nil { + d.l.DebugF("Cannot write to log: %v", err) + } + + d.writeToFile(string(content)) + + return ln, err +} + +func (d *TeeLogger) writeToFile(content string) { + if d.closed { + return + } + + d.bufMutex.Lock() + defer d.bufMutex.Unlock() + + if d.buf == nil { + return + } + + timestamp := time.Now().Format(time.DateTime) + contentWithTimestamp := fmt.Sprintf("%s - %s", timestamp, content) + + if _, err := d.buf.Write([]byte(contentWithTimestamp)); err != nil { + d.l.DebugF("Cannot write to TeeLog: %v", err) + } +} diff --git a/pkg/log/tee_test.go b/pkg/log/tee_test.go new file mode 100644 index 0000000..b6e53ed --- /dev/null +++ b/pkg/log/tee_test.go @@ -0,0 +1,178 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "bytes" + "fmt" + "regexp" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTeeLogger(t *testing.T) { + debugWriter := newTestWriterCloser() + parentLogger := NewInMemoryLoggerWithParent(NewSilentLogger()).WithNoDebug(true) + const oneMB = 1024 * 1024 + teeLogger, err := WrapWithTeeLogger(parentLogger, debugWriter, oneMB) + require.NoError(t, err) + + const ( + infoMsg = "Info message" + infoMsgLn = "Ln info message" + debugMsg = "Debug message" + debugMsgLn = "Ln debug message" + ) + + allLogs := []string{ + infoMsg, + infoMsgLn, + debugMsg, + debugMsgLn, + } + + teeLogger.InfoF(infoMsg) + teeLogger.InfoLn(infoMsgLn) + teeLogger.DebugF(debugMsg) + teeLogger.DebugLn(debugMsgLn) + + assertInOut := func(t *testing.T, msg string, inOut bool) { + matches, err := parentLogger.AllMatches(&Match{ + Prefix: []string{msg, fmt.Sprintf("[%s]", msg)}, + }) + require.NoError(t, err) + expectLen := 0 + if inOut { + expectLen = 1 + } + + require.Len(t, matches, expectLen) + } + + assertValidWriter := func(t *testing.T) { + require.NotNil(t, debugWriter) + require.NotNil(t, debugWriter.writer) + } + + assertInTee := func(t *testing.T, msg string, inOut bool) { + assertValidWriter(t) + + assertInBuffer(t, debugWriter.writer, msg, inOut) + } + + assertInTeeAll := func(t *testing.T, msg []string, inOut bool) { + for _, m := range msg { + assertInTee(t, m, inOut) + } + } + + t.Run("info logs in out", func(t *testing.T) { + assertInOut(t, infoMsg, true) + assertInOut(t, infoMsgLn, true) + }) + + t.Run("debug logs is not in out", func(t *testing.T) { + assertInOut(t, debugMsg, false) + assertInOut(t, debugMsg, false) + }) + + t.Run("no logs in tee because we use long buffer", func(t *testing.T) { + assertInTeeAll(t, allLogs, false) + }) + + // close tee should flush logger + err = teeLogger.FlushAndClose() + require.NoError(t, err) + + t.Run("tee logger close all", func(t *testing.T) { + tee, ok := teeLogger.(*TeeLogger) + require.True(t, ok) + + require.True(t, tee.closed) + require.True(t, debugWriter.closed) + }) + + t.Run("all logs in tee after flush", func(t *testing.T) { + assertInTeeAll(t, allLogs, true) + }) + + t.Run("all logs in tee has date", func(t *testing.T) { + assertValidWriter(t) + + teeOut := debugWriter.writer.String() + for _, m := range allLogs { + escaped := regexp.QuoteMeta(m) + // 2006-01-02 15:04:05 + expStr := fmt.Sprintf("\\d{4}\\-\\d{2}\\-\\d{2} \\d{2}\\:\\d{2}\\:\\d{2} - %s", escaped) + exp := regexp.MustCompile(expStr) + require.True(t, exp.MatchString(teeOut), "not in buffer with time", m) + } + }) + + const ( + afterCloseInfoMsg = "After close Info message" + afterCloseInfoMsgLn = "After close Ln info message" + afterCloseDebugMsg = "After close Debug message" + afterCloseDebugMsgLn = "After close Ln debug message" + ) + + teeLogger.InfoF(afterCloseInfoMsg) + teeLogger.InfoLn(afterCloseInfoMsgLn) + teeLogger.DebugF(afterCloseDebugMsg) + teeLogger.DebugLn(afterCloseDebugMsgLn) + + t.Run("info logs in out after close", func(t *testing.T) { + assertInOut(t, afterCloseInfoMsg, true) + assertInOut(t, afterCloseInfoMsgLn, true) + }) + + t.Run("debug logs is not in out after close", func(t *testing.T) { + assertInOut(t, afterCloseDebugMsg, false) + assertInOut(t, afterCloseDebugMsgLn, false) + }) + + t.Run("no logs in tee after close", func(t *testing.T) { + allLogsAfterClose := []string{ + afterCloseInfoMsg, + afterCloseInfoMsgLn, + afterCloseDebugMsg, + afterCloseDebugMsgLn, + } + + assertInTeeAll(t, allLogsAfterClose, false) + }) +} + +type testWriterCloser struct { + writer *bytes.Buffer + closed bool +} + +func newTestWriterCloser() *testWriterCloser { + return &testWriterCloser{ + writer: bytes.NewBuffer(nil), + closed: false, + } +} + +func (t *testWriterCloser) Close() error { + t.closed = true + return nil +} + +func (t *testWriterCloser) Write(p []byte) (n int, err error) { + return t.writer.Write(p) +} From 65fc12690449c42c046f7d7a276fe31071de0da9 Mon Sep 17 00:00:00 2001 From: Nikolay Mitrofanov Date: Mon, 29 Dec 2025 19:57:36 +0300 Subject: [PATCH 2/5] Add log package --- pkg/log/common_test.go | 78 ++++++++++++++++++++++++++++++++++++ pkg/log/dummy.go | 18 ++++++--- pkg/log/dummy_test.go | 21 ++++++++++ pkg/log/in_memory.go | 10 ++++- pkg/log/in_memory_test.go | 27 +++++++++++++ pkg/log/json.go | 16 +------- pkg/log/ln_logger_wrapper.go | 49 ++++++++++++++++++++++ pkg/log/logger.go | 40 +++++++++++++----- pkg/log/pretty.go | 20 +++++---- pkg/log/pretty_test.go | 4 ++ pkg/log/process_test.go | 2 +- pkg/log/silent.go | 22 +++++++--- pkg/log/silent_test.go | 34 ++++++++++++++++ pkg/log/simple.go | 21 +++++++--- pkg/log/simple_test.go | 21 ++++++++++ pkg/log/tee.go | 42 +++++++++++-------- pkg/log/tee_test.go | 12 ++++++ 17 files changed, 371 insertions(+), 66 deletions(-) create mode 100644 pkg/log/dummy_test.go create mode 100644 pkg/log/in_memory_test.go create mode 100644 pkg/log/ln_logger_wrapper.go create mode 100644 pkg/log/silent_test.go create mode 100644 pkg/log/simple_test.go diff --git a/pkg/log/common_test.go b/pkg/log/common_test.go index f4ae430..3cf7d58 100644 --- a/pkg/log/common_test.go +++ b/pkg/log/common_test.go @@ -16,8 +16,10 @@ package log import ( "bytes" + "fmt" "testing" + "github.com/name212/govalue" "github.com/stretchr/testify/require" ) @@ -29,3 +31,79 @@ func assertInBuffer(t *testing.T, buf *bytes.Buffer, msg string, inOut bool) { } assert(t, out, msg) } + +func assertFollowAllInterfaces(t *testing.T, logger Logger) { + t.Run("Silent logger", func(t *testing.T) { + assertSilentLoggerProviderFollowFormatLnInterface(t, logger) + }) + + t.Run("Format Ln logger", func(t *testing.T) { + assertFollowFormatLnInterface(t, logger) + }) + + t.Run("Buffered logger", func(t *testing.T) { + assertBufferedLoggerProviderFollowFormatLnInterface(t, logger) + }) +} + +func assertFollowFormatLnInterface(t *testing.T, logger Logger) { + runs := []func(){ + func() { + logger.InfoFLn("INFO %s", "test_info") + }, + func() { + logger.WarnFLn("WARN %s", "test_warn") + }, + + func() { + logger.DebugFLn("DEBUG %s", "test_debug") + }, + + func() { + logger.ErrorFLn("ERROR %v", fmt.Errorf("test_error")) + }, + } + + for i, run := range runs { + t.Run(fmt.Sprintf("Does not panic FLn func %d", i), func(t *testing.T) { + require.NotPanics(t, run) + }) + } +} + +func assertSilentLoggerProviderFollowFormatLnInterface(t *testing.T, provider silentLoggerProvider) { + silentLogger := provider.SilentLogger() + + require.NotNil(t, silentLogger) + + assertFollowFormatLnInterface(t, silentLogger) +} + +func assertBufferedLoggerProviderFollowFormatLnInterfaceWithoutCheckWrite(t *testing.T, provider bufferLoggerProvider) *bytes.Buffer { + buf := bytes.NewBuffer(nil) + + bufferedLogger := provider.BufferLogger(buf) + + require.False(t, govalue.IsNil(bufferedLogger)) + + assertFollowFormatLnInterface(t, bufferedLogger) + + return buf +} + +func assertBufferedLoggerProviderFollowFormatLnInterface(t *testing.T, provider bufferLoggerProvider) { + buf := assertBufferedLoggerProviderFollowFormatLnInterfaceWithoutCheckWrite(t, provider) + + messagesInBuffer := []string{ + "INFO test_info", + "WARN test_warn", + "DEBUG test_debug", + "ERROR test_error", + } + + bufContent := buf.String() + + for _, message := range messagesInBuffer { + require.Contains(t, bufContent, message, "expected buffer to contain", message) + } +} diff --git a/pkg/log/dummy.go b/pkg/log/dummy.go index 3eea154..93c307c 100644 --- a/pkg/log/dummy.go +++ b/pkg/log/dummy.go @@ -21,18 +21,26 @@ import ( ) var ( - _ Logger = &DummyLogger{} - _ io.Writer = &DummyLogger{} + _ baseLogger = &DummyLogger{} + _ formatWithNewLineLogger = &DummyLogger{} + _ Logger = &DummyLogger{} + _ io.Writer = &DummyLogger{} ) type DummyLogger struct { + *formatWithNewLineLoggerWrapper + isDebug bool } func NewDummyLogger(isDebug bool) *DummyLogger { - return &DummyLogger{ + l := &DummyLogger{ isDebug: isDebug, } + + l.formatWithNewLineLoggerWrapper = newFormatWithNewLineLoggerWrapper(l) + + return l } func (d *DummyLogger) ProcessLogger() ProcessLogger { @@ -40,11 +48,11 @@ func (d *DummyLogger) ProcessLogger() ProcessLogger { } func (d *DummyLogger) SilentLogger() *SilentLogger { - return &SilentLogger{} + return NewSilentLogger() } func (d *DummyLogger) BufferLogger(buffer *bytes.Buffer) Logger { - return NewSimpleLogger(LoggerOptions{OutStream: buffer}) + return NewSimpleLogger(LoggerOptions{OutStream: buffer, IsDebug: d.isDebug}) } func (d *DummyLogger) FlushAndClose() error { diff --git a/pkg/log/dummy_test.go b/pkg/log/dummy_test.go new file mode 100644 index 0000000..4d385b1 --- /dev/null +++ b/pkg/log/dummy_test.go @@ -0,0 +1,21 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import "testing" + +func TestDummyLoggerFollowInterfaces(t *testing.T) { + assertFollowAllInterfaces(t, NewDummyLogger(true)) +} diff --git a/pkg/log/in_memory.go b/pkg/log/in_memory.go index c0cbbf7..bf1f36e 100644 --- a/pkg/log/in_memory.go +++ b/pkg/log/in_memory.go @@ -26,8 +26,10 @@ import ( ) var ( - _ Logger = &InMemoryLogger{} - _ io.Writer = &InMemoryLogger{} + _ baseLogger = &InMemoryLogger{} + _ formatWithNewLineLogger = &InMemoryLogger{} + _ Logger = &InMemoryLogger{} + _ io.Writer = &InMemoryLogger{} ) // Match @@ -55,6 +57,8 @@ func (m *Match) IsValid() error { } type InMemoryLogger struct { + *formatWithNewLineLoggerWrapper + m sync.RWMutex entries []string buffer *bytes.Buffer @@ -76,6 +80,8 @@ func NewInMemoryLoggerWithParent(parent Logger) *InMemoryLogger { entries: make([]string, 0), } + l.formatWithNewLineLoggerWrapper = newFormatWithNewLineLoggerWrapper(l) + p := parent if govalue.IsNil(p) { diff --git a/pkg/log/in_memory_test.go b/pkg/log/in_memory_test.go new file mode 100644 index 0000000..9786125 --- /dev/null +++ b/pkg/log/in_memory_test.go @@ -0,0 +1,27 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import "testing" + +func TestInMemoryLoggerFollowInterfaces(t *testing.T) { + t.Run("Default constructor", func(t *testing.T) { + assertFollowAllInterfaces(t, NewInMemoryLogger()) + }) + + t.Run("With parent constructor", func(t *testing.T) { + assertFollowAllInterfaces(t, NewInMemoryLoggerWithParent(NewDummyLogger(true))) + }) +} diff --git a/pkg/log/json.go b/pkg/log/json.go index 5063671..f1da4d0 100644 --- a/pkg/log/json.go +++ b/pkg/log/json.go @@ -14,20 +14,6 @@ package log -import "github.com/deckhouse/deckhouse/pkg/log" - func NewJSONLogger(opts LoggerOptions) *SimpleLogger { - //json is default formatter for our slog implementation - l := log.NewLogger() - - if opts.OutStream != nil { - l.SetOutput(opts.OutStream) - } - - res := &SimpleLogger{ - logger: l, - isDebug: opts.IsDebug, - } - - return res + return NewSimpleLogger(opts) } diff --git a/pkg/log/ln_logger_wrapper.go b/pkg/log/ln_logger_wrapper.go new file mode 100644 index 0000000..25ebbb5 --- /dev/null +++ b/pkg/log/ln_logger_wrapper.go @@ -0,0 +1,49 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import "fmt" + +// formatWithNewLineLogger +// we often use *F function, but for pretty log we use "\n" in end of string +// this interface and wrapper help us for get rit of this +type formatWithNewLineLoggerWrapper struct { + parent baseLogger +} + +func newFormatWithNewLineLoggerWrapper(parent baseLogger) *formatWithNewLineLoggerWrapper { + return &formatWithNewLineLoggerWrapper{parent: parent} +} + +func (w *formatWithNewLineLoggerWrapper) InfoFLn(format string, a ...any) { + w.parent.InfoF(addLnToMessage(format, a...)) +} + +func (w *formatWithNewLineLoggerWrapper) ErrorFLn(format string, a ...any) { + w.parent.ErrorF(addLnToMessage(format, a...)) +} + +func (w *formatWithNewLineLoggerWrapper) DebugFLn(format string, a ...any) { + w.parent.DebugF(addLnToMessage(format, a...)) +} + +func (w *formatWithNewLineLoggerWrapper) WarnFLn(format string, a ...any) { + w.parent.WarnF(addLnToMessage(format, a...)) +} + +func addLnToMessage(format string, a ...any) string { + f := format + "\n" + return fmt.Sprintf(f, a...) +} diff --git a/pkg/log/logger.go b/pkg/log/logger.go index 2d5b8b1..0508052 100644 --- a/pkg/log/logger.go +++ b/pkg/log/logger.go @@ -23,13 +23,10 @@ import ( "strings" "github.com/deckhouse/deckhouse/pkg/log" + "github.com/name212/govalue" "github.com/werf/logboek/pkg/types" ) -var ( - emptyLogger Logger = &SilentLogger{} -) - type Type string const ( @@ -94,7 +91,18 @@ type ProcessLogger interface { ProcessEnd() } -type Logger interface { +type silentLoggerProvider interface { + SilentLogger() *SilentLogger +} + +type bufferLoggerProvider interface { + BufferLogger(buffer *bytes.Buffer) Logger +} + +type baseLogger interface { + silentLoggerProvider + bufferLoggerProvider + FlushAndClose() error Process(Process, string, func() error) error @@ -119,8 +127,18 @@ type Logger interface { Write([]byte) (int, error) ProcessLogger() ProcessLogger - SilentLogger() *SilentLogger - BufferLogger(buffer *bytes.Buffer) Logger +} + +type formatWithNewLineLogger interface { + InfoFLn(format string, a ...any) + ErrorFLn(format string, a ...any) + DebugFLn(format string, a ...any) + WarnFLn(format string, a ...any) +} + +type Logger interface { + formatWithNewLineLogger + baseLogger } type LoggerOptions struct { @@ -160,7 +178,7 @@ func NewLogger(loggerType Type, isDebug bool) (Logger, error) { // NewLoggerWithOptions // do not init Klog use InitKlog for initialize Klog wrapper func NewLoggerWithOptions(loggerType Type, opts LoggerOptions) (Logger, error) { - l := emptyLogger + var l Logger switch loggerType { case Pretty: l = NewPrettyLogger(opts) @@ -169,11 +187,15 @@ func NewLoggerWithOptions(loggerType Type, opts LoggerOptions) (Logger, error) { case JSON: l = NewJSONLogger(opts) case Empty: - l = emptyLogger + l = NewSilentLogger() default: return nil, fmt.Errorf("Unknown logger type: %s", loggerType) } + if govalue.IsNil(l) { + return nil, fmt.Errorf("Internal error. Unable to create new logger") + } + // Mute Shell-Operator logs log.Default().SetLevel(log.LevelFatal) if opts.IsDebug { diff --git a/pkg/log/pretty.go b/pkg/log/pretty.go index 0cc0333..40486ff 100644 --- a/pkg/log/pretty.go +++ b/pkg/log/pretty.go @@ -30,8 +30,10 @@ import ( ) var ( - _ Logger = &PrettyLogger{} - _ io.Writer = &PrettyLogger{} + _ baseLogger = &PrettyLogger{} + _ formatWithNewLineLogger = &PrettyLogger{} + _ Logger = &PrettyLogger{} + _ io.Writer = &PrettyLogger{} ) type debugLogWriter struct { @@ -39,6 +41,8 @@ type debugLogWriter struct { } type PrettyLogger struct { + *formatWithNewLineLoggerWrapper + processTitles Processes isDebug bool logboekLogger types.LoggerInterface @@ -60,6 +64,8 @@ func NewPrettyLogger(opts LoggerOptions) *PrettyLogger { isDebug: opts.IsDebug, } + res.formatWithNewLineLoggerWrapper = newFormatWithNewLineLoggerWrapper(res) + if opts.OutStream != nil { res.logboekLogger = logboek.DefaultLogger().NewSubLogger(opts.OutStream, opts.OutStream) } else { @@ -96,7 +102,11 @@ func (d *PrettyLogger) ProcessLogger() ProcessLogger { } func (d *PrettyLogger) SilentLogger() *SilentLogger { - return &SilentLogger{} + return NewSilentLogger() +} + +func (d *PrettyLogger) BufferLogger(buffer *bytes.Buffer) Logger { + return NewPrettyLogger(LoggerOptions{OutStream: buffer, IsDebug: d.isDebug}) } func (d *PrettyLogger) Process(p Process, t string, run func() error) error { @@ -182,10 +192,6 @@ func (d *PrettyLogger) Write(content []byte) (int, error) { return len(content), nil } -func (d *PrettyLogger) BufferLogger(buffer *bytes.Buffer) Logger { - return NewPrettyLogger(LoggerOptions{OutStream: buffer}) -} - func prettyJSON(content []byte) string { result := &bytes.Buffer{} if err := json.Indent(result, content, "", " "); err != nil { diff --git a/pkg/log/pretty_test.go b/pkg/log/pretty_test.go index f5f4cc3..ba2b89e 100644 --- a/pkg/log/pretty_test.go +++ b/pkg/log/pretty_test.go @@ -150,6 +150,10 @@ func TestPrettyDebugStream(t *testing.T) { }) } +func TestPrettyFollowInterfaces(t *testing.T) { + assertFollowAllInterfaces(t, NewPrettyLogger(LoggerOptions{IsDebug: true})) +} + func testNewPretty(opts LoggerOptions) (*PrettyLogger, *InMemoryLogger) { inMemoryLogger := NewInMemoryLoggerWithParent(NewSimpleLogger(opts)) diff --git a/pkg/log/process_test.go b/pkg/log/process_test.go index a25245a..457d8c9 100644 --- a/pkg/log/process_test.go +++ b/pkg/log/process_test.go @@ -68,7 +68,7 @@ func TestProcessLoggers(t *testing.T) { name string }{ { - logger: newWrappedProcessLogger(&SilentLogger{}), + logger: newWrappedProcessLogger(NewSilentLogger()), name: "wrapped logger", }, diff --git a/pkg/log/silent.go b/pkg/log/silent.go index 2ecf74f..6dca0c9 100644 --- a/pkg/log/silent.go +++ b/pkg/log/silent.go @@ -21,18 +21,30 @@ import ( ) var ( - _ Logger = &SilentLogger{} - _ io.Writer = &SilentLogger{} + _ baseLogger = &SilentLogger{} + _ formatWithNewLineLogger = &SilentLogger{} + _ Logger = &SilentLogger{} + _ io.Writer = &SilentLogger{} ) type SilentLogger struct { + *formatWithNewLineLoggerWrapper + t *TeeLogger } func NewSilentLogger() *SilentLogger { - return &SilentLogger{ - t: nil, + return newSilentLoggerWithTee(nil) +} + +func newSilentLoggerWithTee(t *TeeLogger) *SilentLogger { + l := &SilentLogger{ + t: t, } + + l.formatWithNewLineLoggerWrapper = newFormatWithNewLineLoggerWrapper(l) + + return l } func (d *SilentLogger) ProcessLogger() ProcessLogger { @@ -40,7 +52,7 @@ func (d *SilentLogger) ProcessLogger() ProcessLogger { } func (d *SilentLogger) SilentLogger() *SilentLogger { - return &SilentLogger{} + return NewSilentLogger() } func (d *SilentLogger) BufferLogger(buffer *bytes.Buffer) Logger { diff --git a/pkg/log/silent_test.go b/pkg/log/silent_test.go new file mode 100644 index 0000000..219a02b --- /dev/null +++ b/pkg/log/silent_test.go @@ -0,0 +1,34 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import "testing" + +func TestSilentLoggerFollowInterfaces(t *testing.T) { + logger := NewSilentLogger() + + t.Run("Silent logger", func(t *testing.T) { + assertSilentLoggerProviderFollowFormatLnInterface(t, logger) + }) + + t.Run("Format Ln logger", func(t *testing.T) { + assertFollowFormatLnInterface(t, logger) + }) + + // silent logger should not write anything + t.Run("Buffered logger", func(t *testing.T) { + assertBufferedLoggerProviderFollowFormatLnInterfaceWithoutCheckWrite(t, logger) + }) +} diff --git a/pkg/log/simple.go b/pkg/log/simple.go index e03c9eb..5d65103 100644 --- a/pkg/log/simple.go +++ b/pkg/log/simple.go @@ -22,11 +22,15 @@ import ( ) var ( - _ Logger = &SimpleLogger{} - _ io.Writer = &SimpleLogger{} + _ baseLogger = &SimpleLogger{} + _ formatWithNewLineLogger = &SimpleLogger{} + _ Logger = &SimpleLogger{} + _ io.Writer = &SimpleLogger{} ) type SimpleLogger struct { + *formatWithNewLineLoggerWrapper + logger *log.Logger isDebug bool } @@ -39,15 +43,22 @@ func NewSimpleLogger(opts LoggerOptions) *SimpleLogger { l.SetOutput(opts.OutStream) } - return &SimpleLogger{ + if opts.IsDebug { + l.SetLevel(log.LevelDebug) + } + + res := &SimpleLogger{ logger: l, isDebug: opts.IsDebug, } + res.formatWithNewLineLoggerWrapper = newFormatWithNewLineLoggerWrapper(res) + + return res } func (d *SimpleLogger) BufferLogger(buffer *bytes.Buffer) Logger { - return NewJSONLogger(LoggerOptions{OutStream: buffer}) + return NewJSONLogger(LoggerOptions{OutStream: buffer, IsDebug: d.isDebug}) } func (d *SimpleLogger) ProcessLogger() ProcessLogger { @@ -55,7 +66,7 @@ func (d *SimpleLogger) ProcessLogger() ProcessLogger { } func (d *SimpleLogger) SilentLogger() *SilentLogger { - return &SilentLogger{} + return NewSilentLogger() } func (d *SimpleLogger) FlushAndClose() error { diff --git a/pkg/log/simple_test.go b/pkg/log/simple_test.go new file mode 100644 index 0000000..0be2c3f --- /dev/null +++ b/pkg/log/simple_test.go @@ -0,0 +1,21 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import "testing" + +func TestSimpleLoggerFollowInterfaces(t *testing.T) { + assertFollowAllInterfaces(t, NewSimpleLogger(LoggerOptions{IsDebug: true})) +} diff --git a/pkg/log/tee.go b/pkg/log/tee.go index d013447..3d7296f 100644 --- a/pkg/log/tee.go +++ b/pkg/log/tee.go @@ -24,11 +24,15 @@ import ( ) var ( - _ Logger = &TeeLogger{} - _ io.Writer = &TeeLogger{} + _ baseLogger = &TeeLogger{} + _ formatWithNewLineLogger = &TeeLogger{} + _ Logger = &TeeLogger{} + _ io.Writer = &TeeLogger{} ) type TeeLogger struct { + *formatWithNewLineLoggerWrapper + l Logger closed bool @@ -37,34 +41,40 @@ type TeeLogger struct { out io.WriteCloser } -func NewTeeLogger(l Logger, writer io.WriteCloser, bufferSize int) (*TeeLogger, error) { - buf := bufio.NewWriterSize(writer, bufferSize) - - return &TeeLogger{ +func newTeeLoggerWithParentAndBuf(l Logger, writer io.WriteCloser, buf *bufio.Writer) *TeeLogger { + res := &TeeLogger{ l: l, buf: buf, out: writer, - }, nil + } + + res.formatWithNewLineLoggerWrapper = newFormatWithNewLineLoggerWrapper(res) + + return res +} + +func NewTeeLogger(l Logger, writer io.WriteCloser, bufferSize int) (*TeeLogger, error) { + buf := bufio.NewWriterSize(writer, bufferSize) + + return newTeeLoggerWithParentAndBuf(l, writer, buf), nil } func (d *TeeLogger) BufferLogger(buffer *bytes.Buffer) Logger { var l Logger switch d.l.(type) { case *PrettyLogger: - l = NewPrettyLogger(LoggerOptions{OutStream: buffer}) + prettyLogger := d.l.(*PrettyLogger) + l = NewPrettyLogger(LoggerOptions{OutStream: buffer, IsDebug: prettyLogger.isDebug}) case *SimpleLogger: - l = NewJSONLogger(LoggerOptions{OutStream: buffer}) + simpleLogger := d.l.(*SimpleLogger) + l = NewJSONLogger(LoggerOptions{OutStream: buffer, IsDebug: simpleLogger.isDebug}) default: l = d.l } buf := bufio.NewWriterSize(d.out, 4096) // 1024 bytes may not be enough when executing in parallel - return &TeeLogger{ - l: l, - buf: buf, - out: d.out, - } + return newTeeLoggerWithParentAndBuf(l, d.out, buf) } func (d *TeeLogger) FlushAndClose() error { @@ -98,9 +108,7 @@ func (d *TeeLogger) ProcessLogger() ProcessLogger { } func (d *TeeLogger) SilentLogger() *SilentLogger { - return &SilentLogger{ - t: d, - } + return newSilentLoggerWithTee(d) } func (d *TeeLogger) Process(p Process, t string, run func() error) error { diff --git a/pkg/log/tee_test.go b/pkg/log/tee_test.go index b6e53ed..491a8cf 100644 --- a/pkg/log/tee_test.go +++ b/pkg/log/tee_test.go @@ -156,6 +156,18 @@ func TestTeeLogger(t *testing.T) { }) } +func TestTeeLoggerFollowInterfaces(t *testing.T) { + logger, err := NewTeeLogger( + NewSimpleLogger(LoggerOptions{IsDebug: true}), + newTestWriterCloser(), + 1024, + ) + + require.NoError(t, err) + + assertFollowAllInterfaces(t, logger) +} + type testWriterCloser struct { writer *bytes.Buffer closed bool From 0fa85dba4f32631b80f013e474b7a22d4eec9385 Mon Sep 17 00:00:00 2001 From: Nikolay Mitrofanov Date: Mon, 29 Dec 2025 20:17:11 +0300 Subject: [PATCH 3/5] Add log package --- Makefile | 4 ++-- hack/run_tests.sh | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100755 hack/run_tests.sh diff --git a/Makefile b/Makefile index b5b75cf..144c0da 100644 --- a/Makefile +++ b/Makefile @@ -73,8 +73,8 @@ bin/gofumpt: curl-installed bin deps: bin bin/jq bin/golangci-lint bin/gofumpt -test: - go test -v -p 1 $(go list ./... | grep -v /validation/) +test: go-installed + ./hack/run_tests.sh lint: bin/golangci-lint ./bin/golangci-lint run ./... -c .golangci.yaml diff --git a/hack/run_tests.sh b/hack/run_tests.sh new file mode 100755 index 0000000..09fb7f8 --- /dev/null +++ b/hack/run_tests.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +# Copyright 2025 Flant JSC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +packages="$(go list ./... | grep -v /validation/)" +prefix="$(grep -oP 'module .*$' go.mod | sed 's|module ||')" + +for p in "$packages"; do + pkg_dir="${p#$prefix}" + if [ -z "$pkg_dir" ]; then + echo "Package $p cannot have dir after trim $prefix" + exit 1 + fi + go test -v -p 1 "./${pkg_dir}" +done \ No newline at end of file From bfe2a0c881c9761f87e793f5cf4951bf189d5b4c Mon Sep 17 00:00:00 2001 From: Nikolay Mitrofanov Date: Mon, 29 Dec 2025 21:05:09 +0300 Subject: [PATCH 4/5] Add log package --- hack/run_tests.sh | 15 ++++++++++++++- pkg/log/pretty_test.go | 8 ++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/hack/run_tests.sh b/hack/run_tests.sh index 09fb7f8..3b1098c 100755 --- a/hack/run_tests.sh +++ b/hack/run_tests.sh @@ -14,14 +14,27 @@ # See the License for the specific language governing permissions and # limitations under the License. +run_tests="" + +if [ -n "$RUN_TEST" ]; then + echo "Found RUN_TEST env. Run only $RUN_TEST test" + run_tests="-run $RUN_TEST" +fi + +run_dir="$(pwd)" packages="$(go list ./... | grep -v /validation/)" prefix="$(grep -oP 'module .*$' go.mod | sed 's|module ||')" +echo "Found packages: $packages in $run_dir with module $prefix" + for p in "$packages"; do pkg_dir="${p#$prefix}" if [ -z "$pkg_dir" ]; then echo "Package $p cannot have dir after trim $prefix" exit 1 fi - go test -v -p 1 "./${pkg_dir}" + full_pkg_path="${run_dir}${pkg_dir}" + echo "Run tests in $full_pkg_path" + cd "$full_pkg_path" + echo "test -v -p 1 $run_tests" | xargs go done \ No newline at end of file diff --git a/pkg/log/pretty_test.go b/pkg/log/pretty_test.go index ba2b89e..98cf52c 100644 --- a/pkg/log/pretty_test.go +++ b/pkg/log/pretty_test.go @@ -155,7 +155,7 @@ func TestPrettyFollowInterfaces(t *testing.T) { } func testNewPretty(opts LoggerOptions) (*PrettyLogger, *InMemoryLogger) { - inMemoryLogger := NewInMemoryLoggerWithParent(NewSimpleLogger(opts)) + inMemoryLogger := NewInMemoryLoggerWithParent(NewDummyLogger(opts.IsDebug)) opts.OutStream = inMemoryLogger @@ -182,14 +182,14 @@ func testPrettyLoggerProcess(t *testing.T, tst *testPrettyLogger) { inRunMsg := fmt.Sprintf("run in process: %s", string(tst.process)) err := tst.logger.Process(tst.process, processName, func() error { - tst.logger.InfoF(inRunMsg) + tst.logger.InfoFLn(inRunMsg) return nil }) require.NoError(t, err) inRunEscaped := regexp.QuoteMeta(inRunMsg) - expInRun := regexp.MustCompile(fmt.Sprintf("^.* %s$", inRunEscaped)) + expInRun := regexp.MustCompile(fmt.Sprintf("^.* %s\\n", inRunEscaped)) matchesInRun, err := tst.out.AllMatches(&Match{ Regex: []*regexp.Regexp{expInRun}, }) @@ -202,7 +202,7 @@ func testPrettyLoggerProcess(t *testing.T, tst *testPrettyLogger) { return } titleEscaped := regexp.QuoteMeta(title) - expProcess := regexp.MustCompile(fmt.Sprintf("^.*%s[\\s]*$", titleEscaped)) + expProcess := regexp.MustCompile(fmt.Sprintf("^.*%s[\\w\\d\\.\\s\\(\\)]*", titleEscaped)) matchesProcessStartEnd, err := tst.out.AllMatches(&Match{ Regex: []*regexp.Regexp{expProcess}, }) From 8260dc468a684fafabf3041712c609912c882404 Mon Sep 17 00:00:00 2001 From: Nikolay Mitrofanov Date: Mon, 29 Dec 2025 21:34:57 +0300 Subject: [PATCH 5/5] Add log package --- pkg/log/in_memory.go | 14 ++++++--- pkg/log/klog.go | 8 ++--- pkg/log/klog_test.go | 28 +++++++---------- pkg/log/logger.go | 3 +- pkg/log/pretty_test.go | 2 +- pkg/log/provider_test.go | 1 - pkg/log/simple.go | 18 +++++------ pkg/log/tee.go | 8 ++--- pkg/log/tee_test.go | 2 +- pkg/log/utils.go | 28 +++++++++++++++++ pkg/log/utils_test.go | 66 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 133 insertions(+), 45 deletions(-) create mode 100644 pkg/log/utils.go create mode 100644 pkg/log/utils_test.go diff --git a/pkg/log/in_memory.go b/pkg/log/in_memory.go index bf1f36e..559c2b2 100644 --- a/pkg/log/in_memory.go +++ b/pkg/log/in_memory.go @@ -171,8 +171,9 @@ func (l *InMemoryLogger) InfoF(format string, a ...interface{}) { l.writeEntityFormatted(format, a...) l.parent.InfoF(format, a...) } + func (l *InMemoryLogger) InfoLn(a ...interface{}) { - l.writeEntityFormatted("%v\n", a) + l.writeEntityFormatted(listToString(a)) l.parent.InfoLn(a...) } @@ -180,8 +181,9 @@ func (l *InMemoryLogger) ErrorF(format string, a ...interface{}) { l.writeEntityWithPrefix(l.errorPrefix, format, a...) l.parent.ErrorF(format, a...) } + func (l *InMemoryLogger) ErrorLn(a ...interface{}) { - l.writeEntityWithPrefix(l.errorPrefix, "%v\n", a) + l.writeEntityWithPrefix(l.errorPrefix, listToString(a)) l.parent.ErrorLn(a...) } @@ -199,7 +201,7 @@ func (l *InMemoryLogger) DebugLn(a ...interface{}) { return } - l.writeEntityWithPrefix(l.debugPrefix, "%v\n", a) + l.writeEntityWithPrefix(l.debugPrefix, listToString(a)) l.parent.DebugLn(a...) } @@ -207,8 +209,9 @@ func (l *InMemoryLogger) WarnF(format string, a ...interface{}) { l.writeEntityFormatted(format, a...) l.parent.WarnF(format, a...) } + func (l *InMemoryLogger) WarnLn(a ...interface{}) { - l.writeEntityFormatted("%v\n", a) + l.writeEntityFormatted(listToString(a)) l.parent.WarnLn(a...) } @@ -216,11 +219,12 @@ func (l *InMemoryLogger) Success(s string) { l.writeEntityFormatted("Success: %s", s) l.parent.Success(s) } + func (l *InMemoryLogger) Fail(s string) { l.writeEntityWithPrefix(l.errorPrefix, "Fail: %s", s) l.parent.Fail(s) - } + func (l *InMemoryLogger) FailRetry(s string) { l.writeEntityWithPrefix(l.errorPrefix, "Fail retry: %s", s) l.parent.FailRetry(s) diff --git a/pkg/log/klog.go b/pkg/log/klog.go index fee7533..0c5c05f 100644 --- a/pkg/log/klog.go +++ b/pkg/log/klog.go @@ -115,9 +115,7 @@ func NewKeywordSanitizer() *KeywordSanitizer { } func (l *KeywordSanitizer) WithAdditionalKeywords(keywords []string) *KeywordSanitizer { - for _, keyword := range keywords { - l.keywords = append(l.keywords, keyword) - } + l.keywords = append(l.keywords, keywords...) return l } @@ -148,7 +146,7 @@ func (l *KeywordSanitizer) FilterS(msg string, keysAndValues []any) (string, []a } // isSensitive - returns empty if is not sensitive -func (l *KeywordSanitizer) isSensitive(msg string) (byKeyword string) { +func (l *KeywordSanitizer) isSensitive(msg string) string { for _, keyword := range l.keywords { if strings.Contains(msg, keyword) { return keyword @@ -169,7 +167,7 @@ func newKlogWriterWrapper(logger Logger) *klogWriterWrapper { return &klogWriterWrapper{logger: logger} } -func (l *klogWriterWrapper) Write(p []byte) (n int, err error) { +func (l *klogWriterWrapper) Write(p []byte) (int, error) { l.logger.DebugF("klog: %s", string(p)) return len(p), nil diff --git a/pkg/log/klog_test.go b/pkg/log/klog_test.go index 9c5054b..99d2ae6 100644 --- a/pkg/log/klog_test.go +++ b/pkg/log/klog_test.go @@ -51,13 +51,13 @@ func TestInitKlogWithVerbose(t *testing.T) { newKlogTest(1), newKlogTest(2), newKlogTest(3), - newKlogTest(4).withOut(false), - newKlogTest(5).withOut(false), - newKlogTest(6).withOut(false), - newKlogTest(7).withOut(false), - newKlogTest(8).withOut(false), - newKlogTest(9).withOut(false), - newKlogTest(10).withOut(false), + newKlogTest(4).withoutOut(), + newKlogTest(5).withoutOut(), + newKlogTest(6).withoutOut(), + newKlogTest(7).withoutOut(), + newKlogTest(8).withoutOut(), + newKlogTest(9).withoutOut(), + newKlogTest(10).withoutOut(), }, testGetDefaultKeywordTests(true)...) tests = append(tests, testCreateSensitive(`"kind":"ConfigMap"`, false)) @@ -94,8 +94,8 @@ func TestInitKlogWithDummySanitizerAndVerbose(t *testing.T) { tests := append([]*baseKlogTest{ newKlogTest(1), - newKlogTest(3).withOut(false), - newKlogTest(10).withOut(false), + newKlogTest(3).withoutOut(), + newKlogTest(10).withoutOut(), }, testGetDefaultKeywordTests(false)...) tests = append(tests, testCreateSensitive(`"kind":"Pod"`, false)) @@ -137,14 +137,8 @@ func (t *baseKlogTest) withName(name string) *baseKlogTest { return t } -func (t *baseKlogTest) withOutMsg(msg string) *baseKlogTest { - t.outMsg = msg - - return t -} - -func (t *baseKlogTest) withOut(shouldOut bool) *baseKlogTest { - t.shouldOut = shouldOut +func (t *baseKlogTest) withoutOut() *baseKlogTest { + t.shouldOut = false return t } diff --git a/pkg/log/logger.go b/pkg/log/logger.go index 0508052..9aba01c 100644 --- a/pkg/log/logger.go +++ b/pkg/log/logger.go @@ -22,9 +22,10 @@ import ( "slices" "strings" - "github.com/deckhouse/deckhouse/pkg/log" "github.com/name212/govalue" "github.com/werf/logboek/pkg/types" + + "github.com/deckhouse/deckhouse/pkg/log" ) type Type string diff --git a/pkg/log/pretty_test.go b/pkg/log/pretty_test.go index 98cf52c..1cc277b 100644 --- a/pkg/log/pretty_test.go +++ b/pkg/log/pretty_test.go @@ -216,7 +216,7 @@ func testPrettyLoggerDefaultProcesses(t *testing.T, tstPrefix string, logger *Pr for process, style := range defaultProcesses { if slices.Contains(expected, process) { - t.Log(fmt.Sprintf("Default process '%s' skipped", process)) + t.Logf("Default process '%s' skipped", process) continue } diff --git a/pkg/log/provider_test.go b/pkg/log/provider_test.go index aa78b53..85ff1fd 100644 --- a/pkg/log/provider_test.go +++ b/pkg/log/provider_test.go @@ -35,5 +35,4 @@ func TestSafeProvideLogger(t *testing.T) { logger = provider(nilProvider) require.False(t, govalue.IsNil(logger)) } - } diff --git a/pkg/log/simple.go b/pkg/log/simple.go index 5d65103..a9ea06e 100644 --- a/pkg/log/simple.go +++ b/pkg/log/simple.go @@ -81,30 +81,30 @@ func (d *SimpleLogger) Process(p Process, t string, run func() error) error { } func (d *SimpleLogger) InfoF(format string, a ...interface{}) { - d.logger.Infof(format, a...) + d.logger.Info(format, a...) } func (d *SimpleLogger) InfoLn(a ...interface{}) { - d.logger.Infof("%v", a) + d.logger.Info(listToString(a)) } func (d *SimpleLogger) ErrorF(format string, a ...interface{}) { - d.logger.Errorf(format, a...) + d.logger.Error(format, a...) } func (d *SimpleLogger) ErrorLn(a ...interface{}) { - d.logger.Errorf("%v", a) + d.logger.Error(listToString(a)) } func (d *SimpleLogger) DebugF(format string, a ...interface{}) { if d.isDebug { - d.logger.Debugf(format, a...) + d.logger.Debug(format, a...) } } func (d *SimpleLogger) DebugLn(a ...interface{}) { if d.isDebug { - d.logger.Debugf("%v", a) + d.logger.Debug(listToString(a)) } } @@ -122,11 +122,11 @@ func (d *SimpleLogger) FailRetry(l string) { } func (d *SimpleLogger) WarnF(format string, a ...interface{}) { - d.logger.Warnf(format, a...) + d.logger.Warn(format, a...) } func (d *SimpleLogger) WarnLn(a ...interface{}) { - d.logger.Warnf("%v", a) + d.logger.Warn(listToString(a)) } func (d *SimpleLogger) JSON(content []byte) { @@ -134,6 +134,6 @@ func (d *SimpleLogger) JSON(content []byte) { } func (d *SimpleLogger) Write(content []byte) (int, error) { - d.logger.Infof("%s", string(content)) + d.logger.Info(string(content)) return len(content), nil } diff --git a/pkg/log/tee.go b/pkg/log/tee.go index 3d7296f..0effd87 100644 --- a/pkg/log/tee.go +++ b/pkg/log/tee.go @@ -61,13 +61,11 @@ func NewTeeLogger(l Logger, writer io.WriteCloser, bufferSize int) (*TeeLogger, func (d *TeeLogger) BufferLogger(buffer *bytes.Buffer) Logger { var l Logger - switch d.l.(type) { + switch typedLogger := d.l.(type) { case *PrettyLogger: - prettyLogger := d.l.(*PrettyLogger) - l = NewPrettyLogger(LoggerOptions{OutStream: buffer, IsDebug: prettyLogger.isDebug}) + l = NewPrettyLogger(LoggerOptions{OutStream: buffer, IsDebug: typedLogger.isDebug}) case *SimpleLogger: - simpleLogger := d.l.(*SimpleLogger) - l = NewJSONLogger(LoggerOptions{OutStream: buffer, IsDebug: simpleLogger.isDebug}) + l = NewJSONLogger(LoggerOptions{OutStream: buffer, IsDebug: typedLogger.isDebug}) default: l = d.l } diff --git a/pkg/log/tee_test.go b/pkg/log/tee_test.go index 491a8cf..f2dba5e 100644 --- a/pkg/log/tee_test.go +++ b/pkg/log/tee_test.go @@ -185,6 +185,6 @@ func (t *testWriterCloser) Close() error { return nil } -func (t *testWriterCloser) Write(p []byte) (n int, err error) { +func (t *testWriterCloser) Write(p []byte) (int, error) { return t.writer.Write(p) } diff --git a/pkg/log/utils.go b/pkg/log/utils.go new file mode 100644 index 0000000..8f36beb --- /dev/null +++ b/pkg/log/utils.go @@ -0,0 +1,28 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import "fmt" + +func listToString(l ...any) string { + switch len(l) { + case 0: + return "" + case 1: + return fmt.Sprintf("%v", l[0]) + default: + return fmt.Sprintf("%v", l) + } +} diff --git a/pkg/log/utils_test.go b/pkg/log/utils_test.go new file mode 100644 index 0000000..b2fbd8b --- /dev/null +++ b/pkg/log/utils_test.go @@ -0,0 +1,66 @@ +// Copyright 2025 Flant JSC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPrepareList(t *testing.T) { + tests := []struct { + name string + input []any + out string + }{ + { + name: "no values", + input: nil, + out: "", + }, + + { + name: "one value string", + input: []any{"string"}, + out: "string", + }, + + { + name: "one value not string", + input: []any{42}, + out: "42", + }, + + { + name: "multiple strings", + input: []any{"a", "b", "c"}, + out: "[a b c]", + }, + + { + name: "multiple different values", + input: []any{"a", 42, "c"}, + out: "[a 42 c]", + }, + } + + for _, tst := range tests { + t.Run(tst.name, func(t *testing.T) { + out := listToString(tst.input...) + require.Equal(t, tst.out, out) + }) + } +}