Skip to content

Commit 9417c2d

Browse files
Apurv SirohiGitHub Enterprise
authored andcommitted
Add logrotation package (#900)
* Add logrotation package
1 parent 8037183 commit 9417c2d

File tree

2 files changed

+378
-0
lines changed

2 files changed

+378
-0
lines changed

pkg/logrotation/logrotation.go

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/*
2+
© Copyright IBM Corporation 2025
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Package logrotation contains code to manage the logrotation and append-log logic for logs
18+
package logrotation
19+
20+
import (
21+
"fmt"
22+
"io"
23+
"os"
24+
"slices"
25+
"strings"
26+
"sync"
27+
)
28+
29+
type RotatingLogger struct {
30+
basePath string
31+
maxFileSize int
32+
logFilesCount int
33+
lock sync.Mutex
34+
}
35+
36+
// NewRotatingLogger create a new RotatingLogger, it expects three input parameters,
37+
// basePath is the log-file path prefix,
38+
// maxFileSize is the max allowed log-file size in bytes,
39+
// logFilesCount is the number of log files required to be created.
40+
func NewRotatingLogger(basePath string, maxFileSize, logFilesCount int) *RotatingLogger {
41+
return &RotatingLogger{
42+
basePath: basePath,
43+
maxFileSize: maxFileSize,
44+
logFilesCount: logFilesCount,
45+
}
46+
}
47+
48+
// instanceFileName returns a log instance filename
49+
func (r *RotatingLogger) instanceFileName(instance int) string {
50+
return fmt.Sprintf("%s-%d.log", r.basePath, instance)
51+
}
52+
53+
// Init creates log files
54+
func (r *RotatingLogger) Init() error {
55+
for i := 1; i <= r.logFilesCount; i++ {
56+
err := os.WriteFile(r.instanceFileName(i), []byte(""), 0660)
57+
if err != nil {
58+
return err
59+
}
60+
}
61+
62+
return nil
63+
64+
}
65+
66+
// Append appends the message line to the logFile and if the logFile size exceeds the maxFileSize then perform log-rotation.
67+
// messageLine is the log message that we need to append to the log-file,
68+
// if deduplicateLine is false the messageLine will always be appended,
69+
// if deduplicateLine is true messageLine will only be appended if it is different from the last line in the logfile.
70+
func (r *RotatingLogger) Append(messageLine string, deduplicateLine bool) {
71+
r.lock.Lock()
72+
defer r.lock.Unlock()
73+
74+
// we will always log in the first instance of the log files
75+
logFilePath := r.instanceFileName(1)
76+
77+
// open the log file in append mode
78+
// for the gosec rule Id: G302 - Expect file permissions to be 0600 or less
79+
logFile, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
80+
if err != nil {
81+
fmt.Printf("Failed to open log file: %v\n", err)
82+
return
83+
}
84+
85+
defer func(f *os.File) {
86+
if err := logFile.Close(); err != nil {
87+
fmt.Printf("Error: %v, Failed to close log file: %s\n", err, logFilePath)
88+
}
89+
}(logFile)
90+
91+
// check if the message should be appended to the file
92+
shouldBeAppended, err := r.checkIfMessageShouldBeAppended(logFilePath, messageLine, deduplicateLine)
93+
if err != nil {
94+
fmt.Printf("Failed to validate the currentLog and the lastLog line %v\n", err)
95+
}
96+
97+
if !shouldBeAppended {
98+
return
99+
}
100+
101+
// check if the logFileSize has exceeded the maxFileSize then perform the logrotation
102+
logFileSizeExceeded, err := r.checkIfLogFileSizeExceeded(len(messageLine), logFile)
103+
if err != nil {
104+
fmt.Printf("Failed to validate log file size: %v\n", err)
105+
return
106+
}
107+
108+
if logFileSizeExceeded {
109+
110+
// close the current log file
111+
err = logFile.Close()
112+
if err != nil {
113+
fmt.Printf("Error: %v, Failed to close log file: %v\n", err, logFile.Name())
114+
}
115+
116+
// perform log rotation
117+
err = r.performLogRotation()
118+
if err != nil {
119+
fmt.Printf("Failed to perform log-rotation: %v\n", err)
120+
}
121+
122+
// open the newly created logFile
123+
logFile, err = os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
124+
if err != nil {
125+
fmt.Printf("Failed to open log file: %v\n", err)
126+
return
127+
}
128+
129+
defer func(f *os.File) {
130+
if err := logFile.Close(); err != nil {
131+
fmt.Printf("Error: %v, Failed to close log file: %s\n", err, logFilePath)
132+
}
133+
}(logFile)
134+
}
135+
136+
// append the message to the file
137+
_, err = logFile.WriteString(messageLine)
138+
if err != nil {
139+
fmt.Printf("Failed to write to log file: %v\n", err)
140+
}
141+
142+
}
143+
144+
func (r *RotatingLogger) performLogRotation() error {
145+
146+
// delete the last log file
147+
lastLogFile := r.instanceFileName(r.logFilesCount)
148+
if err := os.Remove(lastLogFile); err != nil && !os.IsNotExist(err) {
149+
return fmt.Errorf("error deleting the %d instance of log-file: %s", r.logFilesCount, r.basePath)
150+
}
151+
152+
// rename the remaining instances of the log-files
153+
for i := r.logFilesCount; i >= 2; i-- {
154+
oldLogFileInstance := r.instanceFileName(i - 1)
155+
newLogFileInstance := r.instanceFileName(i)
156+
157+
if err := os.Rename(oldLogFileInstance, newLogFileInstance); err != nil && !os.IsNotExist(err) {
158+
return fmt.Errorf("error renaming %d instance to %d of log-file: %s", (i - 1), i, r.basePath)
159+
}
160+
}
161+
162+
// create the first log-file
163+
if err := os.WriteFile(r.instanceFileName(1), []byte(""), 0660); err != nil {
164+
return fmt.Errorf("error creating the first instance of the log-file: %s", r.basePath)
165+
}
166+
167+
return nil
168+
169+
}
170+
171+
func (r *RotatingLogger) checkIfLogFileSizeExceeded(messageLineLength int, logFile *os.File) (bool, error) {
172+
173+
fileStat, err := logFile.Stat()
174+
if err != nil {
175+
return false, err
176+
}
177+
178+
return ((fileStat.Size() + int64(messageLineLength)) >= int64(r.maxFileSize)), nil
179+
180+
}
181+
182+
func (r *RotatingLogger) checkIfMessageShouldBeAppended(logFilePath, currentLogLine string, deduplicateLine bool) (bool, error) {
183+
184+
if !deduplicateLine {
185+
return true, nil
186+
}
187+
188+
lastLogLine, err := r.getLogLastLine(logFilePath)
189+
190+
if err != nil {
191+
return false, err
192+
}
193+
194+
cleanedCurrentLogLine := strings.ReplaceAll(strings.TrimSpace(currentLogLine), "\n", " ")
195+
cleanedLastLogLine := strings.ReplaceAll(strings.TrimSpace(lastLogLine), "\n", " ")
196+
197+
if cleanedCurrentLogLine != cleanedLastLogLine {
198+
return true, nil
199+
} else {
200+
return false, nil
201+
}
202+
203+
}
204+
205+
func (r *RotatingLogger) getLogLastLine(logFilePath string) (string, error) {
206+
207+
logFile, err := os.Open(logFilePath)
208+
209+
if err != nil {
210+
return "", err
211+
}
212+
213+
defer func() {
214+
if err := logFile.Close(); err != nil {
215+
fmt.Printf("error closing logfile: %s", logFilePath)
216+
}
217+
}()
218+
219+
lineCharsBackwards := []byte{}
220+
char := make([]byte, 1)
221+
222+
for pos, err := logFile.Seek(0, io.SeekEnd); pos > 0; pos, err = logFile.Seek(-1, io.SeekCurrent) {
223+
224+
if err != nil {
225+
return "", fmt.Errorf("seek failed: %w", err)
226+
}
227+
228+
n, err := logFile.ReadAt(char, pos-1)
229+
if err != nil {
230+
return "", fmt.Errorf("read failed: %w", err)
231+
}
232+
233+
if n < 1 {
234+
return "", fmt.Errorf("unexpectedly read 0 bytes")
235+
}
236+
237+
if char[0] == '\n' || char[0] == '\r' {
238+
if len(lineCharsBackwards) > 0 {
239+
break
240+
}
241+
continue
242+
}
243+
244+
lineCharsBackwards = append(lineCharsBackwards, char...)
245+
246+
}
247+
248+
// reverse the slice in place
249+
slices.Reverse(lineCharsBackwards)
250+
251+
return string(lineCharsBackwards), nil
252+
253+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package logrotation
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
)
9+
10+
func TestInit(t *testing.T) {
11+
12+
// create a temporary directory
13+
rotatingLogger := createRotatingLogger(t)
14+
15+
if err := rotatingLogger.Init(); err != nil {
16+
t.Fatalf("RotatingLogger Init() failed with error: %v", err)
17+
}
18+
19+
// verify if logFilesCount number of files were created
20+
for i := 1; i <= rotatingLogger.logFilesCount; i++ {
21+
path := rotatingLogger.instanceFileName(i)
22+
_, err := os.Stat(path)
23+
if err != nil {
24+
t.Fatalf("expected %q to exist, got error %v", path, err)
25+
}
26+
}
27+
28+
}
29+
30+
func TestIfMessageShouldBeAppended(t *testing.T) {
31+
32+
rotatingLogger := createRotatingLogger(t)
33+
34+
if err := rotatingLogger.Init(); err != nil {
35+
t.Fatalf("RotatingLogger Init() failed with error: %v", err)
36+
}
37+
38+
logFile := rotatingLogger.instanceFileName(1)
39+
40+
messages := []struct {
41+
num int
42+
messageLine string
43+
fileSeed string
44+
deduplicateLine bool
45+
expectedShouldBeAppended bool
46+
}{
47+
{1, "Log Message", "", true, true},
48+
{2, "Log Message", "Log Message", true, false},
49+
{3, "Error Message", "Log Message", false, true},
50+
{4, "Error Message", "Error Message", false, true},
51+
{5, "Log Message", "Error Message", true, true},
52+
}
53+
54+
for _, message := range messages {
55+
56+
// write the fileSeed to the file
57+
if err := os.WriteFile(logFile, []byte(message.fileSeed), 0660); err != nil {
58+
t.Fatalf("error wrtitng %v to logfile: %v, received error: %v", message.fileSeed, logFile, err)
59+
}
60+
61+
shouldBeAppended, _ := rotatingLogger.checkIfMessageShouldBeAppended(logFile, message.messageLine, message.deduplicateLine)
62+
63+
if shouldBeAppended != message.expectedShouldBeAppended {
64+
t.Fatalf("test:%d failed, expected whether the line should be appended as: %v, but got: %v", message.num, message.expectedShouldBeAppended, shouldBeAppended)
65+
}
66+
}
67+
68+
}
69+
70+
func TestLogRotation(t *testing.T) {
71+
72+
// create a temporary directory
73+
rotatingLogger := createRotatingLogger(t)
74+
75+
if err := rotatingLogger.Init(); err != nil {
76+
t.Fatalf("RotatingLogger Init() failed with error: %v", err)
77+
}
78+
79+
// write data in the files
80+
fileData := make([]string, 0, rotatingLogger.logFilesCount)
81+
82+
for i := 1; i <= rotatingLogger.logFilesCount; i++ {
83+
content := []byte(fmt.Sprintf("data-%d", i))
84+
fileData = append(fileData, string(content))
85+
logFile := rotatingLogger.instanceFileName(i)
86+
if err := os.WriteFile(logFile, content, 0660); err != nil {
87+
t.Fatalf("error wrtitng %v to logfile: %v, received error: %v", content, logFile, err)
88+
}
89+
}
90+
91+
// perform log-rtoation
92+
if err := rotatingLogger.performLogRotation(); err != nil {
93+
t.Fatalf("error performing log-rotation: %v", err)
94+
}
95+
96+
// the first file should be empty, all other files should have content of one file previous to them
97+
for i := 1; i <= rotatingLogger.logFilesCount; i++ {
98+
logFile := rotatingLogger.instanceFileName(i)
99+
100+
data, err := os.ReadFile(logFile)
101+
if err != nil {
102+
t.Errorf("expected %s to exist, got error: %v", logFile, err)
103+
}
104+
105+
received := string(data)
106+
107+
var expected string
108+
if i == 1 {
109+
expected = ""
110+
} else {
111+
expected = fileData[i-2]
112+
}
113+
114+
if received != expected {
115+
t.Fatalf("expected %v, but receieved %v for file: %v", expected, received, logFile)
116+
}
117+
}
118+
119+
}
120+
121+
func createRotatingLogger(t *testing.T) *RotatingLogger {
122+
dir := t.TempDir()
123+
124+
return NewRotatingLogger(filepath.Join(dir, "log"), 100, 3)
125+
}

0 commit comments

Comments
 (0)