1// Package logs implements the log access use case.
2package logs
3
4import (
5 "bufio"
6 "context"
7 "encoding/binary"
8 "fmt"
9 "io"
10 "os"
11 "strings"
12 "time"
13
14 "github.com/bnema/zerowrap"
15
16 "github.com/bnema/gordon/internal/boundaries/in"
17 "github.com/bnema/gordon/internal/boundaries/out"
18)
19
20// Service implements the LogService interface.
21type Service struct {
22 logFilePath string
23 containerSvc in.ContainerService
24 runtime out.ContainerRuntime
25 log zerowrap.Logger
26}
27
28// NewService creates a new log service.
29func NewService(
30 logFilePath string,
31 containerSvc in.ContainerService,
32 runtime out.ContainerRuntime,
33 log zerowrap.Logger,
34) *Service {
35 return &Service{
36 logFilePath: logFilePath,
37 containerSvc: containerSvc,
38 runtime: runtime,
39 log: log,
40 }
41}
42
43// GetProcessLogs returns the last N lines of Gordon process logs.
44func (s *Service) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
45 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
46 zerowrap.FieldLayer: "usecase",
47 zerowrap.FieldUseCase: "GetProcessLogs",
48 "lines": lines,
49 })
50 log := zerowrap.FromCtx(ctx)
51
52 if s.logFilePath == "" {
53 return nil, fmt.Errorf("log file path not configured")
54 }
55
56 file, err := os.Open(s.logFilePath)
57 if err != nil {
58 if os.IsNotExist(err) {
59 return []string{}, nil
60 }
61 return nil, log.WrapErr(err, "failed to open log file")
62 }
63 defer file.Close()
64
65 return tailLines(file, lines)
66}
67
68// FollowProcessLogs returns a channel that streams Gordon process log lines.
69func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-chan string, error) {
70 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
71 zerowrap.FieldLayer: "usecase",
72 zerowrap.FieldUseCase: "FollowProcessLogs",
73 "initial_lines": initialLines,
74 })
75 log := zerowrap.FromCtx(ctx)
76
77 if s.logFilePath == "" {
78 return nil, fmt.Errorf("log file path not configured")
79 }
80
81 file, err := os.Open(s.logFilePath)
82 if err != nil {
83 return nil, log.WrapErr(err, "failed to open log file")
84 }
85
86 ch := make(chan string, 100)
87
88 go func() {
89 defer close(ch)
90 defer file.Close()
91
92 // First, get initial lines
93 if initialLines > 0 {
94 lines, err := tailLines(file, initialLines)
95 if err != nil {
96 log.Warn().Err(err).Msg("failed to read initial lines")
97 } else {
98 for _, line := range lines {
99 select {
100 case ch <- line:
101 case <-ctx.Done():
102 return
103 }
104 }
105 }
106 }
107
108 // Seek to end for following
109 if _, err := file.Seek(0, io.SeekEnd); err != nil {
110 log.Warn().Err(err).Msg("failed to seek to end")
111 return
112 }
113
114 // Follow new lines
115 reader := bufio.NewReader(file)
116 for {
117 select {
118 case <-ctx.Done():
119 return
120 default:
121 line, err := reader.ReadString('\n')
122 if err != nil {
123 if err == io.EOF {
124 time.Sleep(100 * time.Millisecond)
125 continue
126 }
127 log.Warn().Err(err).Msg("error reading log file")
128 return
129 }
130 line = strings.TrimRight(line, "\n\r")
131 select {
132 case ch <- line:
133 case <-ctx.Done():
134 return
135 }
136 }
137 }
138 }()
139
140 return ch, nil
141}
142
143// validateDomain checks that domain is safe for use in logs and error messages.
144func validateDomain(domain string) error {
145 if domain == "" {
146 return fmt.Errorf("domain cannot be empty")
147 }
148 if len(domain) > 253 {
149 return fmt.Errorf("domain too long")
150 }
151 if strings.Contains(domain, "..") {
152 return fmt.Errorf("invalid domain")
153 }
154 if strings.ContainsAny(domain, "\x00\n\r") {
155 return fmt.Errorf("invalid domain")
156 }
157 return nil
158}
159
160// GetContainerLogs returns the last N lines of container logs for a domain.
161func (s *Service) GetContainerLogs(ctx context.Context, domain string, lines int) ([]string, error) {
162 if err := validateDomain(domain); err != nil {
163 return nil, err
164 }
165
166 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
167 zerowrap.FieldLayer: "usecase",
168 zerowrap.FieldUseCase: "GetContainerLogs",
169 "domain": domain,
170 "lines": lines,
171 })
172 log := zerowrap.FromCtx(ctx)
173
174 // Get container by domain
175 container, ok := s.containerSvc.Get(ctx, domain)
176 if !ok || container == nil {
177 return nil, fmt.Errorf("container not found for domain: %s", domain)
178 }
179
180 // Get logs from container runtime (non-follow mode)
181 reader, err := s.runtime.GetContainerLogs(ctx, container.ID, false)
182 if err != nil {
183 return nil, log.WrapErr(err, "failed to get container logs")
184 }
185 defer reader.Close()
186
187 // Parse Docker log stream and get lines
188 logLines, err := parseDockerLogs(reader)
189 if err != nil {
190 return nil, log.WrapErr(err, "failed to parse container logs")
191 }
192
193 // Return last N lines
194 if len(logLines) > lines {
195 logLines = logLines[len(logLines)-lines:]
196 }
197
198 return logLines, nil
199}
200
201// FollowContainerLogs returns a channel that streams container log lines.
202func (s *Service) FollowContainerLogs(ctx context.Context, domain string, initialLines int) (<-chan string, error) {
203 if err := validateDomain(domain); err != nil {
204 return nil, err
205 }
206
207 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
208 zerowrap.FieldLayer: "usecase",
209 zerowrap.FieldUseCase: "FollowContainerLogs",
210 "domain": domain,
211 "initial_lines": initialLines,
212 })
213 log := zerowrap.FromCtx(ctx)
214
215 // Get container by domain
216 container, ok := s.containerSvc.Get(ctx, domain)
217 if !ok || container == nil {
218 return nil, fmt.Errorf("container not found for domain: %s", domain)
219 }
220
221 // Get logs from container runtime (follow mode)
222 reader, err := s.runtime.GetContainerLogs(ctx, container.ID, true)
223 if err != nil {
224 return nil, log.WrapErr(err, "failed to get container logs")
225 }
226
227 ch := make(chan string, 100)
228
229 go func() {
230 defer close(ch)
231 defer reader.Close()
232
233 // Stream Docker logs
234 err := streamDockerLogs(ctx, reader, ch)
235 if err != nil && ctx.Err() == nil {
236 log.Warn().Err(err).Msg("error streaming container logs")
237 }
238 }()
239
240 return ch, nil
241}
242
243// tailLines reads the last N lines from a file using a ring buffer.
244func tailLines(file *os.File, n int) ([]string, error) {
245 if n <= 0 {
246 return []string{}, nil
247 }
248
249 // Seek to beginning
250 if _, err := file.Seek(0, io.SeekStart); err != nil {
251 return nil, err
252 }
253
254 scanner := bufio.NewScanner(file)
255 buffer := make([]string, n)
256 index := 0
257 total := 0
258
259 for scanner.Scan() {
260 buffer[index] = scanner.Text()
261 index = (index + 1) % n
262 total++
263 }
264
265 if err := scanner.Err(); err != nil {
266 return nil, err
267 }
268
269 if total == 0 {
270 return []string{}, nil
271 }
272
273 // Build result in correct order
274 var result []string
275 if total <= n {
276 result = make([]string, total)
277 for i := 0; i < total; i++ {
278 result[i] = buffer[i]
279 }
280 } else {
281 result = make([]string, n)
282 for i := 0; i < n; i++ {
283 result[i] = buffer[(index+i)%n]
284 }
285 }
286
287 return result, nil
288}
289
290// parseDockerLogs parses Docker multiplexed log stream and returns lines.
291// Docker logs have an 8-byte header: [stream type (1), padding (3), size (4)]
292func parseDockerLogs(reader io.Reader) ([]string, error) {
293 var lines []string
294 header := make([]byte, 8)
295
296 for {
297 // Read header
298 _, err := io.ReadFull(reader, header)
299 if err != nil {
300 if err == io.EOF {
301 break
302 }
303 return lines, nil // Return what we have
304 }
305
306 // Get payload size from header (big endian)
307 size := binary.BigEndian.Uint32(header[4:8])
308 if size == 0 {
309 continue
310 }
311
312 // Read payload
313 payload := make([]byte, size)
314 _, err = io.ReadFull(reader, payload)
315 if err != nil {
316 if err == io.EOF {
317 break
318 }
319 return lines, nil
320 }
321
322 // Split by newlines and add to result
323 content := strings.TrimRight(string(payload), "\n\r")
324 for _, line := range strings.Split(content, "\n") {
325 if line != "" {
326 lines = append(lines, line)
327 }
328 }
329 }
330
331 return lines, nil
332}
333
334// streamDockerLogs streams Docker multiplexed log stream to a channel.
335func streamDockerLogs(ctx context.Context, reader io.Reader, ch chan<- string) error {
336 header := make([]byte, 8)
337
338 for {
339 select {
340 case <-ctx.Done():
341 return ctx.Err()
342 default:
343 }
344
345 // Read header
346 _, err := io.ReadFull(reader, header)
347 if err != nil {
348 if err == io.EOF {
349 return nil
350 }
351 return err
352 }
353
354 // Get payload size
355 size := binary.BigEndian.Uint32(header[4:8])
356 if size == 0 {
357 continue
358 }
359
360 // Read payload
361 payload := make([]byte, size)
362 _, err = io.ReadFull(reader, payload)
363 if err != nil {
364 if err == io.EOF {
365 return nil
366 }
367 return err
368 }
369
370 // Split by newlines and send to channel
371 content := strings.TrimRight(string(payload), "\n\r")
372 for _, line := range strings.Split(content, "\n") {
373 if line != "" {
374 select {
375 case ch <- line:
376 case <-ctx.Done():
377 return ctx.Err()
378 }
379 }
380 }
381 }
382}