408 lines
10 KiB
Go
408 lines
10 KiB
Go
// package rotatelogs is a port of File-RotateLogs from Perl
|
|
// (https://metacpan.org/release/File-RotateLogs), and it allows
|
|
// you to automatically rotate output files when you write to them
|
|
// according to the filename pattern that you can specify.
|
|
package rotatelogs
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
strftime "github.com/lestrrat-go/strftime"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
func (c clockFn) Now() time.Time {
|
|
return c()
|
|
}
|
|
|
|
// New creates a new RotateLogs object. A log filename pattern
|
|
// must be passed. Optional `Option` parameters may be passed
|
|
func New(p string, options ...Option) (*RotateLogs, error) {
|
|
globPattern := p
|
|
for _, re := range patternConversionRegexps {
|
|
globPattern = re.ReplaceAllString(globPattern, "*")
|
|
}
|
|
|
|
pattern, err := strftime.New(p)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, `invalid strftime pattern`)
|
|
}
|
|
|
|
var clock Clock = Local
|
|
rotationTime := 24 * time.Hour
|
|
var rotationSize int64
|
|
var rotationCount uint
|
|
var linkName string
|
|
var maxAge time.Duration
|
|
var handler Handler
|
|
var forceNewFile bool
|
|
|
|
for _, o := range options {
|
|
switch o.Name() {
|
|
case optkeyClock:
|
|
clock = o.Value().(Clock)
|
|
case optkeyLinkName:
|
|
linkName = o.Value().(string)
|
|
case optkeyMaxAge:
|
|
maxAge = o.Value().(time.Duration)
|
|
if maxAge < 0 {
|
|
maxAge = 0
|
|
}
|
|
case optkeyRotationTime:
|
|
rotationTime = o.Value().(time.Duration)
|
|
if rotationTime < 0 {
|
|
rotationTime = 0
|
|
}
|
|
case optkeyRotationSize:
|
|
rotationSize = o.Value().(int64)
|
|
if rotationSize < 0 {
|
|
rotationSize = 0
|
|
}
|
|
case optkeyRotationCount:
|
|
rotationCount = o.Value().(uint)
|
|
case optkeyHandler:
|
|
handler = o.Value().(Handler)
|
|
case optkeyForceNewFile:
|
|
forceNewFile = true
|
|
}
|
|
}
|
|
|
|
if maxAge > 0 && rotationCount > 0 {
|
|
return nil, errors.New("options MaxAge and RotationCount cannot be both set")
|
|
}
|
|
|
|
if maxAge == 0 && rotationCount == 0 {
|
|
// if both are 0, give maxAge a sane default
|
|
maxAge = 7 * 24 * time.Hour
|
|
}
|
|
|
|
return &RotateLogs{
|
|
clock: clock,
|
|
eventHandler: handler,
|
|
globPattern: globPattern,
|
|
linkName: linkName,
|
|
maxAge: maxAge,
|
|
pattern: pattern,
|
|
rotationTime: rotationTime,
|
|
rotationSize: rotationSize,
|
|
rotationCount: rotationCount,
|
|
forceNewFile: forceNewFile,
|
|
}, nil
|
|
}
|
|
|
|
func (rl *RotateLogs) genFilename() string {
|
|
now := rl.clock.Now()
|
|
|
|
// XXX HACK: Truncate only happens in UTC semantics, apparently.
|
|
// observed values for truncating given time with 86400 secs:
|
|
//
|
|
// before truncation: 2018/06/01 03:54:54 2018-06-01T03:18:00+09:00
|
|
// after truncation: 2018/06/01 03:54:54 2018-05-31T09:00:00+09:00
|
|
//
|
|
// This is really annoying when we want to truncate in local time
|
|
// so we hack: we take the apparent local time in the local zone,
|
|
// and pretend that it's in UTC. do our math, and put it back to
|
|
// the local zone
|
|
var base time.Time
|
|
if now.Location() != time.UTC {
|
|
base = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), time.UTC)
|
|
base = base.Truncate(time.Duration(rl.rotationTime))
|
|
base = time.Date(base.Year(), base.Month(), base.Day(), base.Hour(), base.Minute(), base.Second(), base.Nanosecond(), base.Location())
|
|
} else {
|
|
base = now.Truncate(time.Duration(rl.rotationTime))
|
|
}
|
|
return rl.pattern.FormatString(base)
|
|
}
|
|
|
|
// Write satisfies the io.Writer interface. It writes to the
|
|
// appropriate file handle that is currently being used.
|
|
// If we have reached rotation time, the target file gets
|
|
// automatically rotated, and also purged if necessary.
|
|
func (rl *RotateLogs) Write(p []byte) (n int, err error) {
|
|
// Guard against concurrent writes
|
|
rl.mutex.Lock()
|
|
defer rl.mutex.Unlock()
|
|
|
|
out, err := rl.getWriter_nolock(false, false)
|
|
if err != nil {
|
|
return 0, errors.Wrap(err, `failed to acquite target io.Writer`)
|
|
}
|
|
|
|
return out.Write(p)
|
|
}
|
|
|
|
// must be locked during this operation
|
|
func (rl *RotateLogs) getWriter_nolock(bailOnRotateFail, useGenerationalNames bool) (io.Writer, error) {
|
|
generation := rl.generation
|
|
previousFn := rl.curFn
|
|
// This filename contains the name of the "NEW" filename
|
|
// to log to, which may be newer than rl.currentFilename
|
|
baseFn := rl.genFilename()
|
|
filename := baseFn
|
|
var forceNewFile bool
|
|
|
|
fi, err := os.Stat(rl.curFn)
|
|
sizeRotation := false
|
|
if err == nil && rl.rotationSize > 0 && rl.rotationSize <= fi.Size() {
|
|
forceNewFile = true
|
|
sizeRotation = true
|
|
}
|
|
|
|
if baseFn != rl.curBaseFn {
|
|
generation = 0
|
|
// even though this is the first write after calling New(),
|
|
// check if a new file needs to be created
|
|
if rl.forceNewFile {
|
|
forceNewFile = true
|
|
}
|
|
} else {
|
|
if !useGenerationalNames && !sizeRotation {
|
|
// nothing to do
|
|
return rl.outFh, nil
|
|
}
|
|
forceNewFile = true
|
|
generation++
|
|
}
|
|
if forceNewFile {
|
|
// A new file has been requested. Instead of just using the
|
|
// regular strftime pattern, we create a new file name using
|
|
// generational names such as "foo.1", "foo.2", "foo.3", etc
|
|
var name string
|
|
for {
|
|
if generation == 0 {
|
|
name = filename
|
|
} else {
|
|
name = fmt.Sprintf("%s.%d", filename, generation)
|
|
}
|
|
if _, err := os.Stat(name); err != nil {
|
|
filename = name
|
|
break
|
|
}
|
|
generation++
|
|
}
|
|
}
|
|
// make sure the dir is existed, eg:
|
|
// ./foo/bar/baz/hello.log must make sure ./foo/bar/baz is existed
|
|
dirname := filepath.Dir(filename)
|
|
if err := os.MkdirAll(dirname, 0755); err != nil {
|
|
return nil, errors.Wrapf(err, "failed to create directory %s", dirname)
|
|
}
|
|
// if we got here, then we need to create a file
|
|
fh, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
return nil, errors.Errorf("failed to open file %s: %s", rl.pattern, err)
|
|
}
|
|
|
|
if err := rl.rotate_nolock(filename); err != nil {
|
|
err = errors.Wrap(err, "failed to rotate")
|
|
if bailOnRotateFail {
|
|
// Failure to rotate is a problem, but it's really not a great
|
|
// idea to stop your application just because you couldn't rename
|
|
// your log.
|
|
//
|
|
// We only return this error when explicitly needed (as specified by bailOnRotateFail)
|
|
//
|
|
// However, we *NEED* to close `fh` here
|
|
if fh != nil { // probably can't happen, but being paranoid
|
|
fh.Close()
|
|
}
|
|
return nil, err
|
|
}
|
|
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
|
|
}
|
|
|
|
rl.outFh.Close()
|
|
rl.outFh = fh
|
|
rl.curBaseFn = baseFn
|
|
rl.curFn = filename
|
|
rl.generation = generation
|
|
|
|
if h := rl.eventHandler; h != nil {
|
|
go h.Handle(&FileRotatedEvent{
|
|
prev: previousFn,
|
|
current: filename,
|
|
})
|
|
}
|
|
return fh, nil
|
|
}
|
|
|
|
// CurrentFileName returns the current file name that
|
|
// the RotateLogs object is writing to
|
|
func (rl *RotateLogs) CurrentFileName() string {
|
|
rl.mutex.RLock()
|
|
defer rl.mutex.RUnlock()
|
|
return rl.curFn
|
|
}
|
|
|
|
var patternConversionRegexps = []*regexp.Regexp{
|
|
regexp.MustCompile(`%[%+A-Za-z]`),
|
|
regexp.MustCompile(`\*+`),
|
|
}
|
|
|
|
type cleanupGuard struct {
|
|
enable bool
|
|
fn func()
|
|
mutex sync.Mutex
|
|
}
|
|
|
|
func (g *cleanupGuard) Enable() {
|
|
g.mutex.Lock()
|
|
defer g.mutex.Unlock()
|
|
g.enable = true
|
|
}
|
|
func (g *cleanupGuard) Run() {
|
|
g.fn()
|
|
}
|
|
|
|
// Rotate forcefully rotates the log files. If the generated file name
|
|
// clash because file already exists, a numeric suffix of the form
|
|
// ".1", ".2", ".3" and so forth are appended to the end of the log file
|
|
//
|
|
// Thie method can be used in conjunction with a signal handler so to
|
|
// emulate servers that generate new log files when they receive a
|
|
// SIGHUP
|
|
func (rl *RotateLogs) Rotate() error {
|
|
rl.mutex.Lock()
|
|
defer rl.mutex.Unlock()
|
|
if _, err := rl.getWriter_nolock(true, true); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (rl *RotateLogs) rotate_nolock(filename string) error {
|
|
lockfn := filename + `_lock`
|
|
fh, err := os.OpenFile(lockfn, os.O_CREATE|os.O_EXCL, 0644)
|
|
if err != nil {
|
|
// Can't lock, just return
|
|
return err
|
|
}
|
|
|
|
var guard cleanupGuard
|
|
guard.fn = func() {
|
|
fh.Close()
|
|
os.Remove(lockfn)
|
|
}
|
|
defer guard.Run()
|
|
|
|
if rl.linkName != "" {
|
|
tmpLinkName := filename + `_symlink`
|
|
|
|
// Change how the link name is generated based on where the
|
|
// target location is. if the location is directly underneath
|
|
// the main filename's parent directory, then we create a
|
|
// symlink with a relative path
|
|
linkDest := filename
|
|
linkDir := filepath.Dir(rl.linkName)
|
|
|
|
baseDir := filepath.Dir(filename)
|
|
if strings.Contains(rl.linkName, baseDir) {
|
|
tmp, err := filepath.Rel(linkDir, filename)
|
|
if err != nil {
|
|
return errors.Wrapf(err, `failed to evaluate relative path from %#v to %#v`, baseDir, rl.linkName)
|
|
}
|
|
|
|
linkDest = tmp
|
|
}
|
|
|
|
if err := os.Symlink(linkDest, tmpLinkName); err != nil {
|
|
return errors.Wrap(err, `failed to create new symlink`)
|
|
}
|
|
|
|
// the directory where rl.linkName should be created must exist
|
|
_, err := os.Stat(linkDir)
|
|
if err != nil { // Assume err != nil means the directory doesn't exist
|
|
if err := os.MkdirAll(linkDir, 0755); err != nil {
|
|
return errors.Wrapf(err, `failed to create directory %s`, linkDir)
|
|
}
|
|
}
|
|
|
|
if err := os.Rename(tmpLinkName, rl.linkName); err != nil {
|
|
return errors.Wrap(err, `failed to rename new symlink`)
|
|
}
|
|
}
|
|
|
|
if rl.maxAge <= 0 && rl.rotationCount <= 0 {
|
|
return errors.New("panic: maxAge and rotationCount are both set")
|
|
}
|
|
|
|
matches, err := filepath.Glob(rl.globPattern)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cutoff := rl.clock.Now().Add(-1 * rl.maxAge)
|
|
var toUnlink []string
|
|
for _, path := range matches {
|
|
// Ignore lock files
|
|
if strings.HasSuffix(path, "_lock") || strings.HasSuffix(path, "_symlink") {
|
|
continue
|
|
}
|
|
|
|
fi, err := os.Stat(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
fl, err := os.Lstat(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if rl.maxAge > 0 && fi.ModTime().After(cutoff) {
|
|
continue
|
|
}
|
|
|
|
if rl.rotationCount > 0 && fl.Mode()&os.ModeSymlink == os.ModeSymlink {
|
|
continue
|
|
}
|
|
toUnlink = append(toUnlink, path)
|
|
}
|
|
|
|
if rl.rotationCount > 0 {
|
|
// Only delete if we have more than rotationCount
|
|
if rl.rotationCount >= uint(len(toUnlink)) {
|
|
return nil
|
|
}
|
|
|
|
toUnlink = toUnlink[:len(toUnlink)-int(rl.rotationCount)]
|
|
}
|
|
|
|
if len(toUnlink) <= 0 {
|
|
return nil
|
|
}
|
|
|
|
guard.Enable()
|
|
go func() {
|
|
// unlink files on a separate goroutine
|
|
for _, path := range toUnlink {
|
|
os.Remove(path)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close satisfies the io.Closer interface. You must
|
|
// call this method if you performed any writes to
|
|
// the object.
|
|
func (rl *RotateLogs) Close() error {
|
|
rl.mutex.Lock()
|
|
defer rl.mutex.Unlock()
|
|
|
|
if rl.outFh == nil {
|
|
return nil
|
|
}
|
|
|
|
rl.outFh.Close()
|
|
rl.outFh = nil
|
|
return nil
|
|
}
|