diff --git a/cns/logger/log.go b/cns/logger/log.go index 3397e132c1..ec1d32056d 100644 --- a/cns/logger/log.go +++ b/cns/logger/log.go @@ -7,8 +7,9 @@ import ( ) var ( - Log *CNSLogger - aiMetadata string // this var is set at build time. + Log *CNSLogger + aiMetadata string // this var is set at build time. + AppInsightsIKey = aiMetadata ) // todo: the functions below should be removed. CNSLogger should be injected where needed and not used from package level scope. diff --git a/cns/logger/v2/config.go b/cns/logger/v2/config.go new file mode 100644 index 0000000000..d64d6486fa --- /dev/null +++ b/cns/logger/v2/config.go @@ -0,0 +1,74 @@ +package logger + +import ( + "encoding/json" + + loggerv1 "github.com/Azure/azure-container-networking/cns/logger" + "github.com/Azure/azure-container-networking/internal/time" + "github.com/pkg/errors" + "go.uber.org/zap/zapcore" +) + +//nolint:unused // will be used +const ( + defaultMaxBackups = 10 + defaultMaxSize = 10 // MB + defaultMaxBatchInterval = 30 * time.Second + defaultMaxBatchSize = 32000 + defaultGracePeriod = 30 * time.Second +) + +//nolint:unused // will be used +var defaultIKey = loggerv1.AppInsightsIKey + +// UnmarshalJSON implements json.Unmarshaler for the Config. +// It only differs from the default by parsing the +// Level string into a zapcore.Level and setting the level field. +func (c *Config) UnmarshalJSON(data []byte) error { + type Alias Config + aux := &struct { + *Alias + }{ + Alias: (*Alias)(c), + } + if err := json.Unmarshal(data, &aux); err != nil { //nolint:musttag // doesn't understand the embedding strategy + return errors.Wrap(err, "failed to unmarshal Config") + } + lvl, err := zapcore.ParseLevel(c.Level) + if err != nil { + return errors.Wrap(err, "failed to parse Config Level") + } + c.level = lvl + return nil +} + +// Normalize checks the Config for missing or illegal values and sets them +// to defaults if appropriate. +func (c *Config) Normalize() { + if c.File != nil { + if c.File.Filepath == "" { + c.File.Filepath = defaultFilePath + } + if c.File.MaxBackups == 0 { + c.File.MaxBackups = defaultMaxBackups + } + if c.File.MaxSize == 0 { + c.File.MaxSize = defaultMaxSize + } + } + if c.AppInsights != nil { + if c.AppInsights.IKey == "" { + c.AppInsights.IKey = defaultIKey + } + if c.AppInsights.GracePeriod.Duration == 0 { + c.AppInsights.GracePeriod.Duration = defaultGracePeriod + } + if c.AppInsights.MaxBatchInterval.Duration == 0 { + c.AppInsights.MaxBatchInterval.Duration = defaultMaxBatchInterval + } + if c.AppInsights.MaxBatchSize == 0 { + c.AppInsights.MaxBatchSize = defaultMaxBatchSize + } + } + c.normalize() +} diff --git a/cns/logger/v2/config_linux.go b/cns/logger/v2/config_linux.go new file mode 100644 index 0000000000..0b109ebaa2 --- /dev/null +++ b/cns/logger/v2/config_linux.go @@ -0,0 +1,18 @@ +package logger + +import ( + cores "github.com/Azure/azure-container-networking/cns/logger/v2/cores" + "go.uber.org/zap/zapcore" +) + +const defaultFilePath = "/var/log/azure-cns.log" + +type Config struct { + // Level is the general logging Level. If cores have more specific config it will override this. + Level string `json:"level"` + level zapcore.Level `json:"-"` + AppInsights *cores.AppInsightsConfig `json:"appInsights,omitempty"` + File *cores.FileConfig `json:"file,omitempty"` +} + +func (c *Config) normalize() {} diff --git a/cns/logger/v2/config_test.go b/cns/logger/v2/config_test.go new file mode 100644 index 0000000000..1295cd663b --- /dev/null +++ b/cns/logger/v2/config_test.go @@ -0,0 +1,55 @@ +package logger + +import ( + "encoding/json" + "testing" + + cores "github.com/Azure/azure-container-networking/cns/logger/v2/cores" + "github.com/stretchr/testify/require" +) + +func TestUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + have []byte + want *Config + wantErr bool + }{ + { + name: "valid", + have: []byte(`{"level":"info"}`), + want: &Config{ + Level: "info", + level: 0, + }, + }, + { + name: "invalid level", + have: []byte(`{"level":"invalid"}`), + wantErr: true, + }, + { + name: "valid with file", + have: []byte(`{"level":"info","file":{"filepath":"/k/azurecns/azure-cns.log"}}`), + want: &Config{ + Level: "info", + level: 0, + File: &cores.FileConfig{ + Filepath: "/k/azurecns/azure-cns.log", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Config{} + err := json.Unmarshal(tt.have, c) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, c) + }) + } +} diff --git a/cns/logger/v2/config_windows.go b/cns/logger/v2/config_windows.go new file mode 100644 index 0000000000..f614e8797c --- /dev/null +++ b/cns/logger/v2/config_windows.go @@ -0,0 +1,28 @@ +package logger + +import ( + cores "github.com/Azure/azure-container-networking/cns/logger/v2/cores" + "go.uber.org/zap/zapcore" +) + +const defaultFilePath = "/k/azurecns/azure-cns.log" + +type Config struct { + // Level is the general logging Level. If cores have more specific config it will override this. + Level string `json:"level"` + level zapcore.Level `json:"-"` + AppInsights *cores.AppInsightsConfig `json:"appInsights,omitempty"` + File *cores.FileConfig `json:"file,omitempty"` + ETW *cores.ETWConfig `json:"etw,omitempty"` +} + +func (c *Config) normalize() { + if c.ETW != nil { + if c.ETW.EventName == "" { + c.ETW.EventName = "AzureCNS" + } + if c.ETW.ProviderName == "" { + c.ETW.ProviderName = "ACN-Monitoring" + } + } +} diff --git a/cns/logger/v2/cores/ai.go b/cns/logger/v2/cores/ai.go new file mode 100644 index 0000000000..76ba6add65 --- /dev/null +++ b/cns/logger/v2/cores/ai.go @@ -0,0 +1,67 @@ +package logger + +import ( + "encoding/json" + + "github.com/Azure/azure-container-networking/internal/time" + "github.com/Azure/azure-container-networking/zapai" + "github.com/microsoft/ApplicationInsights-Go/appinsights" + "github.com/pkg/errors" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type AppInsightsConfig struct { + level zapcore.Level `json:"-"` // Zero value is default Info level. + Level string `json:"level"` + IKey string `json:"ikey"` + GracePeriod time.Duration `json:"grace_period"` + MaxBatchInterval time.Duration `json:"max_batch_interval"` + MaxBatchSize int `json:"max_batch_size"` + Fields []zapcore.Field `json:"fields"` +} + +// UnmarshalJSON implements json.Unmarshaler for the Config. +// It only differs from the default by parsing the +// Level string into a zapcore.Level and setting the level field. +func (c *AppInsightsConfig) UnmarshalJSON(data []byte) error { + type Alias AppInsightsConfig + aux := &struct { + *Alias + }{ + Alias: (*Alias)(c), + } + if err := json.Unmarshal(data, &aux); err != nil { + return errors.Wrap(err, "failed to unmarshal AppInsightsConfig") + } + lvl, err := zapcore.ParseLevel(c.Level) + if err != nil { + return errors.Wrap(err, "failed to parse AppInsightsConfig Level") + } + c.level = lvl + return nil +} + +// ApplicationInsightsCore builds a zapcore.Core that sends logs to Application Insights. +// The first return is the core, the second is a function to close the sink. +func ApplicationInsightsCore(cfg *AppInsightsConfig) (zapcore.Core, func(), error) { + // build the AI config + aicfg := *appinsights.NewTelemetryConfiguration(cfg.IKey) + aicfg.MaxBatchSize = cfg.MaxBatchSize + aicfg.MaxBatchInterval = cfg.MaxBatchInterval.Duration + sinkcfg := zapai.SinkConfig{ + GracePeriod: cfg.GracePeriod.Duration, + TelemetryConfiguration: aicfg, + } + // open the AI zap sink + sink, aiclose, err := zap.Open(sinkcfg.URI()) + if err != nil { + return nil, aiclose, errors.Wrap(err, "failed to open AI sink") + } + // build the AI core + core := zapai.NewCore(cfg.level, sink) + core = core.WithFieldMappers(zapai.DefaultMappers) + // add normalized fields for the built-in AI Tags + // TODO(rbtr): move to the caller + return core.With(cfg.Fields), aiclose, nil +} diff --git a/cns/logger/v2/cores/ai_test.go b/cns/logger/v2/cores/ai_test.go new file mode 100644 index 0000000000..963d8d37c4 --- /dev/null +++ b/cns/logger/v2/cores/ai_test.go @@ -0,0 +1,48 @@ +package logger + +import ( + "encoding/json" + "testing" + + "github.com/Azure/azure-container-networking/internal/time" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" +) + +func TestAIConfigUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + have []byte + want *AppInsightsConfig + wantErr bool + }{ + { + name: "valid", + have: []byte(`{"grace_period":"30s","level":"panic","max_batch_interval":"30s","max_batch_size":32000}`), + want: &AppInsightsConfig{ + GracePeriod: time.Duration{Duration: 30 * time.Second}, + Level: "panic", + level: zapcore.PanicLevel, + MaxBatchInterval: time.Duration{Duration: 30 * time.Second}, + MaxBatchSize: 32000, + }, + }, + { + name: "invalid level", + have: []byte(`{"level":"invalid"}`), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &AppInsightsConfig{} + err := json.Unmarshal(tt.have, c) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, c) + }) + } +} diff --git a/cns/logger/v2/cores/etw_windows.go b/cns/logger/v2/cores/etw_windows.go new file mode 100644 index 0000000000..51e6656f48 --- /dev/null +++ b/cns/logger/v2/cores/etw_windows.go @@ -0,0 +1,22 @@ +package logger + +import ( + "github.com/Azure/azure-container-networking/zapetw" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type ETWConfig struct { + EventName string + Level zapcore.Level + ProviderName string +} + +// ETWCore builds a zapcore.Core that sends logs to ETW. +// The first return is the core, the second is a function to close the sink. +func ETWCore(cfg *ETWConfig) (zapcore.Core, func(), error) { + encoderConfig := zap.NewProductionEncoderConfig() + encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + jsonEncoder := zapcore.NewJSONEncoder(encoderConfig) + return zapetw.New(cfg.ProviderName, cfg.EventName, jsonEncoder, cfg.Level) //nolint:wrapcheck // ignore +} diff --git a/cns/logger/v2/cores/file.go b/cns/logger/v2/cores/file.go new file mode 100644 index 0000000000..21b6b1182a --- /dev/null +++ b/cns/logger/v2/cores/file.go @@ -0,0 +1,53 @@ +package logger + +import ( + "encoding/json" + + "github.com/pkg/errors" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" +) + +type FileConfig struct { + Filepath string `json:"filepath"` + Level string `json:"level"` + level zapcore.Level `json:"-"` + MaxBackups int `json:"maxBackups"` + MaxSize int `json:"maxSize"` +} + +// UnmarshalJSON implements json.Unmarshaler for the Config. +// It only differs from the default by parsing the +// Level string into a zapcore.Level and setting the level field. +func (cfg *FileConfig) UnmarshalJSON(data []byte) error { + type Alias FileConfig + aux := &struct { + *Alias + }{ + Alias: (*Alias)(cfg), + } + if err := json.Unmarshal(data, &aux); err != nil { + return errors.Wrap(err, "failed to unmarshal FileConfig") + } + lvl, err := zapcore.ParseLevel(cfg.Level) + if err != nil { + return errors.Wrap(err, "failed to parse FileConfig Level") + } + cfg.level = lvl + return nil +} + +// FileCore builds a zapcore.Core that writes to a file. +// The first return is the core, the second is a function to close the file. +func FileCore(cfg *FileConfig) (zapcore.Core, func(), error) { + filesink := &lumberjack.Logger{ + Filename: cfg.Filepath, + MaxSize: cfg.MaxSize, // MB + MaxBackups: cfg.MaxBackups, + } + encoderConfig := zap.NewProductionEncoderConfig() + encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + jsonEncoder := zapcore.NewJSONEncoder(encoderConfig) + return zapcore.NewCore(jsonEncoder, zapcore.AddSync(filesink), cfg.level), func() { _ = filesink.Close() }, nil +} diff --git a/cns/logger/v2/cores/file_test.go b/cns/logger/v2/cores/file_test.go new file mode 100644 index 0000000000..4fde7c3e04 --- /dev/null +++ b/cns/logger/v2/cores/file_test.go @@ -0,0 +1,47 @@ +package logger + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" +) + +func TestFileConfig_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + have []byte + want *FileConfig + wantErr bool + }{ + { + name: "valid", + have: []byte(`{"filepath":"test.log","level":"debug","maxBackups":5,"maxSize":10}`), + want: &FileConfig{ + Filepath: "test.log", + Level: "debug", + level: zapcore.DebugLevel, + MaxBackups: 5, + MaxSize: 10, + }, + }, + { + name: "invalid level", + have: []byte(`{"filepath":"test.log","level":"invalid","maxBackups":5,"maxSize":10}`), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &FileConfig{} + err := json.Unmarshal(tt.have, c) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, c) + }) + } +} diff --git a/cns/logger/v2/cores/stdout.go b/cns/logger/v2/cores/stdout.go new file mode 100644 index 0000000000..93ad09767a --- /dev/null +++ b/cns/logger/v2/cores/stdout.go @@ -0,0 +1,16 @@ +package logger + +import ( + "os" + + logfmt "github.com/jsternberg/zap-logfmt" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// StdoutCore builds a zapcore.Core that writes to stdout. +func StdoutCore(l zapcore.Level) zapcore.Core { + encoderConfig := zap.NewProductionEncoderConfig() + encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + return zapcore.NewCore(logfmt.NewEncoder(encoderConfig), os.Stdout, l) +} diff --git a/cns/logger/v2/logger.go b/cns/logger/v2/logger.go new file mode 100644 index 0000000000..2064f18e1c --- /dev/null +++ b/cns/logger/v2/logger.go @@ -0,0 +1,44 @@ +package logger + +import ( + cores "github.com/Azure/azure-container-networking/cns/logger/v2/cores" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type compoundCloser []func() + +func (c compoundCloser) Close() { + for _, closer := range c { + closer() + } +} + +func New(cfg *Config) (*zap.Logger, func(), error) { + cfg.Normalize() + core := cores.StdoutCore(cfg.level) + closer := compoundCloser{} + if cfg.File != nil { + fileCore, fileCloser, err := cores.FileCore(cfg.File) + closer = append(closer, fileCloser) + if err != nil { + return nil, closer.Close, err //nolint:wrapcheck // it's an internal pkg + } + core = zapcore.NewTee(core, fileCore) + } + if cfg.AppInsights != nil { + aiCore, aiCloser, err := cores.ApplicationInsightsCore(cfg.AppInsights) + closer = append(closer, aiCloser) + if err != nil { + return nil, closer.Close, err //nolint:wrapcheck // it's an internal pkg + } + core = zapcore.NewTee(core, aiCore) + } + platformCore, platformCloser, err := platformCore(cfg) + closer = append(closer, platformCloser) + if err != nil { + return nil, closer.Close, err + } + core = zapcore.NewTee(core, platformCore) + return zap.New(core), closer.Close, nil +} diff --git a/cns/logger/v2/logger_linux.go b/cns/logger/v2/logger_linux.go new file mode 100644 index 0000000000..c875f3faaf --- /dev/null +++ b/cns/logger/v2/logger_linux.go @@ -0,0 +1,10 @@ +package logger + +import ( + "go.uber.org/zap/zapcore" +) + +// platformCore returns a no-op core for Linux. +func platformCore(*Config) (zapcore.Core, func(), error) { + return zapcore.NewNopCore(), func() {}, nil +} diff --git a/cns/logger/v2/logger_windows.go b/cns/logger/v2/logger_windows.go new file mode 100644 index 0000000000..e6e5f198dc --- /dev/null +++ b/cns/logger/v2/logger_windows.go @@ -0,0 +1,14 @@ +package logger + +import ( + cores "github.com/Azure/azure-container-networking/cns/logger/v2/cores" + "go.uber.org/zap/zapcore" +) + +// platformCore returns a zapcore.Core that sends logs to ETW. +func platformCore(cfg *Config) (zapcore.Core, func(), error) { + if cfg.ETW == nil { + return zapcore.NewNopCore(), func() {}, nil + } + return cores.ETWCore(cfg.ETW) //nolint:wrapcheck // ignore +} diff --git a/go.mod b/go.mod index c8fc32d2b3..b9eaf712fc 100644 --- a/go.mod +++ b/go.mod @@ -68,7 +68,7 @@ require ( github.com/go-openapi/jsonpointer v0.20.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.22.4 // indirect - github.com/gofrs/uuid v3.3.0+incompatible // indirect + github.com/gofrs/uuid v4.2.0+incompatible // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/gofuzz v1.2.0 // indirect @@ -125,11 +125,13 @@ require ( ) require ( + github.com/Azure/azure-container-networking/zapai v0.0.3 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v4 v4.7.0-beta.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dashboard/armdashboard v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/monitor/armmonitor v0.11.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v5 v5.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 + github.com/jsternberg/zap-logfmt v1.3.0 golang.org/x/sync v0.11.0 gotest.tools/v3 v3.5.2 k8s.io/kubectl v0.28.5 diff --git a/go.sum b/go.sum index 8ea6a3e523..c7b862b562 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= code.cloudfoundry.org/clock v1.0.0 h1:kFXWQM4bxYvdBw2X8BbBeXwQNgfoWv1vqAk2ZZyBN2o= code.cloudfoundry.org/clock v1.0.0/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +github.com/Azure/azure-container-networking/zapai v0.0.3 h1:73druF1cnne5Ign/ztiXP99Ss5D+UJ80EL2mzPgNRhk= +github.com/Azure/azure-container-networking/zapai v0.0.3/go.mod h1:XV/aKJQAV6KqV4HQtZlDyxg2z7LaY9rsX8dqwyWFmUI= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys= @@ -111,8 +113,9 @@ github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84= github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= +github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= @@ -175,6 +178,8 @@ github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2E github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jsternberg/zap-logfmt v1.3.0 h1:z1n1AOHVVydOOVuyphbOKyR4NICDQFiJMn1IK5hVQ5Y= +github.com/jsternberg/zap-logfmt v1.3.0/go.mod h1:N3DENp9WNmCZxvkBD/eReWwz1149BK6jEN9cQ4fNwZE= github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs= github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= diff --git a/internal/time/duration.go b/internal/time/duration.go new file mode 100644 index 0000000000..f9a56dc0a7 --- /dev/null +++ b/internal/time/duration.go @@ -0,0 +1,30 @@ +package time + +import ( + "encoding/json" + "time" +) + +const Second = time.Second //nolint:revive // it's not a suffix + +type Duration struct { + time.Duration +} + +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.String()) //nolint:wrapcheck // ignore +} + +func (d *Duration) UnmarshalJSON(b []byte) error { + var s string + err := json.Unmarshal(b, &s) + if err != nil { + return err //nolint:wrapcheck // ignore + } + duration, err := time.ParseDuration(s) + if err != nil { + return err //nolint:wrapcheck // ignore + } + d.Duration = duration + return nil +} diff --git a/internal/time/duration_test.go b/internal/time/duration_test.go new file mode 100644 index 0000000000..d35b5772b0 --- /dev/null +++ b/internal/time/duration_test.go @@ -0,0 +1,64 @@ +package time + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestDurationMarshalJSON(t *testing.T) { + tests := []struct { + name string + have Duration + want []byte + wantErr bool + }{ + { + name: "valid", + have: Duration{30 * time.Second}, + want: []byte(`"30s"`), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.have) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestDurationUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + have []byte + want Duration + wantErr bool + }{ + { + name: "valid", + have: []byte(`"30s"`), + want: Duration{30 * time.Second}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := &Duration{} + err := json.Unmarshal(tt.have, got) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, *got) + }) + } +}