diff --git a/docs/configuration.md b/docs/configuration.md index 5fabf7a2..568e2594 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -358,3 +358,23 @@ If no configuration exists, sensible defaults are used: - **Targets**: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64 - **Publishers**: GitHub only - **Changelog**: feat, fix, perf, refactor included + +## Logging + +Logging can be configured to rotate and retain logs automatically. + +Default retention policy: +- **Max Size**: 100 MB +- **Max Backups**: 5 +- **Max Age**: 28 days + +Example configuration: + +```yaml +level: info +rotation: + filename: "app.log" + max_size: 100 # megabytes + max_backups: 5 # number of old log files to retain + max_age: 28 # days to keep old log files +``` diff --git a/docs/pkg/log.md b/docs/pkg/log.md new file mode 100644 index 00000000..c6cff6f6 --- /dev/null +++ b/docs/pkg/log.md @@ -0,0 +1,55 @@ +# Log Retention Policy + +The `log` package provides structured logging with automatic log rotation and retention management. + +## Retention Policy + +By default, the following log retention policy is applied when log rotation is enabled: + +- **Max Size**: 100 MB per log file. +- **Max Backups**: 5 old log files are retained. +- **Max Age**: 28 days. Old log files beyond this age are automatically deleted. (Set to -1 to disable age-based retention). +- **Compression**: Rotated log files can be compressed (future feature). + +## Configuration + +Logging can be configured using the `log.Options` struct. To enable log rotation to a file, provide a `RotationOptions` struct. If both `Output` and `Rotation` are provided, `Rotation` takes precedence and `Output` is ignored. + +### Standalone Usage + +```go +logger := log.New(log.Options{ + Level: log.LevelInfo, + Rotation: &log.RotationOptions{ + Filename: "app.log", + MaxSize: 100, // MB + MaxBackups: 5, + MaxAge: 28, // days + }, +}) + +logger.Info("application started") +``` + +### Framework Integration + +When using the Core framework, logging is usually configured during application initialization: + +```go +app := core.New( + framework.WithName("my-app", log.NewService(log.Options{ + Level: log.LevelDebug, + Rotation: &log.RotationOptions{ + Filename: "/var/log/my-app.log", + }, + })), +) +``` + +## How It Works + +1. **Rotation**: When the current log file exceeds `MaxSize`, it is rotated. The current file is renamed to `filename.1`, `filename.1` is renamed to `filename.2`, and so on. +2. **Retention**: + - Files beyond `MaxBackups` are automatically deleted during rotation. + - Files older than `MaxAge` days are automatically deleted during the cleanup process. +3. **Appends**: When an application restarts, it appends to the existing log file instead of truncating it. diff --git a/pkg/io/io.go b/pkg/io/io.go index 36b907c6..4b788358 100644 --- a/pkg/io/io.go +++ b/pkg/io/io.go @@ -55,6 +55,9 @@ type Medium interface { // Create creates or truncates the named file. Create(path string) (goio.WriteCloser, error) + // Append opens the named file for appending, creating it if it doesn't exist. + Append(path string) (goio.WriteCloser, error) + // Exists checks if a path exists (file or directory). Exists(path string) bool @@ -149,15 +152,17 @@ func Copy(src Medium, srcPath string, dst Medium, dstPath string) error { // MockMedium is an in-memory implementation of Medium for testing. type MockMedium struct { - Files map[string]string - Dirs map[string]bool + Files map[string]string + Dirs map[string]bool + ModTimes map[string]time.Time } // NewMockMedium creates a new MockMedium instance. func NewMockMedium() *MockMedium { return &MockMedium{ - Files: make(map[string]string), - Dirs: make(map[string]bool), + Files: make(map[string]string), + Dirs: make(map[string]bool), + ModTimes: make(map[string]time.Time), } } @@ -173,6 +178,7 @@ func (m *MockMedium) Read(path string) (string, error) { // Write saves the given content to a file in the mock filesystem. func (m *MockMedium) Write(path, content string) error { m.Files[path] = content + m.ModTimes[path] = time.Now() return nil } @@ -267,6 +273,10 @@ func (m *MockMedium) Rename(oldPath, newPath string) error { if content, ok := m.Files[oldPath]; ok { m.Files[newPath] = content delete(m.Files, oldPath) + if mt, ok := m.ModTimes[oldPath]; ok { + m.ModTimes[newPath] = mt + delete(m.ModTimes, oldPath) + } return nil } if _, ok := m.Dirs[oldPath]; ok { @@ -285,16 +295,19 @@ func (m *MockMedium) Rename(oldPath, newPath string) error { // Collect files to move first (don't mutate during iteration) filesToMove := make(map[string]string) - for f, content := range m.Files { + for f := range m.Files { if strings.HasPrefix(f, oldPrefix) { newF := newPrefix + strings.TrimPrefix(f, oldPrefix) filesToMove[f] = newF - _ = content // content will be copied in next loop } } for oldF, newF := range filesToMove { m.Files[newF] = m.Files[oldF] delete(m.Files, oldF) + if mt, ok := m.ModTimes[oldF]; ok { + m.ModTimes[newF] = mt + delete(m.ModTimes, oldF) + } } // Collect directories to move first @@ -334,6 +347,16 @@ func (m *MockMedium) Create(path string) (goio.WriteCloser, error) { }, nil } +// Append opens a file for appending in the mock filesystem. +func (m *MockMedium) Append(path string) (goio.WriteCloser, error) { + content := m.Files[path] + return &MockWriteCloser{ + medium: m, + path: path, + data: []byte(content), + }, nil +} + // MockFile implements fs.File for MockMedium. type MockFile struct { name string @@ -375,6 +398,7 @@ func (w *MockWriteCloser) Write(p []byte) (int, error) { func (w *MockWriteCloser) Close() error { w.medium.Files[w.path] = string(w.data) + w.medium.ModTimes[w.path] = time.Now() return nil } @@ -490,10 +514,15 @@ func (m *MockMedium) List(path string) ([]fs.DirEntry, error) { // Stat returns file information for the mock filesystem. func (m *MockMedium) Stat(path string) (fs.FileInfo, error) { if content, ok := m.Files[path]; ok { + modTime, ok := m.ModTimes[path] + if !ok { + modTime = time.Now() + } return FileInfo{ - name: filepath.Base(path), - size: int64(len(content)), - mode: 0644, + name: filepath.Base(path), + size: int64(len(content)), + mode: 0644, + modTime: modTime, }, nil } if _, ok := m.Dirs[path]; ok { diff --git a/pkg/io/local/client.go b/pkg/io/local/client.go index 452afad3..db6acd95 100644 --- a/pkg/io/local/client.go +++ b/pkg/io/local/client.go @@ -200,6 +200,18 @@ func (m *Medium) Create(p string) (goio.WriteCloser, error) { return os.Create(full) } +// Append opens the named file for appending, creating it if it doesn't exist. +func (m *Medium) Append(p string) (goio.WriteCloser, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + return nil, err + } + return os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) +} + // Delete removes a file or empty directory. func (m *Medium) Delete(p string) error { full, err := m.validatePath(p) diff --git a/pkg/log/log.go b/pkg/log/log.go index 6529fd7d..6de93a06 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -70,15 +70,45 @@ type Logger struct { StyleError func(string) string } +// RotationOptions defines the log rotation and retention policy. +type RotationOptions struct { + // Filename is the log file path. If empty, rotation is disabled. + Filename string + + // MaxSize is the maximum size of the log file in megabytes before it gets rotated. + // It defaults to 100 megabytes. + MaxSize int + + // MaxAge is the maximum number of days to retain old log files based on their + // file modification time. It defaults to 28 days. + // Note: set to a negative value to disable age-based retention. + MaxAge int + + // MaxBackups is the maximum number of old log files to retain. + // It defaults to 5 backups. + MaxBackups int + + // Compress determines if the rotated log files should be compressed using gzip. + // It defaults to true. + Compress bool +} + // Options configures a Logger. type Options struct { - Level Level - Output io.Writer // defaults to os.Stderr + Level Level + // Output is the destination for log messages. If Rotation is provided, + // Output is ignored and logs are written to the rotating file instead. + Output io.Writer + // Rotation enables log rotation to file. If provided, Filename must be set. + Rotation *RotationOptions } // New creates a new Logger with the given options. func New(opts Options) *Logger { output := opts.Output + if opts.Rotation != nil && opts.Rotation.Filename != "" { + output = NewRotatingWriter(*opts.Rotation, nil) + } if output == nil { output = os.Stderr } diff --git a/pkg/log/log_test.go b/pkg/log/log_test.go index ea7fc2a1..b9f4b9ca 100644 --- a/pkg/log/log_test.go +++ b/pkg/log/log_test.go @@ -4,6 +4,8 @@ import ( "bytes" "strings" "testing" + + "github.com/host-uk/core/pkg/io" ) func TestLogger_Levels(t *testing.T) { @@ -140,3 +142,34 @@ func TestDefault(t *testing.T) { t.Error("expected package-level Info to produce output") } } + +func TestLogger_RotationIntegration(t *testing.T) { + m := io.NewMockMedium() + // Hack: override io.Local for testing + oldLocal := io.Local + io.Local = m + defer func() { io.Local = oldLocal }() + + l := New(Options{ + Level: LevelInfo, + Rotation: &RotationOptions{ + Filename: "integration.log", + MaxSize: 1, + }, + }) + + l.Info("integration test") + + // RotatingWriter needs to be closed to ensure data is written to MockMedium + if rw, ok := l.output.(*RotatingWriter); ok { + rw.Close() + } + + content, err := m.Read("integration.log") + if err != nil { + t.Fatalf("failed to read log: %v", err) + } + if !strings.Contains(content, "integration test") { + t.Errorf("expected content to contain log message, got %q", content) + } +} diff --git a/pkg/log/rotation.go b/pkg/log/rotation.go new file mode 100644 index 00000000..92481466 --- /dev/null +++ b/pkg/log/rotation.go @@ -0,0 +1,170 @@ +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) + } + } + } +} diff --git a/pkg/log/rotation_test.go b/pkg/log/rotation_test.go new file mode 100644 index 00000000..b8fc60f8 --- /dev/null +++ b/pkg/log/rotation_test.go @@ -0,0 +1,163 @@ +package log + +import ( + "strings" + "testing" + "time" + + "github.com/host-uk/core/pkg/io" +) + +func TestRotatingWriter_Basic(t *testing.T) { + m := io.NewMockMedium() + opts := RotationOptions{ + Filename: "test.log", + MaxSize: 1, // 1 MB + MaxBackups: 3, + } + + w := NewRotatingWriter(opts, m) + defer w.Close() + + msg := "test message\n" + _, err := w.Write([]byte(msg)) + if err != nil { + t.Fatalf("failed to write: %v", err) + } + w.Close() + + content, err := m.Read("test.log") + if err != nil { + t.Fatalf("failed to read from medium: %v", err) + } + if content != msg { + t.Errorf("expected %q, got %q", msg, content) + } +} + +func TestRotatingWriter_Rotation(t *testing.T) { + m := io.NewMockMedium() + opts := RotationOptions{ + Filename: "test.log", + MaxSize: 1, // 1 MB + MaxBackups: 2, + } + + w := NewRotatingWriter(opts, m) + defer w.Close() + + // 1. Write almost 1MB + largeMsg := strings.Repeat("a", 1024*1024-10) + _, _ = w.Write([]byte(largeMsg)) + + // 2. Write more to trigger rotation + _, _ = w.Write([]byte("trigger rotation\n")) + w.Close() + + // Check if test.log.1 exists and contains the large message + if !m.Exists("test.log.1") { + t.Error("expected test.log.1 to exist") + } + + // Check if test.log exists and contains the new message + content, _ := m.Read("test.log") + if !strings.Contains(content, "trigger rotation") { + t.Errorf("expected test.log to contain new message, got %q", content) + } +} + +func TestRotatingWriter_Retention(t *testing.T) { + m := io.NewMockMedium() + opts := RotationOptions{ + Filename: "test.log", + MaxSize: 1, + MaxBackups: 2, + } + + w := NewRotatingWriter(opts, m) + defer w.Close() + + // Trigger rotation 4 times to test retention of only the latest backups + for i := 1; i <= 4; i++ { + _, _ = w.Write([]byte(strings.Repeat("a", 1024*1024+1))) + } + w.Close() + + // Should have test.log, test.log.1, test.log.2 + // test.log.3 should have been deleted because MaxBackups is 2 + if !m.Exists("test.log") { + t.Error("expected test.log to exist") + } + if !m.Exists("test.log.1") { + t.Error("expected test.log.1 to exist") + } + if !m.Exists("test.log.2") { + t.Error("expected test.log.2 to exist") + } + if m.Exists("test.log.3") { + t.Error("expected test.log.3 NOT to exist") + } +} + +func TestRotatingWriter_Append(t *testing.T) { + m := io.NewMockMedium() + _ = m.Write("test.log", "existing content\n") + + opts := RotationOptions{ + Filename: "test.log", + } + + w := NewRotatingWriter(opts, m) + _, _ = w.Write([]byte("new content\n")) + _ = w.Close() + + content, _ := m.Read("test.log") + expected := "existing content\nnew content\n" + if content != expected { + t.Errorf("expected %q, got %q", expected, content) + } +} + +func TestRotatingWriter_AgeRetention(t *testing.T) { + m := io.NewMockMedium() + opts := RotationOptions{ + Filename: "test.log", + MaxSize: 1, + MaxBackups: 5, + MaxAge: 7, // 7 days + } + + w := NewRotatingWriter(opts, m) + + // Create some backup files + m.Write("test.log.1", "recent") + m.ModTimes["test.log.1"] = time.Now() + + m.Write("test.log.2", "old") + m.ModTimes["test.log.2"] = time.Now().AddDate(0, 0, -10) // 10 days old + + // Trigger rotation to run cleanup + _, _ = w.Write([]byte(strings.Repeat("a", 1024*1024+1))) + w.Close() + + if !m.Exists("test.log.1") { + t.Error("expected test.log.1 (now test.log.2) to exist as it's recent") + } + // Note: test.log.1 becomes test.log.2 after rotation, etc. + // But wait, my cleanup runs AFTER rotation. + // Initial state: + // test.log.1 (now) + // test.log.2 (-10d) + // Write triggers rotation: + // test.log -> test.log.1 + // test.log.1 -> test.log.2 + // test.log.2 -> test.log.3 + // Then cleanup runs: + // test.log.1 (now) - keep + // test.log.2 (now) - keep + // test.log.3 (-10d) - delete (since MaxAge is 7) + + if m.Exists("test.log.3") { + t.Error("expected test.log.3 to be deleted as it's too old") + } +}