cli/pkg/log/rotation_test.go
Snider 155251c8d9
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

163 lines
3.7 KiB
Go

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")
}
}