Skip to content

Commit a5ec747

Browse files
authored
Merge pull request #1372 from rumpl/feature/rotate-debug-logs
Add log rotation for debug logs
2 parents e2164c9 + 97e0b43 commit a5ec747

File tree

3 files changed

+267
-8
lines changed

3 files changed

+267
-8
lines changed

cmd/root/root.go

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/docker/cagent/pkg/environment"
1717
"github.com/docker/cagent/pkg/feedback"
18+
"github.com/docker/cagent/pkg/logging"
1819
"github.com/docker/cagent/pkg/paths"
1920
"github.com/docker/cagent/pkg/telemetry"
2021
"github.com/docker/cagent/pkg/version"
@@ -24,7 +25,7 @@ type rootFlags struct {
2425
enableOtel bool
2526
debugMode bool
2627
logFilePath string
27-
logFile *os.File
28+
logFile io.Closer
2829
}
2930

3031
func NewRootCmd() *cobra.Command {
@@ -169,8 +170,9 @@ We collect anonymous usage data to help improve cagent. To disable:
169170
}
170171

171172
// setupLogging configures slog logging behavior.
172-
// When --debug is enabled, logs are written to a single file <dataDir>/cagent.debug.log (append mode),
173-
// or to the file specified by --log-file.
173+
// When --debug is enabled, logs are written to a rotating file <dataDir>/cagent.debug.log,
174+
// or to the file specified by --log-file. Log files are rotated when they exceed 10MB,
175+
// keeping up to 3 backup files.
174176
func (f *rootFlags) setupLogging() error {
175177
if !f.debugMode {
176178
slog.SetDefault(slog.New(slog.DiscardHandler))
@@ -179,11 +181,7 @@ func (f *rootFlags) setupLogging() error {
179181

180182
path := cmp.Or(strings.TrimSpace(f.logFilePath), filepath.Join(paths.GetDataDir(), "cagent.debug.log"))
181183

182-
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
183-
return err
184-
}
185-
186-
logFile, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
184+
logFile, err := logging.NewRotatingFile(path)
187185
if err != nil {
188186
return err
189187
}

pkg/logging/rotate.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package logging
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"sync"
8+
)
9+
10+
const (
11+
DefaultMaxSize = 10 * 1024 * 1024 // 10MB
12+
DefaultMaxBackups = 3
13+
)
14+
15+
// RotatingFile is an io.WriteCloser that rotates log files when they exceed a size limit.
16+
type RotatingFile struct {
17+
path string
18+
maxSize int64
19+
maxBackups int
20+
21+
mu sync.Mutex
22+
file *os.File
23+
size int64
24+
}
25+
26+
type Option func(*RotatingFile)
27+
28+
func WithMaxSize(size int64) Option {
29+
return func(r *RotatingFile) {
30+
r.maxSize = size
31+
}
32+
}
33+
34+
func WithMaxBackups(count int) Option {
35+
return func(r *RotatingFile) {
36+
r.maxBackups = count
37+
}
38+
}
39+
40+
// NewRotatingFile creates a new rotating file writer.
41+
func NewRotatingFile(path string, opts ...Option) (*RotatingFile, error) {
42+
r := &RotatingFile{
43+
path: path,
44+
maxSize: DefaultMaxSize,
45+
maxBackups: DefaultMaxBackups,
46+
}
47+
48+
for _, opt := range opts {
49+
opt(r)
50+
}
51+
52+
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
53+
return nil, err
54+
}
55+
56+
if err := r.openFile(); err != nil {
57+
return nil, err
58+
}
59+
60+
return r, nil
61+
}
62+
63+
func (r *RotatingFile) openFile() error {
64+
file, err := os.OpenFile(r.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
65+
if err != nil {
66+
return err
67+
}
68+
69+
info, err := file.Stat()
70+
if err != nil {
71+
file.Close()
72+
return err
73+
}
74+
75+
r.file = file
76+
r.size = info.Size()
77+
return nil
78+
}
79+
80+
func (r *RotatingFile) Write(p []byte) (int, error) {
81+
r.mu.Lock()
82+
defer r.mu.Unlock()
83+
84+
if r.size+int64(len(p)) > r.maxSize {
85+
if err := r.rotate(); err != nil {
86+
return 0, err
87+
}
88+
}
89+
90+
n, err := r.file.Write(p)
91+
r.size += int64(n)
92+
return n, err
93+
}
94+
95+
func (r *RotatingFile) Close() error {
96+
r.mu.Lock()
97+
defer r.mu.Unlock()
98+
99+
if r.file != nil {
100+
return r.file.Close()
101+
}
102+
return nil
103+
}
104+
105+
func (r *RotatingFile) rotate() error {
106+
if err := r.file.Close(); err != nil {
107+
return err
108+
}
109+
110+
// Remove the oldest backup if it exists
111+
oldest := fmt.Sprintf("%s.%d", r.path, r.maxBackups)
112+
_ = os.Remove(oldest)
113+
114+
// Shift existing backups: .2 -> .3, .1 -> .2, etc.
115+
for i := r.maxBackups - 1; i >= 1; i-- {
116+
oldPath := fmt.Sprintf("%s.%d", r.path, i)
117+
newPath := fmt.Sprintf("%s.%d", r.path, i+1)
118+
_ = os.Rename(oldPath, newPath)
119+
}
120+
121+
// Rename current log to .1
122+
if err := os.Rename(r.path, r.path+".1"); err != nil && !os.IsNotExist(err) {
123+
return err
124+
}
125+
126+
r.size = 0
127+
return r.openFile()
128+
}

pkg/logging/rotate_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package logging
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestRotatingFile_Write(t *testing.T) {
13+
dir := t.TempDir()
14+
path := filepath.Join(dir, "test.log")
15+
16+
rf, err := NewRotatingFile(path, WithMaxSize(100), WithMaxBackups(2))
17+
require.NoError(t, err)
18+
defer rf.Close()
19+
20+
data := []byte("hello world\n")
21+
n, err := rf.Write(data)
22+
require.NoError(t, err)
23+
assert.Equal(t, len(data), n)
24+
25+
content, err := os.ReadFile(path)
26+
require.NoError(t, err)
27+
assert.Equal(t, data, content)
28+
}
29+
30+
func TestRotatingFile_Rotate(t *testing.T) {
31+
dir := t.TempDir()
32+
path := filepath.Join(dir, "test.log")
33+
34+
rf, err := NewRotatingFile(path, WithMaxSize(50), WithMaxBackups(2))
35+
require.NoError(t, err)
36+
defer rf.Close()
37+
38+
// Write enough data to trigger rotation
39+
data := make([]byte, 30)
40+
for i := range data {
41+
data[i] = 'a'
42+
}
43+
44+
_, err = rf.Write(data)
45+
require.NoError(t, err)
46+
47+
// This write should trigger rotation
48+
_, err = rf.Write(data)
49+
require.NoError(t, err)
50+
51+
// Check that backup file was created
52+
_, err = os.Stat(path + ".1")
53+
require.NoError(t, err, "backup file should exist")
54+
55+
// Original file should have new content
56+
content, err := os.ReadFile(path)
57+
require.NoError(t, err)
58+
assert.Equal(t, data, content)
59+
60+
// Backup should have old content
61+
backup, err := os.ReadFile(path + ".1")
62+
require.NoError(t, err)
63+
assert.Equal(t, data, backup)
64+
}
65+
66+
func TestRotatingFile_MaxBackups(t *testing.T) {
67+
dir := t.TempDir()
68+
path := filepath.Join(dir, "test.log")
69+
70+
rf, err := NewRotatingFile(path, WithMaxSize(20), WithMaxBackups(2))
71+
require.NoError(t, err)
72+
defer rf.Close()
73+
74+
data := make([]byte, 15)
75+
76+
// Write 4 times to trigger multiple rotations
77+
for i := range 4 {
78+
for j := range data {
79+
data[j] = byte('a' + i)
80+
}
81+
_, err = rf.Write(data)
82+
require.NoError(t, err)
83+
}
84+
85+
// Should have current file + 2 backups (maxBackups=2)
86+
_, err = os.Stat(path)
87+
require.NoError(t, err, "current file should exist")
88+
89+
_, err = os.Stat(path + ".1")
90+
require.NoError(t, err, "backup .1 should exist")
91+
92+
_, err = os.Stat(path + ".2")
93+
require.NoError(t, err, "backup .2 should exist")
94+
95+
// .3 should NOT exist because maxBackups=2
96+
_, err = os.Stat(path + ".3")
97+
require.True(t, os.IsNotExist(err), "backup .3 should not exist")
98+
}
99+
100+
func TestRotatingFile_AppendsToExisting(t *testing.T) {
101+
dir := t.TempDir()
102+
path := filepath.Join(dir, "test.log")
103+
104+
// Create a file with existing content
105+
err := os.WriteFile(path, []byte("existing\n"), 0o600)
106+
require.NoError(t, err)
107+
108+
rf, err := NewRotatingFile(path, WithMaxSize(1000), WithMaxBackups(2))
109+
require.NoError(t, err)
110+
defer rf.Close()
111+
112+
_, err = rf.Write([]byte("new\n"))
113+
require.NoError(t, err)
114+
115+
content, err := os.ReadFile(path)
116+
require.NoError(t, err)
117+
assert.Equal(t, "existing\nnew\n", string(content))
118+
}
119+
120+
func TestRotatingFile_CreatesDirectory(t *testing.T) {
121+
dir := t.TempDir()
122+
path := filepath.Join(dir, "subdir", "nested", "test.log")
123+
124+
rf, err := NewRotatingFile(path)
125+
require.NoError(t, err)
126+
defer rf.Close()
127+
128+
_, err = rf.Write([]byte("test"))
129+
require.NoError(t, err)
130+
131+
_, err = os.Stat(path)
132+
require.NoError(t, err)
133+
}

0 commit comments

Comments
 (0)