github.com/telepresenceio/telepresence/v2@v2.20.0-pro.6.0.20240517030216-236ea954e789/pkg/client/logging/rotatingfile.go (about)

     1  package logging
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io/fs"
     7  	"os"
     8  	"path/filepath"
     9  	"sort"
    10  	"strings"
    11  	"sync"
    12  	"time"
    13  
    14  	"github.com/datawire/dlib/dlog"
    15  	"github.com/datawire/dlib/dtime"
    16  	"github.com/telepresenceio/telepresence/v2/pkg/dos"
    17  )
    18  
    19  // A RotationStrategy answers the question if it is time to rotate the file now. It is called prior to every write
    20  // so it needs to be fairly quick.
    21  type RotationStrategy interface {
    22  	RotateNow(file *RotatingFile, writeSize int) bool
    23  }
    24  
    25  type rotateNever int
    26  
    27  // The RotateNever strategy will always answer false to the RotateNow question.
    28  const RotateNever = rotateNever(0)
    29  
    30  func (rotateNever) RotateNow(_ *RotatingFile, _ int) bool {
    31  	return false
    32  }
    33  
    34  // A rotateOnce ensures that the file is rotated exactly once if it is of non-zero size when the
    35  // first call to Write() arrives.
    36  type rotateOnce struct {
    37  	called bool
    38  }
    39  
    40  func NewRotateOnce() RotationStrategy {
    41  	return &rotateOnce{}
    42  }
    43  
    44  func (r *rotateOnce) RotateNow(rf *RotatingFile, _ int) bool {
    45  	if r.called {
    46  		return false
    47  	}
    48  	r.called = true
    49  	return rf.Size() > 0
    50  }
    51  
    52  type rotateDaily int
    53  
    54  // The RotateDaily strategy will ensure that the file is rotated if it is of non-zero size when a call
    55  // to Write() arrives on a day different from the day when the current file was created.
    56  const RotateDaily = rotateDaily(0)
    57  
    58  func (rotateDaily) RotateNow(rf *RotatingFile, _ int) bool {
    59  	if rf.Size() == 0 {
    60  		return false
    61  	}
    62  	bt := rf.BirthTime()
    63  	return dtime.Now().In(bt.Location()).Day() != rf.BirthTime().Day()
    64  }
    65  
    66  type RotatingFile struct {
    67  	ctx         context.Context
    68  	fileMode    fs.FileMode
    69  	dirName     string
    70  	fileName    string
    71  	timeFormat  string
    72  	localTime   bool
    73  	maxFiles    uint16
    74  	strategy    RotationStrategy
    75  	mutex       sync.Mutex
    76  	removeMutex sync.Mutex
    77  
    78  	// file is the current file. It is never nil
    79  	file dos.File
    80  
    81  	// size is the number of bytes written to the current file.
    82  	size int64
    83  
    84  	// birthTime is the time when the current file was first created
    85  	birthTime time.Time
    86  }
    87  
    88  // OpenRotatingFile opens a file with the given name after first having created the directory that it
    89  // resides in and all parent directories. The file is opened write only.
    90  //
    91  // Parameters:
    92  //
    93  // - dirName:   full path to the directory of the log file and its backups
    94  //
    95  // - fileName:   name of the file that should be opened (relative to dirName)
    96  //
    97  // - timeFormat: the format to use for the timestamp that is added to rotated files
    98  //
    99  // - localTime: if true, use local time in timestamps, if false, use UTC
   100  //
   101  // - stdLogger: if not nil, all writes to os.Stdout and os.Stderr will be redirected to this logger as INFO level
   102  // messages prefixed with <stdout> or <stderr>
   103  //
   104  // - fileMode: the mode to use when creating new files the file
   105  //
   106  // - strategy:  determines when a rotation should take place
   107  //
   108  // - maxFiles: maximum number of files in rotation, including the currently active logfile. A value of zero means
   109  // unlimited.
   110  func OpenRotatingFile(
   111  	ctx context.Context,
   112  	logfilePath string,
   113  	timeFormat string,
   114  	localTime bool,
   115  	fileMode fs.FileMode,
   116  	strategy RotationStrategy,
   117  	maxFiles uint16,
   118  ) (*RotatingFile, error) {
   119  	logfileDir, logfileBase := filepath.Split(logfilePath)
   120  
   121  	var err error
   122  	if err = dos.MkdirAll(ctx, logfileDir, 0o755); err != nil {
   123  		return nil, err
   124  	}
   125  
   126  	rf := &RotatingFile{
   127  		ctx:        ctx,
   128  		dirName:    logfileDir,
   129  		fileName:   logfileBase,
   130  		fileMode:   fileMode,
   131  		strategy:   strategy,
   132  		localTime:  localTime,
   133  		timeFormat: timeFormat,
   134  		maxFiles:   maxFiles,
   135  	}
   136  
   137  	// Try to open existing file for append.
   138  	if rf.file, err = dos.OpenFile(ctx, logfilePath, os.O_WRONLY|os.O_APPEND, rf.fileMode); err != nil {
   139  		if os.IsNotExist(err) {
   140  			// There is no existing file, go ahead and create a new one.
   141  			if err = rf.openNew(nil, ""); err == nil {
   142  				return rf, nil
   143  			}
   144  		}
   145  		return nil, err
   146  	}
   147  	// We successfully opened the existing file, get it plugged in.
   148  	stat, err := FStat(rf.file)
   149  	if err != nil {
   150  		return nil, fmt.Errorf("failed to stat %s: %w", logfilePath, err)
   151  	}
   152  	rf.birthTime = stat.BirthTime()
   153  	rf.size = stat.Size()
   154  	rf.afterOpen()
   155  	return rf, nil
   156  }
   157  
   158  // BirthTime returns the time when the current file was created. The time will be local if
   159  // the file was opened with localTime == true and UTC otherwise.
   160  func (rf *RotatingFile) BirthTime() time.Time {
   161  	rf.mutex.Lock()
   162  	bt := rf.birthTime
   163  	rf.mutex.Unlock()
   164  	return bt
   165  }
   166  
   167  // Close implements io.Closer.
   168  func (rf *RotatingFile) Close() error {
   169  	return rf.file.Close()
   170  }
   171  
   172  // Rotate closes the currently opened file and renames it by adding a timestamp between the file name
   173  // and its extension. A new file empty file is then opened to receive subsequent data.
   174  func (rf *RotatingFile) Rotate() (err error) {
   175  	rf.mutex.Lock()
   176  	defer rf.mutex.Unlock()
   177  	return rf.rotate()
   178  }
   179  
   180  // Size returns the size of the current file.
   181  func (rf *RotatingFile) Size() int64 {
   182  	rf.mutex.Lock()
   183  	sz := rf.size
   184  	rf.mutex.Unlock()
   185  	return sz
   186  }
   187  
   188  // Write implements io.Writer.
   189  func (rf *RotatingFile) Write(data []byte) (int, error) {
   190  	rotateNow := rf.strategy.RotateNow(rf, len(data))
   191  	rf.mutex.Lock()
   192  	defer rf.mutex.Unlock()
   193  
   194  	if rotateNow {
   195  		if err := rf.rotate(); err != nil {
   196  			return 0, err
   197  		}
   198  	}
   199  	l, err := rf.file.Write(data)
   200  	if err != nil {
   201  		return 0, err
   202  	}
   203  	rf.size += int64(l)
   204  	return l, nil
   205  }
   206  
   207  func (rf *RotatingFile) afterOpen() {
   208  	go rf.removeOldFiles()
   209  }
   210  
   211  func (rf *RotatingFile) fileTime(t time.Time) time.Time {
   212  	if rf.localTime {
   213  		t = t.Local()
   214  	} else {
   215  		t = t.UTC()
   216  	}
   217  	return t
   218  }
   219  
   220  func (rf *RotatingFile) openNew(prevInfo SysInfo, backupName string) (err error) {
   221  	fullPath := filepath.Join(rf.dirName, rf.fileName)
   222  	var flag int
   223  	if rf.file == nil {
   224  		flag = os.O_CREATE | os.O_WRONLY | os.O_TRUNC
   225  	} else {
   226  		// Open file with a different name so that a tail -F on the original doesn't fail with a permission denied
   227  		tmp := fullPath + ".tmp"
   228  		var tmpFile dos.File
   229  		if tmpFile, err = dos.OpenFile(rf.ctx, tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, rf.fileMode); err != nil {
   230  			return fmt.Errorf("failed to createFile %s: %w", tmp, err)
   231  		}
   232  
   233  		var si SysInfo
   234  		si, err = FStat(tmpFile)
   235  		_ = tmpFile.Close()
   236  
   237  		if err != nil {
   238  			return fmt.Errorf("failed to stat %s: %w", tmp, err)
   239  		}
   240  
   241  		if prevInfo != nil && !prevInfo.HaveSameOwnerAndGroup(si) {
   242  			if err = prevInfo.SetOwnerAndGroup(tmp); err != nil {
   243  				return fmt.Errorf("failed to SetOwnerAndGroup for %s: %w", tmp, err)
   244  			}
   245  		}
   246  
   247  		if err = rf.file.Close(); err != nil {
   248  			return fmt.Errorf("failed to close %s: %w", rf.file.Name(), err)
   249  		}
   250  		if err = dos.Rename(rf.ctx, fullPath, backupName); err != nil {
   251  			return fmt.Errorf("failed to rename %s to %s: %w", fullPath, backupName, err)
   252  		}
   253  		if err = dos.Rename(rf.ctx, tmp, fullPath); err != nil {
   254  			return fmt.Errorf("failed to rename %s to %s: %w", tmp, fullPath, err)
   255  		}
   256  		// Need to restore birth time on Windows since it retains the birt time of the
   257  		// overwritten target of the rename operation.
   258  		if err = restoreCTimeAfterRename(fullPath, si.BirthTime()); err != nil {
   259  			return fmt.Errorf("failed to restore creation time of %s to %s: %w", tmp, si.BirthTime(), err)
   260  		}
   261  		flag = os.O_WRONLY | os.O_APPEND
   262  	}
   263  	if rf.file, err = dos.OpenFile(rf.ctx, fullPath, flag, rf.fileMode); err != nil {
   264  		return fmt.Errorf("failed to open file %s: %w", fullPath, err)
   265  	}
   266  	rf.birthTime = rf.fileTime(dtime.Now())
   267  	rf.size = 0
   268  	rf.afterOpen()
   269  	return nil
   270  }
   271  
   272  // removeOldFiles checks how many files that currently exists (backups + current log file) with the same
   273  // name as this RotatingFile and then, as long as the number of files exceed the maxFiles given to  the
   274  // constructor, it will continuously remove the oldest file.
   275  //
   276  // This function should typically run in its own goroutine.
   277  func (rf *RotatingFile) removeOldFiles() {
   278  	rf.removeMutex.Lock()
   279  	defer rf.removeMutex.Unlock()
   280  
   281  	files, err := dos.ReadDir(rf.ctx, rf.dirName)
   282  	if err != nil {
   283  		return
   284  	}
   285  	ext := filepath.Ext(rf.fileName)
   286  	pfx := rf.fileName[:len(rf.fileName)-len(ext)] + "-"
   287  
   288  	// Use a map with unix nanosecond timestamp as key
   289  	names := make(map[int64]string, rf.maxFiles+2)
   290  
   291  	// Slice of timestamps later to be ordered
   292  	keys := make([]int64, 0, rf.maxFiles+2)
   293  
   294  	for _, file := range files {
   295  		fn := file.Name()
   296  
   297  		// Skip files that don't start with the prefix and end with the suffix.
   298  		if !(strings.HasPrefix(fn, pfx) && strings.HasSuffix(fn, ext)) {
   299  			continue
   300  		}
   301  		// Parse the timestamp from the file name
   302  		var ts time.Time
   303  		if ts, err = time.Parse(rf.timeFormat, fn[len(pfx):len(fn)-len(ext)]); err != nil {
   304  			continue
   305  		}
   306  		key := ts.UnixNano()
   307  		keys = append(keys, key)
   308  		names[key] = fn
   309  	}
   310  	mx := int(rf.maxFiles) - 1 // -1 to account for the current log file
   311  	if len(keys) <= mx {
   312  		return
   313  	}
   314  	sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
   315  	for _, key := range keys[:len(keys)-mx] {
   316  		_ = os.Remove(filepath.Join(rf.dirName, names[key]))
   317  	}
   318  }
   319  
   320  func (rf *RotatingFile) rotate() error {
   321  	var prevInfo SysInfo
   322  	var backupName string
   323  	if rf.maxFiles == 0 || rf.maxFiles > 1 {
   324  		var err error
   325  		prevInfo, err = FStat(rf.file)
   326  		if err != nil || prevInfo == nil {
   327  			err = fmt.Errorf("failed to stat %s: %w", rf.file.Name(), err)
   328  			dlog.Error(rf.ctx, err)
   329  			return err
   330  		}
   331  
   332  		fullPath := filepath.Join(rf.dirName, rf.fileName)
   333  		ex := filepath.Ext(rf.fileName)
   334  		sf := fullPath[:len(fullPath)-len(ex)]
   335  		ts := rf.fileTime(dtime.Now()).Format(rf.timeFormat)
   336  		backupName = fmt.Sprintf("%s-%s%s", sf, ts, ex)
   337  	}
   338  	err := rf.openNew(prevInfo, backupName)
   339  	if err != nil {
   340  		dlog.Error(rf.ctx, err)
   341  	}
   342  	return err
   343  }