v2.10.2 705579a

service.go RawHistoryFolder

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}

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