1package logwriter
2
3import (
4 "bytes"
5 "context"
6 "io"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15)
16
17func TestNew(t *testing.T) {
18 t.Run("creates log directory", func(t *testing.T) {
19 dir := t.TempDir()
20 logDir := filepath.Join(dir, "containers")
21
22 writer, err := New(Config{
23 Dir: logDir,
24 MaxSize: 100,
25 MaxBackups: 3,
26 MaxAge: 28,
27 })
28
29 require.NoError(t, err)
30 require.NotNil(t, writer)
31 defer writer.Close()
32
33 // Verify directory was created
34 info, err := os.Stat(logDir)
35 require.NoError(t, err)
36 assert.True(t, info.IsDir())
37 })
38
39 t.Run("uses existing directory", func(t *testing.T) {
40 logDir := t.TempDir()
41
42 writer, err := New(Config{
43 Dir: logDir,
44 MaxSize: 100,
45 MaxBackups: 3,
46 MaxAge: 28,
47 })
48
49 require.NoError(t, err)
50 require.NotNil(t, writer)
51 defer writer.Close()
52 })
53}
54
55func TestStartLogging(t *testing.T) {
56 t.Run("creates log file and writes data", func(t *testing.T) {
57 logDir := t.TempDir()
58 writer, err := New(Config{
59 Dir: logDir,
60 MaxSize: 100,
61 MaxBackups: 3,
62 MaxAge: 28,
63 })
64 require.NoError(t, err)
65 defer writer.Close()
66
67 // Create a mock log stream with Docker multiplex format
68 // Format: 1 byte stream type, 3 bytes reserved, 4 bytes size (big endian), then data
69 logData := createDockerLogEntry(1, "Hello from container\n")
70 logStream := io.NopCloser(bytes.NewReader(logData))
71
72 err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream)
73 require.NoError(t, err)
74
75 // Wait for the goroutine to process
76 time.Sleep(100 * time.Millisecond)
77
78 // Verify log file was created
79 logFile := filepath.Join(logDir, "app_example_com.log")
80 content, err := os.ReadFile(logFile)
81 require.NoError(t, err)
82 assert.Contains(t, string(content), "Hello from container")
83 })
84
85 t.Run("replaces existing log stream for same container", func(t *testing.T) {
86 logDir := t.TempDir()
87 writer, err := New(Config{
88 Dir: logDir,
89 MaxSize: 100,
90 MaxBackups: 3,
91 MaxAge: 28,
92 })
93 require.NoError(t, err)
94 defer writer.Close()
95
96 // Start first logging
97 logData1 := createDockerLogEntry(1, "First log\n")
98 logStream1 := io.NopCloser(bytes.NewReader(logData1))
99 err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream1)
100 require.NoError(t, err)
101
102 time.Sleep(50 * time.Millisecond)
103
104 // Start second logging for same container
105 logData2 := createDockerLogEntry(1, "Second log\n")
106 logStream2 := io.NopCloser(bytes.NewReader(logData2))
107 err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream2)
108 require.NoError(t, err)
109
110 time.Sleep(100 * time.Millisecond)
111
112 // Should have both logs (file is appended)
113 logFile := filepath.Join(logDir, "app_example_com.log")
114 content, err := os.ReadFile(logFile)
115 require.NoError(t, err)
116 assert.Contains(t, string(content), "Second log")
117 })
118}
119
120func TestStopLogging(t *testing.T) {
121 t.Run("stops log collection", func(t *testing.T) {
122 logDir := t.TempDir()
123 writer, err := New(Config{
124 Dir: logDir,
125 MaxSize: 100,
126 MaxBackups: 3,
127 MaxAge: 28,
128 })
129 require.NoError(t, err)
130 defer writer.Close()
131
132 // Use a pipe so we can control the stream
133 pr, pw := io.Pipe()
134 err = writer.StartLogging(context.Background(), "container-123", "app.example.com", pr)
135 require.NoError(t, err)
136
137 // Write some data
138 go func() {
139 pw.Write(createDockerLogEntry(1, "Test log\n"))
140 // Keep pipe open until stopped
141 }()
142
143 time.Sleep(50 * time.Millisecond)
144
145 // Stop logging
146 err = writer.StopLogging("container-123")
147 require.NoError(t, err)
148
149 // Clean up pipe
150 pw.Close()
151 })
152
153 t.Run("noop for non-existent container", func(t *testing.T) {
154 logDir := t.TempDir()
155 writer, err := New(Config{
156 Dir: logDir,
157 MaxSize: 100,
158 MaxBackups: 3,
159 MaxAge: 28,
160 })
161 require.NoError(t, err)
162 defer writer.Close()
163
164 // Should not error
165 err = writer.StopLogging("non-existent")
166 require.NoError(t, err)
167 })
168}
169
170func TestClose(t *testing.T) {
171 t.Run("stops all log streams", func(t *testing.T) {
172 logDir := t.TempDir()
173 writer, err := New(Config{
174 Dir: logDir,
175 MaxSize: 100,
176 MaxBackups: 3,
177 MaxAge: 28,
178 })
179 require.NoError(t, err)
180
181 // Start multiple log streams
182 for i := 0; i < 3; i++ {
183 pr, pw := io.Pipe()
184 containerID := "container-" + string(rune('a'+i))
185 domain := "app" + string(rune('0'+i)) + ".example.com"
186 err := writer.StartLogging(context.Background(), containerID, domain, pr)
187 require.NoError(t, err)
188
189 // Keep pipe alive briefly
190 go func(pw *io.PipeWriter) {
191 time.Sleep(50 * time.Millisecond)
192 pw.Close()
193 }(pw)
194 }
195
196 time.Sleep(25 * time.Millisecond)
197
198 // Close should stop all
199 err = writer.Close()
200 require.NoError(t, err)
201
202 // Verify all streams are gone
203 assert.Empty(t, writer.streams)
204 })
205}
206
207func TestSanitizeDomain(t *testing.T) {
208 tests := []struct {
209 input string
210 expected string
211 }{
212 {"app.example.com", "app_example_com"},
213 {"api.v1.example.com", "api_v1_example_com"},
214 {"localhost:8080", "localhost_8080"},
215 {"app/service", "app_service"},
216 {"app with spaces", "app_with_spaces"},
217 {"simple", "simple"},
218 }
219
220 for _, tc := range tests {
221 t.Run(tc.input, func(t *testing.T) {
222 result := sanitizeDomain(tc.input)
223 assert.Equal(t, tc.expected, result)
224 })
225 }
226}
227
228func TestMultipleContainers(t *testing.T) {
229 logDir := t.TempDir()
230 writer, err := New(Config{
231 Dir: logDir,
232 MaxSize: 100,
233 MaxBackups: 3,
234 MaxAge: 28,
235 })
236 require.NoError(t, err)
237 defer writer.Close()
238
239 // Start logging for multiple containers
240 containers := []struct {
241 id string
242 domain string
243 msg string
244 }{
245 {"container-1", "app1.example.com", "Log from app1"},
246 {"container-2", "app2.example.com", "Log from app2"},
247 {"container-3", "api.example.com", "Log from api"},
248 }
249
250 for _, c := range containers {
251 logData := createDockerLogEntry(1, c.msg+"\n")
252 logStream := io.NopCloser(bytes.NewReader(logData))
253 err := writer.StartLogging(context.Background(), c.id, c.domain, logStream)
254 require.NoError(t, err)
255 }
256
257 // Wait for processing
258 time.Sleep(100 * time.Millisecond)
259
260 // Verify each container has its own log file
261 for _, c := range containers {
262 filename := strings.ReplaceAll(c.domain, ".", "_") + ".log"
263 logFile := filepath.Join(logDir, filename)
264 content, err := os.ReadFile(logFile)
265 require.NoError(t, err, "Log file should exist for %s", c.domain)
266 assert.Contains(t, string(content), c.msg)
267 }
268}
269
270// createDockerLogEntry creates a Docker multiplexed log entry.
271// streamType: 0=stdin, 1=stdout, 2=stderr
272func createDockerLogEntry(streamType byte, data string) []byte {
273 size := len(data)
274 header := make([]byte, 8)
275 header[0] = streamType
276 // bytes 1-3 are reserved (zeros)
277 header[4] = byte(size >> 24)
278 header[5] = byte(size >> 16)
279 header[6] = byte(size >> 8)
280 header[7] = byte(size)
281
282 return append(header, []byte(data)...)
283}