v2.31.0-dev21 b59533b

service.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close