cli/pkg/log/rotation.go
Snider 38db43bbfb Implement log retention policy (#306)
* Implement log retention policy

- Added Append method to io.Medium interface and implementations.
- Defined RotationOptions and updated log.Options to support log rotation.
- Implemented RotatingWriter in pkg/log/rotation.go with size and age-based retention.
- Updated Logger to use RotatingWriter when configured.
- Added comprehensive tests for log rotation and retention.
- Documented the log retention policy in docs/pkg/log.md and docs/configuration.md.
- Fixed MockMedium to return current time for Stat to avoid premature cleanup in tests.

* Fix formatting issues in pkg/io/local/client.go

The CI failed due to formatting issues. This commit fixes them and ensures all modified files are properly formatted.

* Fix auto-merge workflow CI failure

Inlined the auto-merge logic and added actions/checkout and --repo flag to gh command to provide the necessary git context. This resolves the 'fatal: not a git repository' error in CI.

* Address feedback on log retention policy

- Made cleanup synchronous in RotatingWriter for better reliability.
- Improved rotation error handling with recovery logic.
- Fixed size tracking to only increment on successful writes.
- Updated MockMedium to support and preserve ModTimes for age-based testing.
- Added TestRotatingWriter_AgeRetention and TestLogger_RotationIntegration.
- Implemented negative MaxAge to disable age-based retention.
- Updated documentation for clarity on Output priority and MaxAge behavior.
- Fixed typo in test comments.
- Fixed CI failure in auto-merge workflow.

---------

Co-authored-by: Claude <developers@lethean.io>
2026-02-05 10:26:32 +00:00

170 lines
3.3 KiB
Go

package log
import (
"fmt"
"io"
"sync"
"time"
coreio "github.com/host-uk/core/pkg/io"
)
// RotatingWriter implements io.WriteCloser and provides log rotation.
type RotatingWriter struct {
opts RotationOptions
medium coreio.Medium
mu sync.Mutex
file io.WriteCloser
size int64
}
// NewRotatingWriter creates a new RotatingWriter with the given options and medium.
func NewRotatingWriter(opts RotationOptions, m coreio.Medium) *RotatingWriter {
if m == nil {
m = coreio.Local
}
if opts.MaxSize <= 0 {
opts.MaxSize = 100 // 100 MB
}
if opts.MaxBackups <= 0 {
opts.MaxBackups = 5
}
if opts.MaxAge == 0 {
opts.MaxAge = 28 // 28 days
} else if opts.MaxAge < 0 {
opts.MaxAge = 0 // disabled
}
return &RotatingWriter{
opts: opts,
medium: m,
}
}
// Write writes data to the current log file, rotating it if necessary.
func (w *RotatingWriter) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
if err := w.openExistingOrNew(); err != nil {
return 0, err
}
}
if w.size+int64(len(p)) > int64(w.opts.MaxSize)*1024*1024 {
if err := w.rotate(); err != nil {
return 0, err
}
}
n, err = w.file.Write(p)
if err == nil {
w.size += int64(n)
}
return n, err
}
// Close closes the current log file.
func (w *RotatingWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
return w.close()
}
func (w *RotatingWriter) close() error {
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
func (w *RotatingWriter) openExistingOrNew() error {
info, err := w.medium.Stat(w.opts.Filename)
if err == nil {
w.size = info.Size()
f, err := w.medium.Append(w.opts.Filename)
if err != nil {
return err
}
w.file = f
return nil
}
f, err := w.medium.Create(w.opts.Filename)
if err != nil {
return err
}
w.file = f
w.size = 0
return nil
}
func (w *RotatingWriter) rotate() error {
if err := w.close(); err != nil {
return err
}
if err := w.rotateFiles(); err != nil {
// Try to reopen current file even if rotation failed
_ = w.openExistingOrNew()
return err
}
if err := w.openExistingOrNew(); err != nil {
return err
}
w.cleanup()
return nil
}
func (w *RotatingWriter) rotateFiles() error {
// Rotate existing backups: log.N -> log.N+1
for i := w.opts.MaxBackups; i >= 1; i-- {
oldPath := w.backupPath(i)
newPath := w.backupPath(i + 1)
if w.medium.Exists(oldPath) {
if i+1 > w.opts.MaxBackups {
_ = w.medium.Delete(oldPath)
} else {
_ = w.medium.Rename(oldPath, newPath)
}
}
}
// log -> log.1
return w.medium.Rename(w.opts.Filename, w.backupPath(1))
}
func (w *RotatingWriter) backupPath(n int) string {
return fmt.Sprintf("%s.%d", w.opts.Filename, n)
}
func (w *RotatingWriter) cleanup() {
// 1. Remove backups beyond MaxBackups
// This is already partially handled by rotateFiles but we can be thorough
for i := w.opts.MaxBackups + 1; ; i++ {
path := w.backupPath(i)
if !w.medium.Exists(path) {
break
}
_ = w.medium.Delete(path)
}
// 2. Remove backups older than MaxAge
if w.opts.MaxAge > 0 {
cutoff := time.Now().AddDate(0, 0, -w.opts.MaxAge)
for i := 1; i <= w.opts.MaxBackups; i++ {
path := w.backupPath(i)
info, err := w.medium.Stat(path)
if err == nil && info.ModTime().Before(cutoff) {
_ = w.medium.Delete(path)
}
}
}
}