Add audit_log forward endpoint (#16914)

add config item to set log forward endpoint
  fallback the audit log to default log when endpoint in error

Signed-off-by: stonezdj <stonezdj@gmail.com>
This commit is contained in:
stonezdj(Daojun Zhang) 2022-06-10 10:59:40 +08:00 committed by GitHub
parent 4d062c33d1
commit e778ec2edf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 410 additions and 28 deletions

View File

@ -8378,6 +8378,12 @@ definitions:
storage_per_project: storage_per_project:
$ref: '#/definitions/IntegerConfigItem' $ref: '#/definitions/IntegerConfigItem'
description: The storage quota per project description: The storage quota per project
audit_log_forward_endpoint:
$ref: '#/definitions/StringConfigItem'
description: The endpoint of the audit log forwarder
skip_audit_log_database:
$ref: '#/definitions/BoolConfigItem'
description: Whether skip the audit log in database
scan_all_policy: scan_all_policy:
type: object type: object
properties: properties:
@ -8669,6 +8675,16 @@ definitions:
description: The storage quota per project description: The storage quota per project
x-omitempty: true x-omitempty: true
x-isnullable: true x-isnullable: true
audit_log_forward_endpoint:
type: string
description: The audit log forward endpoint
x-omitempty: true
x-isnullable: true
skip_audit_log_database:
type: boolean
description: Skip audit log database
x-omitempty: true
x-isnullable: true
StringConfigItem: StringConfigItem:
type: object type: object
properties: properties:

View File

@ -209,4 +209,8 @@ const (
PurgeAuditIncludeOperations = "include_operations" PurgeAuditIncludeOperations = "include_operations"
PurgeAuditDryRun = "dry_run" PurgeAuditDryRun = "dry_run"
PurgeAuditRetentionHour = "audit_retention_hour" PurgeAuditRetentionHour = "audit_retention_hour"
// AuditLogForwardEndpoint indicate to forward the audit log to an endpoint
AuditLogForwardEndpoint = "audit_log_forward_endpoint"
// SkipAuditLogDatabase skip to log audit log in database
SkipAuditLogDatabase = "skip_audit_log_database"
) )

View File

@ -27,6 +27,7 @@ import (
"github.com/goharbor/harbor/src/lib/errors" "github.com/goharbor/harbor/src/lib/errors"
"github.com/goharbor/harbor/src/lib/log" "github.com/goharbor/harbor/src/lib/log"
"github.com/goharbor/harbor/src/lib/q" "github.com/goharbor/harbor/src/lib/q"
"github.com/goharbor/harbor/src/pkg/audit"
"github.com/goharbor/harbor/src/pkg/user" "github.com/goharbor/harbor/src/pkg/user"
) )
@ -92,6 +93,22 @@ func (c *controller) UpdateUserConfigs(ctx context.Context, conf map[string]inte
log.Errorf("failed to upload configurations: %v", err) log.Errorf("failed to upload configurations: %v", err)
return fmt.Errorf("failed to validate configuration") return fmt.Errorf("failed to validate configuration")
} }
// update the audit logger to point to the new endpoint
return c.updateLogEndpoint(ctx, conf)
}
func (c *controller) updateLogEndpoint(ctx context.Context, cfgs map[string]interface{}) error {
// check if the audit log forward endpoint updated
if _, ok := cfgs[common.AuditLogForwardEndpoint]; ok {
auditEP := config.AuditLogForwardEndpoint(ctx)
if len(auditEP) == 0 {
return nil
}
if !audit.CheckEndpointActive(auditEP) {
return errors.BadRequestError(fmt.Errorf("could not connect to the audit endpoint: %v", auditEP))
}
audit.LogMgr.Init(ctx, auditEP)
}
return nil return nil
} }
@ -116,6 +133,21 @@ func (c *controller) validateCfg(ctx context.Context, cfgs map[string]interface{
if err != nil { if err != nil {
return errors.BadRequestError(err) return errors.BadRequestError(err)
} }
return verifySkipAuditLogCfg(ctx, cfgs, mgr)
}
func verifySkipAuditLogCfg(ctx context.Context, cfgs map[string]interface{}, mgr config.Manager) error {
if skip, exist := cfgs[common.SkipAuditLogDatabase]; exist {
endPoint := mgr.Get(ctx, common.AuditLogForwardEndpoint).GetString()
if edp, found := cfgs[common.AuditLogForwardEndpoint]; found {
endPoint = edp.(string)
}
skipAuditDB := skip.(bool)
if len(endPoint) == 0 && skipAuditDB {
return errors.BadRequestError(errors.New("audit log forward endpoint should be configured before enable skip audit log in database"))
}
}
return nil return nil
} }

