1// Package logs implements the log access use case.
2package logs
3
4import (
5 "bufio"
6 "bytes"
7 "context"
8 "encoding/binary"
9 "fmt"
10 "io"
11 "os"
12 "os/exec"
13 "strconv"
14 "strings"
15 "time"
16
17 "github.com/bnema/zerowrap"
18
19 "github.com/bnema/gordon/internal/boundaries/in"
20 "github.com/bnema/gordon/internal/boundaries/out"
21)
22
23// Service implements the LogService interface.
24type Service struct {
25 logFilePath string
26 fileLoggingEnabled bool
27 containerSvc in.ContainerService
28 runtime out.ContainerRuntime
29 log zerowrap.Logger
30}
31
32var execCommandContext = exec.CommandContext
33
34// NewService creates a new log service.
35func NewService(
36 logFilePath string,
37 fileLoggingEnabled bool,
38 containerSvc in.ContainerService,
39 runtime out.ContainerRuntime,
40 log zerowrap.Logger,
41) *Service {
42 return &Service{
43 logFilePath: logFilePath,
44 fileLoggingEnabled: fileLoggingEnabled,
45 containerSvc: containerSvc,
46 runtime: runtime,
47 log: log,
48 }
49}
50
51// GetProcessLogs returns the last N lines of Gordon process logs.
52func (s *Service) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
53 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
54 zerowrap.FieldLayer: "usecase",
55 zerowrap.FieldUseCase: "GetProcessLogs",
56 "lines": lines,
57 })
58 log := zerowrap.FromCtx(ctx)
59
60 if s.useFileProcessLogs() {
61 if s.logFilePath == "" {
62 return nil, fmt.Errorf("log file path not configured")
63 }
64
65 file, err := os.Open(s.logFilePath)
66 if err != nil {
67 if os.IsNotExist(err) {
68 log.Warn().Str("log_path", s.logFilePath).
69 Msg("process log file missing, falling back to journalctl")
70 return s.getProcessLogsFromJournal(ctx, lines)
71 }
72 return nil, log.WrapErr(err, "failed to open log file")
73 }
74 defer file.Close()
75
76 return tailLines(file, lines)
77 }
78
79 return s.getProcessLogsFromJournal(ctx, lines)
80}
81
82// FollowProcessLogs returns a channel that streams Gordon process log lines.
83func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-chan string, error) {
84 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
85 zerowrap.FieldLayer: "usecase",
86 zerowrap.FieldUseCase: "FollowProcessLogs",
87 "initial_lines": initialLines,
88 })
89 if !s.useFileProcessLogs() {
90 return s.followProcessLogsFromJournal(ctx, initialLines)
91 }
92
93 return s.followProcessLogsFromFileOrJournal(ctx, initialLines)
94}
95
96func (s *Service) useFileProcessLogs() bool {
97 return s.fileLoggingEnabled
98}
99
100func (s *Service) followProcessLogsFromFileOrJournal(
101 ctx context.Context,
102 initialLines int,
103) (<-chan string, error) {
104 log := zerowrap.FromCtx(ctx)
105
106 file, err := s.openProcessLogFile()
107 if err != nil {
108 if os.IsNotExist(err) {
109 log.Warn().Str("log_path", s.logFilePath).
110 Msg("process log file missing, falling back to journalctl follow")
111 return s.followProcessLogsFromJournal(ctx, initialLines)
112 }
113 return nil, log.WrapErr(err, "failed to open log file")
114 }
115
116 return s.followProcessLogsFromFile(ctx, file, initialLines), nil
117}
118
119func (s *Service) openProcessLogFile() (*os.File, error) {
120 if s.logFilePath == "" {
121 return nil, fmt.Errorf("log file path not configured")
122 }
123
124 return os.Open(s.logFilePath)
125}
126
127func (s *Service) followProcessLogsFromFile(
128 ctx context.Context,
129 file *os.File,
130 initialLines int,
131) <-chan string {
132 log := zerowrap.FromCtx(ctx)
133 ch := make(chan string, 100)
134
135 go func() {
136 defer close(ch)
137 defer file.Close()
138
139 if !s.emitInitialFileLogs(ctx, file, initialLines, ch) {
140 return
141 }
142 if err := s.seekToEndForFileFollow(file); err != nil {
143 log.Warn().Err(err).Msg("failed to seek to end")
144 return
145 }
146
147 s.streamFileFollowLogs(ctx, file, ch)
148 }()
149
150 return ch
151}
152
153func (s *Service) emitInitialFileLogs(
154 ctx context.Context,
155 file *os.File,
156 initialLines int,
157 ch chan<- string,
158) bool {
159 if initialLines <= 0 {
160 return true
161 }
162
163 log := zerowrap.FromCtx(ctx)
164 lines, err := tailLines(file, initialLines)
165 if err != nil {
166 log.Warn().Err(err).Msg("failed to read initial lines")
167 return true
168 }
169
170 for _, line := range lines {
171 if !sendLogLineWithChannel(ctx, ch, line) {
172 return false
173 }
174 }
175
176 return true
177}
178
179func (s *Service) seekToEndForFileFollow(file *os.File) error {
180 _, err := file.Seek(0, io.SeekEnd)
181 return err
182}
183
184func (s *Service) streamFileFollowLogs(ctx context.Context, file *os.File, ch chan<- string) {
185 log := zerowrap.FromCtx(ctx)
186 reader := bufio.NewReader(file)
187
188 for {
189 if ctx.Err() != nil {
190 return
191 }
192
193 line, err := reader.ReadString('\n')
194 if err != nil {
195 if err == io.EOF {
196 time.Sleep(100 * time.Millisecond)
197 continue
198 }
199 log.Warn().Err(err).Msg("error reading log file")
200 return
201 }
202
203 line = strings.TrimRight(line, "\n\r")
204 if !sendLogLineWithChannel(ctx, ch, line) {
205 return
206 }
207 }
208}
209
210func sendLogLineWithChannel(ctx context.Context, ch chan<- string, line string) bool {
211 select {
212 case ch <- line:
213 return true
214 case <-ctx.Done():
215 return false
216 }
217}
218
219func (s *Service) getProcessLogsFromJournal(ctx context.Context, lines int) ([]string, error) {
220 userScope, err := resolveJournalctlScope(ctx)
221 if err != nil {
222 return nil, err
223 }
224
225 args := []string{"-u", "gordon", "-n", strconv.Itoa(lines), "--no-pager", "-o", "cat"}
226 if userScope {
227 args = append([]string{"--user"}, args...)
228 }
229
230 out, err := execCommandContext(ctx, "journalctl", args...).Output()
231 if err != nil {
232 return nil, fmt.Errorf("failed to read journalctl logs: %w", err)
233 }
234
235 return outputLines(out), nil
236}
237
238func (s *Service) followProcessLogsFromJournal(ctx context.Context, initialLines int) (<-chan string, error) {
239 userScope, err := resolveJournalctlScope(ctx)
240 if err != nil {
241 return nil, err
242 }
243
244 args := []string{
245 "-u", "gordon",
246 "-n", strconv.Itoa(initialLines),
247 "--no-pager",
248 "-o", "cat",
249 "-f",
250 }
251 if userScope {
252 args = append([]string{"--user"}, args...)
253 }
254
255 cmd := execCommandContext(ctx, "journalctl", args...) // #nosec G204 -- internal args only
256 stdout, err := cmd.StdoutPipe()
257 if err != nil {
258 return nil, fmt.Errorf("failed to open journalctl stdout: %w", err)
259 }
260 cmd.Stderr = io.Discard
261
262 if err := cmd.Start(); err != nil {
263 return nil, fmt.Errorf("failed to start journalctl follow: %w", err)
264 }
265
266 ch := make(chan string, 100)
267
268 go func() {
269 defer close(ch)
270 defer stdout.Close()
271 defer cmd.Wait() //nolint:errcheck // best-effort cleanup
272
273 scanner := bufio.NewScanner(stdout)
274 scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
275
276 for scanner.Scan() {
277 select {
278 case ch <- scanner.Text():
279 case <-ctx.Done():
280 return
281 }
282 }
283 }()
284
285 return ch, nil
286}
287
288func resolveJournalctlScope(ctx context.Context) (bool, error) {
289 userLogs, userErr := hasJournalctlLogs(ctx, true)
290 if userErr == nil && userLogs {
291 return true, nil
292 }
293
294 systemLogs, systemErr := hasJournalctlLogs(ctx, false)
295 if systemErr == nil && systemLogs {
296 return false, nil
297 }
298
299 if userErr != nil && systemErr != nil {
300 return false, fmt.Errorf("journalctl unavailable for user and system services")
301 }
302
303 // Default to user scope when logs are currently empty; this matches rootless setups.
304 return true, nil
305}
306
307func hasJournalctlLogs(ctx context.Context, userService bool) (bool, error) {
308 args := []string{"-u", "gordon", "-n", "1", "--no-pager", "-q", "-o", "cat"}
309 if userService {
310 args = append([]string{"--user"}, args...)
311 }
312
313 out, err := execCommandContext(ctx, "journalctl", args...).Output()
314 if err != nil {
315 return false, err
316 }
317
318 return len(bytes.TrimSpace(out)) > 0, nil
319}
320
321func outputLines(out []byte) []string {
322 trimmed := strings.TrimRight(string(out), "\n\r")
323 if trimmed == "" {
324 return []string{}
325 }
326 return strings.Split(trimmed, "\n")
327}
328
329// validateDomain checks that domain is safe for use in logs and error messages.
330func validateDomain(domain string) error {
331 if domain == "" {
332 return fmt.Errorf("domain cannot be empty")
333 }
334 if len(domain) > 253 {
335 return fmt.Errorf("domain too long")
336 }
337 if strings.Contains(domain, "..") {
338 return fmt.Errorf("invalid domain")
339 }
340 if strings.ContainsAny(domain, "\x00\n\r") {
341 return fmt.Errorf("invalid domain")
342 }
343 return nil
344}
345
346// GetContainerLogs returns the last N lines of container logs for a domain.
347func (s *Service) GetContainerLogs(ctx context.Context, domain string, lines int) ([]string, error) {
348 if err := validateDomain(domain); err != nil {
349 return nil, err
350 }
351
352 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
353 zerowrap.FieldLayer: "usecase",
354 zerowrap.FieldUseCase: "GetContainerLogs",
355 "domain": domain,
356 "lines": lines,
357 })
358 log := zerowrap.FromCtx(ctx)
359
360 // Get container by domain
361 container, ok := s.containerSvc.Get(ctx, domain)
362 if !ok || container == nil {
363 return nil, fmt.Errorf("container not found for domain: %s", domain)
364 }
365
366 // Get logs from container runtime (non-follow mode)
367 reader, err := s.runtime.GetContainerLogs(ctx, container.ID, false)
368 if err != nil {
369 return nil, log.WrapErr(err, "failed to get container logs")
370 }
371 defer reader.Close()
372
373 // Parse Docker log stream and get lines
374 logLines, err := parseDockerLogs(reader)
375 if err != nil {
376 return nil, log.WrapErr(err, "failed to parse container logs")
377 }
378
379 // Return last N lines
380 if len(logLines) > lines {
381 logLines = logLines[len(logLines)-lines:]
382 }
383
384 return logLines, nil
385}
386
387// FollowContainerLogs returns a channel that streams container log lines.
388func (s *Service) FollowContainerLogs(ctx context.Context, domain string, initialLines int) (<-chan string, error) {
389 if err := validateDomain(domain); err != nil {
390 return nil, err
391 }
392
393 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
394 zerowrap.FieldLayer: "usecase",
395 zerowrap.FieldUseCase: "FollowContainerLogs",
396 "domain": domain,
397 "initial_lines": initialLines,
398 })
399 log := zerowrap.FromCtx(ctx)
400
401 // Get container by domain
402 container, ok := s.containerSvc.Get(ctx, domain)
403 if !ok || container == nil {
404 return nil, fmt.Errorf("container not found for domain: %s", domain)
405 }
406
407 // Get logs from container runtime (follow mode)
408 reader, err := s.runtime.GetContainerLogs(ctx, container.ID, true)
409 if err != nil {
410 return nil, log.WrapErr(err, "failed to get container logs")
411 }
412
413 ch := make(chan string, 100)
414
415 go func() {
416 defer close(ch)
417 defer reader.Close()
418
419 // Stream Docker logs
420 err := streamDockerLogs(ctx, reader, ch)
421 if err != nil && ctx.Err() == nil {
422 log.Warn().Err(err).Msg("error streaming container logs")
423 }
424 }()
425
426 return ch, nil
427}
428
429// tailLines reads the last N lines from a file using a ring buffer.
430func tailLines(file *os.File, n int) ([]string, error) {
431 if n <= 0 {
432 return []string{}, nil
433 }
434
435 // Seek to beginning
436 if _, err := file.Seek(0, io.SeekStart); err != nil {
437 return nil, err
438 }
439
440 scanner := bufio.NewScanner(file)
441 buffer := make([]string, n)
442 index := 0
443 total := 0
444
445 for scanner.Scan() {
446 buffer[index] = scanner.Text()
447 index = (index + 1) % n
448 total++
449 }
450
451 if err := scanner.Err(); err != nil {
452 return nil, err
453 }
454
455 if total == 0 {
456 return []string{}, nil
457 }
458
459 // Build result in correct order
460 var result []string
461 if total <= n {
462 result = make([]string, total)
463 for i := 0; i < total; i++ {
464 result[i] = buffer[i]
465 }
466 } else {
467 result = make([]string, n)
468 for i := 0; i < n; i++ {
469 result[i] = buffer[(index+i)%n]
470 }
471 }
472
473 return result, nil
474}
475
476// parseDockerLogs parses Docker multiplexed log stream and returns lines.
477// Docker logs have an 8-byte header: [stream type (1), padding (3), size (4)]
478func parseDockerLogs(reader io.Reader) ([]string, error) {
479 var lines []string
480 header := make([]byte, 8)
481
482 for {
483 // Read header
484 _, err := io.ReadFull(reader, header)
485 if err != nil {
486 if err == io.EOF {
487 break
488 }
489 return lines, nil // Return what we have
490 }
491
492 // Get payload size from header (big endian)
493 size := binary.BigEndian.Uint32(header[4:8])
494 if size == 0 {
495 continue
496 }
497
498 // Read payload
499 payload := make([]byte, size)
500 _, err = io.ReadFull(reader, payload)
501 if err != nil {
502 if err == io.EOF {
503 break
504 }
505 return lines, nil
506 }
507
508 // Split by newlines and add to result
509 content := strings.TrimRight(string(payload), "\n\r")
510 for _, line := range strings.Split(content, "\n") {
511 if line != "" {
512 lines = append(lines, line)
513 }
514 }
515 }
516
517 return lines, nil
518}
519
520// streamDockerLogs streams Docker multiplexed log stream to a channel.
521func streamDockerLogs(ctx context.Context, reader io.Reader, ch chan<- string) error {
522 header := make([]byte, 8)
523
524 for {
525 select {
526 case <-ctx.Done():
527 return ctx.Err()
528 default:
529 }
530
531 // Read header
532 _, err := io.ReadFull(reader, header)
533 if err != nil {
534 if err == io.EOF {
535 return nil
536 }
537 return err
538 }
539
540 // Get payload size
541 size := binary.BigEndian.Uint32(header[4:8])
542 if size == 0 {
543 continue
544 }
545
546 // Read payload
547 payload := make([]byte, size)
548 _, err = io.ReadFull(reader, payload)
549 if err != nil {
550 if err == io.EOF {
551 return nil
552 }
553 return err
554 }
555
556 // Split by newlines and send to channel
557 content := strings.TrimRight(string(payload), "\n\r")
558 for _, line := range strings.Split(content, "\n") {
559 if line != "" {
560 select {
561 case ch <- line:
562 case <-ctx.Done():
563 return ctx.Err()
564 }
565 }
566 }
567 }
568}