View File

@ -0,0 +1,59 @@
// Copyright Project Harbor Authors
//
// 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 config
import (
"context"
"github.com/goharbor/harbor/src/common"
"github.com/goharbor/harbor/src/lib/config"
"github.com/goharbor/harbor/src/lib/config/metadata"
testCfg "github.com/goharbor/harbor/src/testing/lib/config"
"github.com/goharbor/harbor/src/testing/mock"
"testing"
)
func Test_verifySkipAuditLogCfg(t *testing.T) {
cfgManager := &testCfg.Manager{}
cfgManager.On("Get", mock.Anything, common.AuditLogForwardEndpoint).
Return(&metadata.ConfigureValue{Name: common.AuditLogForwardEndpoint, Value: ""})
type args struct {
ctx context.Context
cfgs map[string]interface{}
mgr config.Manager
}
tests := []struct {
name string
args args
wantErr bool
}{
{name: "both configured", args: args{ctx: context.TODO(),
cfgs: map[string]interface{}{common.AuditLogForwardEndpoint: "harbor-log:15041",
common.SkipAuditLogDatabase: true},
mgr: cfgManager}, wantErr: false},
{name: "no forward endpoint config", args: args{ctx: context.TODO(),
cfgs: map[string]interface{}{common.SkipAuditLogDatabase: true},
mgr: cfgManager}, wantErr: true},
{name: "none configured", args: args{ctx: context.TODO(),
cfgs: map[string]interface{}{},
mgr: cfgManager}, wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := verifySkipAuditLogCfg(tt.args.ctx, tt.args.cfgs, tt.args.mgr); (err != nil) != tt.wantErr {
t.Errorf("verifySkipAuditLogCfg() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@ -23,6 +23,7 @@ import (
"github.com/goharbor/harbor/src/controller/event/metadata" "github.com/goharbor/harbor/src/controller/event/metadata"
"github.com/goharbor/harbor/src/lib/q" "github.com/goharbor/harbor/src/lib/q"
"github.com/goharbor/harbor/src/pkg/audit/model" "github.com/goharbor/harbor/src/pkg/audit/model"
_ "github.com/goharbor/harbor/src/pkg/config/db"
"github.com/goharbor/harbor/src/pkg/notifier" "github.com/goharbor/harbor/src/pkg/notifier"
ne "github.com/goharbor/harbor/src/pkg/notifier/event" ne "github.com/goharbor/harbor/src/pkg/notifier/event"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"

View File

@ -1,16 +1,16 @@
// Copyright 2018 Project Harbor Authors // Copyright Project Harbor Authors
// //
// Licensed under the Apache License, Version 2.0 (the "License"); // Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. // you may not use this file except in compliance with the License.
// You may obtain a copy of the License at // You may obtain a copy of the License at
// //
// http://www.apache.org/licenses/LICENSE-2.0 // http://www.apache.org/licenses/LICENSE-2.0
// //
// Unless required by applicable law or agreed to in writing, software // Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, // distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
package main package main
@ -53,6 +53,7 @@ import (
"github.com/goharbor/harbor/src/lib/orm" "github.com/goharbor/harbor/src/lib/orm"
tracelib "github.com/goharbor/harbor/src/lib/trace" tracelib "github.com/goharbor/harbor/src/lib/trace"
"github.com/goharbor/harbor/src/migration" "github.com/goharbor/harbor/src/migration"
"github.com/goharbor/harbor/src/pkg/audit"
dbCfg "github.com/goharbor/harbor/src/pkg/config/db" dbCfg "github.com/goharbor/harbor/src/pkg/config/db"
_ "github.com/goharbor/harbor/src/pkg/config/inmemory" _ "github.com/goharbor/harbor/src/pkg/config/inmemory"
"github.com/goharbor/harbor/src/pkg/notification" "github.com/goharbor/harbor/src/pkg/notification"
@ -202,6 +203,9 @@ func main() {
go gracefulShutdown(closing, done, shutdownTracerProvider) go gracefulShutdown(closing, done, shutdownTracerProvider)
// Start health checker for registries // Start health checker for registries
go registry.Ctl.StartRegularHealthCheck(orm.Context(), closing, done) go registry.Ctl.StartRegularHealthCheck(orm.Context(), closing, done)
// Init audit log
auditEP := config.AuditLogForwardEndpoint(ctx)
audit.LogMgr.Init(ctx, auditEP)
log.Info("initializing notification...") log.Info("initializing notification...")
notification.Init() notification.Init()

View File

@ -186,5 +186,8 @@ var (
{Name: common.CacheEnabled, Scope: SystemScope, Group: BasicGroup, EnvKey: "CACHE_ENABLED", DefaultValue: "false", ItemType: &BoolType{}, Editable: false, Description: `Enable cache`}, {Name: common.CacheEnabled, Scope: SystemScope, Group: BasicGroup, EnvKey: "CACHE_ENABLED", DefaultValue: "false", ItemType: &BoolType{}, Editable: false, Description: `Enable cache`},
{Name: common.CacheExpireHours, Scope: SystemScope, Group: BasicGroup, EnvKey: "CACHE_EXPIRE_HOURS", DefaultValue: "24", ItemType: &IntType{}, Editable: false, Description: `The expire hours for cache`}, {Name: common.CacheExpireHours, Scope: SystemScope, Group: BasicGroup, EnvKey: "CACHE_EXPIRE_HOURS", DefaultValue: "24", ItemType: &IntType{}, Editable: false, Description: `The expire hours for cache`},
{Name: common.AuditLogForwardEndpoint, Scope: UserScope, Group: BasicGroup, EnvKey: "AUDIT_LOG_FORWARD_ENDPOINT", DefaultValue: "", ItemType: &StringType{}, Editable: false, Description: `The endpoint to forward the audit log.`},
{Name: common.SkipAuditLogDatabase, Scope: UserScope, Group: BasicGroup, EnvKey: "SKIP_LOG_AUDIT_DATABASE", DefaultValue: "false", ItemType: &BoolType{}, Editable: false, Description: `The option to skip audit log in database`},
} }
) )

View File

@ -241,3 +241,13 @@ func PullTimeUpdateDisable(ctx context.Context) bool {
func PullAuditLogDisable(ctx context.Context) bool { func PullAuditLogDisable(ctx context.Context) bool {
return DefaultMgr().Get(ctx, common.PullAuditLogDisable).GetBool() return DefaultMgr().Get(ctx, common.PullAuditLogDisable).GetBool()
} }
// AuditLogForwardEndpoint returns the audit log forward endpoint
func AuditLogForwardEndpoint(ctx context.Context) string {
return DefaultMgr().Get(ctx, common.AuditLogForwardEndpoint).GetString()
}
// SkipAuditLogDatabase returns the audit log forward endpoint
func SkipAuditLogDatabase(ctx context.Context) bool {
return DefaultMgr().Get(ctx, common.SkipAuditLogDatabase).GetBool()
}

View File

@ -1,16 +1,16 @@
// Copyright Project Harbor Authors // Copyright Project Harbor Authors
// //
// Licensed under the Apache License, Version 2.0 (the "License"); // Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. // you may not use this file except in compliance with the License.
// You may obtain a copy of the License at // You may obtain a copy of the License at
// //
// http://www.apache.org/licenses/LICENSE-2.0 // http://www.apache.org/licenses/LICENSE-2.0
// //
// Unless required by applicable law or agreed to in writing, software // Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, // distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
package log package log
@ -59,6 +59,7 @@ type Logger struct {
fields map[string]interface{} fields map[string]interface{}
fieldsStr string fieldsStr string
mu *sync.Mutex // ptr here to share one sync.Mutex for clone method mu *sync.Mutex // ptr here to share one sync.Mutex for clone method
fallback *Logger // fallback logger when current out fail
} }
// New returns a customized Logger // New returns a customized Logger
@ -89,6 +90,11 @@ func DefaultLogger() *Logger {
return logger return logger
} }
// SetFallback enable fallback when error happen
func (l *Logger) SetFallback(logger *Logger) {
l.fallback = logger
}
func (l *Logger) clone() *Logger { func (l *Logger) clone() *Logger {
return &Logger{ return &Logger{
out: l.out, out: l.out,
@ -146,8 +152,8 @@ func (l *Logger) WithField(key string, value interface{}) *Logger {
return l.WithFields(Fields{key: value}) return l.WithFields(Fields{key: value})
} }
// setOutput sets the output of Logger l // SetOutput sets the output of Logger l
func (l *Logger) setOutput(out io.Writer) { func (l *Logger) SetOutput(out io.Writer) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
@ -175,12 +181,17 @@ func (l *Logger) output(record *Record) (err error) {
if err != nil { if err != nil {
return return
} }
defer func() {
if err := recover(); err != nil && l.fallback != nil {
_ = l.fallback.output(record)
}
}()
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
_, err = l.out.Write(b) _, err = l.out.Write(b)
if err != nil && l.fallback != nil {
_ = l.fallback.output(record)
}
return return
} }

View File

@ -32,7 +32,7 @@ func contains(t *testing.T, str string, lvl string, line, msg string) bool {
func TestSetx(t *testing.T) { func TestSetx(t *testing.T) {
logger := New(nil, nil, WarningLevel) logger := New(nil, nil, WarningLevel)
logger.setOutput(os.Stdout) logger.SetOutput(os.Stdout)
fmt := NewTextFormatter() fmt := NewTextFormatter()
logger.setFormatter(fmt) logger.setFormatter(fmt)
logger.setLevel(DebugLevel) logger.setLevel(DebugLevel)
@ -223,11 +223,11 @@ func enter() *bytes.Buffer {
b := make([]byte, 0, 32) b := make([]byte, 0, 32)
buf := bytes.NewBuffer(b) buf := bytes.NewBuffer(b)
logger.setOutput(buf) logger.SetOutput(buf)
return buf return buf
} }
func exit() { func exit() {
logger.setOutput(os.Stdout) logger.SetOutput(os.Stdout)
} }

74
src/pkg/audit/forward.go Normal file
View File

@ -0,0 +1,74 @@
// Copyright Project Harbor Authors
//
// 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 audit
import (
"context"
"github.com/goharbor/harbor/src/lib/config"
"github.com/goharbor/harbor/src/lib/log"
"io"
"log/syslog"
"os"
)
// LogMgr manage the audit log forward operations
var LogMgr = &LoggerManager{}
// LoggerManager manage the operations related to the audit log
type LoggerManager struct {
endpoint string
initialized bool
remoteLogger *log.Logger
}
// Init redirect the audit log to the forward endpoint
func (a *LoggerManager) Init(ctx context.Context, logEndpoint string) {
var w io.Writer
w, err := syslog.Dial("tcp", logEndpoint,
syslog.LOG_INFO, "audit")
a.initialized = true
if err != nil {
log.Errorf("failed to create audit log, error %v", err)
w = os.Stdout
a.initialized = false
}
a.remoteLogger = log.New(w, log.NewTextFormatter(), log.InfoLevel, 3)
a.remoteLogger.SetFallback(log.DefaultLogger())
}
// DefaultLogger ...
func (a *LoggerManager) DefaultLogger(ctx context.Context) *log.Logger {
endpoint := config.AuditLogForwardEndpoint(ctx)
if a.endpoint != endpoint {
a.Init(ctx, endpoint)
a.initialized = true
}
return a.remoteLogger
}
// CheckEndpointActive check the liveliness of the endpoint
func CheckEndpointActive(address string) bool {
al, err := syslog.Dial("tcp", address,
syslog.LOG_INFO, "audit")
if al != nil {
defer al.Close()
}
if err != nil {
log.Errorf("failed to connect to audit log endpoint, error %v", err)
return false
}
return true
}

View File

@ -16,6 +16,7 @@ package audit
import ( import (
"context" "context"
"github.com/goharbor/harbor/src/lib/config"
"github.com/goharbor/harbor/src/lib/q" "github.com/goharbor/harbor/src/lib/q"
"github.com/goharbor/harbor/src/pkg/audit/dao" "github.com/goharbor/harbor/src/pkg/audit/dao"
"github.com/goharbor/harbor/src/pkg/audit/model" "github.com/goharbor/harbor/src/pkg/audit/model"
@ -68,6 +69,14 @@ func (m *manager) Get(ctx context.Context, id int64) (*model.AuditLog, error) {
// Create ... // Create ...
func (m *manager) Create(ctx context.Context, audit *model.AuditLog) (int64, error) { func (m *manager) Create(ctx context.Context, audit *model.AuditLog) (int64, error) {
if len(config.AuditLogForwardEndpoint(ctx)) > 0 {
LogMgr.DefaultLogger(ctx).WithField("operator", audit.Username).
WithField("time", audit.OpTime).
Infof("action:%s, resource:%s", audit.Operation, audit.Resource)
}
if config.SkipAuditLogDatabase(ctx) {
return 0, nil
}
return m.dao.Create(ctx, audit) return m.dao.Create(ctx, audit)
} }

View File

@ -16,6 +16,7 @@ package audit
import ( import (
"github.com/goharbor/harbor/src/pkg/audit/model" "github.com/goharbor/harbor/src/pkg/audit/model"
_ "github.com/goharbor/harbor/src/pkg/config/db"
mockDAO "github.com/goharbor/harbor/src/testing/pkg/audit/dao" mockDAO "github.com/goharbor/harbor/src/testing/pkg/audit/dao"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"

View File

@ -0,0 +1,157 @@
// Code generated by mockery v2.12.3. DO NOT EDIT.
package config
import (
context "context"
metadata "github.com/goharbor/harbor/src/lib/config/metadata"
mock "github.com/stretchr/testify/mock"
models "github.com/goharbor/harbor/src/common/models"
)
// Manager is an autogenerated mock type for the Manager type
type Manager struct {
mock.Mock
}
// Get provides a mock function with given fields: ctx, key
func (_m *Manager) Get(ctx context.Context, key string) *metadata.ConfigureValue {
ret := _m.Called(ctx, key)
var r0 *metadata.ConfigureValue
if rf, ok := ret.Get(0).(func(context.Context, string) *metadata.ConfigureValue); ok {
r0 = rf(ctx, key)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*metadata.ConfigureValue)
}
}
return r0
}
// GetAll provides a mock function with given fields: ctx
func (_m *Manager) GetAll(ctx context.Context) map[string]interface{} {
ret := _m.Called(ctx)
var r0 map[string]interface{}
if rf, ok := ret.Get(0).(func(context.Context) map[string]interface{}); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[string]interface{})
}
}
return r0
}
// GetDatabaseCfg provides a mock function with given fields:
func (_m *Manager) GetDatabaseCfg() *models.Database {
ret := _m.Called()
var r0 *models.Database
if rf, ok := ret.Get(0).(func() *models.Database); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.Database)
}
}
return r0
}
// GetUserCfgs provides a mock function with given fields: ctx
func (_m *Manager) GetUserCfgs(ctx context.Context) map[string]interface{} {
ret := _m.Called(ctx)
var r0 map[string]interface{}
if rf, ok := ret.Get(0).(func(context.Context) map[string]interface{}); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[string]interface{})
}
}
return r0
}
// Load provides a mock function with given fields: ctx
func (_m *Manager) Load(ctx context.Context) error {
ret := _m.Called(ctx)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
r0 = rf(ctx)
} else {
r0 = ret.Error(0)
}
return r0
}
// Save provides a mock function with given fields: ctx
func (_m *Manager) Save(ctx context.Context) error {
ret := _m.Called(ctx)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
r0 = rf(ctx)
} else {
r0 = ret.Error(0)
}
return r0
}
// Set provides a mock function with given fields: ctx, key, value
func (_m *Manager) Set(ctx context.Context, key string, value interface{}) {
_m.Called(ctx, key, value)
}
// UpdateConfig provides a mock function with given fields: ctx, cfgs
func (_m *Manager) UpdateConfig(ctx context.Context, cfgs map[string]interface{}) error {
ret := _m.Called(ctx, cfgs)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, map[string]interface{}) error); ok {
r0 = rf(ctx, cfgs)
} else {
r0 = ret.Error(0)
}
return r0
}
// ValidateCfg provides a mock function with given fields: ctx, cfgs
func (_m *Manager) ValidateCfg(ctx context.Context, cfgs map[string]interface{}) error {
ret := _m.Called(ctx, cfgs)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, map[string]interface{}) error); ok {
r0 = rf(ctx, cfgs)
} else {
r0 = ret.Error(0)
}
return r0
}
type NewManagerT interface {
mock.TestingT
Cleanup(func())
}
// NewManager creates a new instance of Manager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewManager(t NewManagerT) *Manager {
mock := &Manager{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@ -16,3 +16,4 @@ package lib
//go:generate mockery --case snake --dir ../../lib/orm --name Creator --output ./orm --outpkg orm //go:generate mockery --case snake --dir ../../lib/orm --name Creator --output ./orm --outpkg orm
//go:generate mockery --case snake --dir ../../lib/cache --name Cache --output ./cache --outpkg cache //go:generate mockery --case snake --dir ../../lib/cache --name Cache --output ./cache --outpkg cache
//go:generate mockery --case snake --dir ../../lib/config --name Manager --output ./config --outpkg config