detached 450e4fe

Fallback admin process logs to journalctl when file logging is disabled

commit
450e4fed6d97c77470038cdf390bb00d5390c322
author
brice · · 2026-02-08 17:13 UTC
parent
3e72060
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +227 -70
M internal/app/run.go +1 -1
@@ -492,7 +492,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
492492 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
493493
494494 // Create log service for accessing logs via admin API
495- logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)
495+ logSvc := logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log)
496496
497497 // Create admin handler for admin API
498498 svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc, svc.imageSvc)
@@ -492,7 +492,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
492 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)492 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
493 493
494 // Create log service for accessing logs via admin API494 // Create log service for accessing logs via admin API
495- logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)495+ logSvc := logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log)
496 496
497 // Create admin handler for admin API497 // Create admin handler for admin API
498 svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc, svc.imageSvc)498 svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc, svc.imageSvc)
M internal/usecase/logs/service.go +198 -61
@@ -3,11 +3,14 @@ package logs
33
44 import (
55 "bufio"
6+ "bytes"
67 "context"
78 "encoding/binary"
89 "fmt"
910 "io"
1011 "os"
12+ "os/exec"
13+ "strconv"
1114 "strings"
1215 "time"
1316
@@ -19,24 +22,29 @@ import (
1922
2023 // Service implements the LogService interface.
2124 type Service struct {
22- logFilePath string
23- containerSvc in.ContainerService
24- runtime out.ContainerRuntime
25- log zerowrap.Logger
25+ logFilePath string
26+ fileLoggingEnabled bool
27+ containerSvc in.ContainerService
28+ runtime out.ContainerRuntime
29+ log zerowrap.Logger
2630 }
2731
32+var execCommandContext = exec.CommandContext
33+
2834 // NewService creates a new log service.
2935 func NewService(
3036 logFilePath string,
37+ fileLoggingEnabled bool,
3138 containerSvc in.ContainerService,
3239 runtime out.ContainerRuntime,
3340 log zerowrap.Logger,
3441 ) *Service {
3542 return &Service{
36- logFilePath: logFilePath,
37- containerSvc: containerSvc,
38- runtime: runtime,
39- log: log,
43+ logFilePath: logFilePath,
44+ fileLoggingEnabled: fileLoggingEnabled,
45+ containerSvc: containerSvc,
46+ runtime: runtime,
47+ log: log,
4048 }
4149 }
4250
@@ -49,20 +57,26 @@ func (s *Service) GetProcessLogs(ctx context.Context, lines int) ([]string, erro
4957 })
5058 log := zerowrap.FromCtx(ctx)
5159
52- if s.logFilePath == "" {
53- return nil, fmt.Errorf("log file path not configured")
54- }
60+ if s.useFileProcessLogs() {
61+ if s.logFilePath == "" {
62+ return nil, fmt.Errorf("log file path not configured")
63+ }
5564
56- file, err := os.Open(s.logFilePath)
57- if err != nil {
58- if os.IsNotExist(err) {
59- return []string{}, nil
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")
6073 }
61- return nil, log.WrapErr(err, "failed to open log file")
74+ defer file.Close()
75+
76+ return tailLines(file, lines)
6277 }
63- defer file.Close()
6478
65- return tailLines(file, lines)
79+ return s.getProcessLogsFromJournal(ctx, lines)
6680 }
6781
6882 // FollowProcessLogs returns a channel that streams Gordon process log lines.
@@ -74,28 +88,66 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch
7488 })
7589 log := zerowrap.FromCtx(ctx)
7690
77- if s.logFilePath == "" {
78- return nil, fmt.Errorf("log file path not configured")
79- }
91+ if s.useFileProcessLogs() {
92+ if s.logFilePath == "" {
93+ return nil, fmt.Errorf("log file path not configured")
94+ }
8095
81- file, err := os.Open(s.logFilePath)
82- if err != nil {
83- return nil, log.WrapErr(err, "failed to open log file")
84- }
96+ file, err := os.Open(s.logFilePath)
97+ if err != nil {
98+ if os.IsNotExist(err) {
99+ log.Warn().Str("log_path", s.logFilePath).
100+ Msg("process log file missing, falling back to journalctl follow")
101+ return s.followProcessLogsFromJournal(ctx, initialLines)
102+ }
103+ return nil, log.WrapErr(err, "failed to open log file")
104+ }
85105
86- ch := make(chan string, 100)
106+ ch := make(chan string, 100)
87107
88- go func() {
89- defer close(ch)
90- defer file.Close()
108+ go func() {
109+ defer close(ch)
110+ defer file.Close()
111+
112+ // First, get initial lines
113+ if initialLines > 0 {
114+ lines, err := tailLines(file, initialLines)
115+ if err != nil {
116+ log.Warn().Err(err).Msg("failed to read initial lines")
117+ } else {
118+ for _, line := range lines {
119+ select {
120+ case ch <- line:
121+ case <-ctx.Done():
122+ return
123+ }
124+ }
125+ }
126+ }
91127
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 {
128+ // Seek to end for following
129+ if _, err := file.Seek(0, io.SeekEnd); err != nil {
130+ log.Warn().Err(err).Msg("failed to seek to end")
131+ return
132+ }
133+
134+ // Follow new lines
135+ reader := bufio.NewReader(file)
136+ for {
137+ select {
138+ case <-ctx.Done():
139+ return
140+ default:
141+ line, err := reader.ReadString('\n')
142+ if err != nil {
143+ if err == io.EOF {
144+ time.Sleep(100 * time.Millisecond)
145+ continue
146+ }
147+ log.Warn().Err(err).Msg("error reading log file")
148+ return
149+ }
150+ line = strings.TrimRight(line, "\n\r")
99151 select {
100152 case ch <- line:
101153 case <-ctx.Done():
@@ -103,36 +155,80 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch
103155 }
104156 }
105157 }
106- }
158+ }()
107159
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- }
160+ return ch, nil
161+ }
162+
163+ return s.followProcessLogsFromJournal(ctx, initialLines)
164+}
165+
166+func (s *Service) useFileProcessLogs() bool {
167+ return s.fileLoggingEnabled
168+}
169+
170+func (s *Service) getProcessLogsFromJournal(ctx context.Context, lines int) ([]string, error) {
171+ userScope, err := resolveJournalctlScope(ctx)
172+ if err != nil {
173+ return nil, err
174+ }
175+
176+ args := []string{"-u", "gordon", "-n", strconv.Itoa(lines), "--no-pager", "-o", "cat"}
177+ if userScope {
178+ args = append([]string{"--user"}, args...)
179+ }
180+
181+ out, err := execCommandContext(ctx, "journalctl", args...).Output()
182+ if err != nil {
183+ return nil, fmt.Errorf("failed to read journalctl logs: %w", err)
184+ }
113185
114- // Follow new lines
115- reader := bufio.NewReader(file)
116- for {
186+ return outputLines(out), nil
187+}
188+
189+func (s *Service) followProcessLogsFromJournal(ctx context.Context, initialLines int) (<-chan string, error) {
190+ userScope, err := resolveJournalctlScope(ctx)
191+ if err != nil {
192+ return nil, err
193+ }
194+
195+ args := []string{
196+ "-u", "gordon",
197+ "-n", strconv.Itoa(initialLines),
198+ "--no-pager",
199+ "-o", "cat",
200+ "-f",
201+ }
202+ if userScope {
203+ args = append([]string{"--user"}, args...)
204+ }
205+
206+ cmd := execCommandContext(ctx, "journalctl", args...) // #nosec G204 -- internal args only
207+ stdout, err := cmd.StdoutPipe()
208+ if err != nil {
209+ return nil, fmt.Errorf("failed to open journalctl stdout: %w", err)
210+ }
211+ cmd.Stderr = io.Discard
212+
213+ if err := cmd.Start(); err != nil {
214+ return nil, fmt.Errorf("failed to start journalctl follow: %w", err)
215+ }
216+
217+ ch := make(chan string, 100)
218+
219+ go func() {
220+ defer close(ch)
221+ defer stdout.Close()
222+ defer cmd.Wait() //nolint:errcheck // best-effort cleanup
223+
224+ scanner := bufio.NewScanner(stdout)
225+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
226+
227+ for scanner.Scan() {
117228 select {
229+ case ch <- scanner.Text():
118230 case <-ctx.Done():
119231 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- }
136232 }
137233 }
138234 }()
@@ -140,6 +236,47 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch
140236 return ch, nil
141237 }
142238
239+func resolveJournalctlScope(ctx context.Context) (bool, error) {
240+ userLogs, userErr := hasJournalctlLogs(ctx, true)
241+ if userErr == nil && userLogs {
242+ return true, nil
243+ }
244+
245+ systemLogs, systemErr := hasJournalctlLogs(ctx, false)
246+ if systemErr == nil && systemLogs {
247+ return false, nil
248+ }
249+
250+ if userErr != nil && systemErr != nil {
251+ return false, fmt.Errorf("journalctl unavailable for user and system services")
252+ }
253+
254+ // Default to user scope when logs are currently empty; this matches rootless setups.
255+ return true, nil
256+}
257+
258+func hasJournalctlLogs(ctx context.Context, userService bool) (bool, error) {
259+ args := []string{"-u", "gordon", "-n", "1", "--no-pager", "-q", "-o", "cat"}
260+ if userService {
261+ args = append([]string{"--user"}, args...)
262+ }
263+
264+ out, err := execCommandContext(ctx, "journalctl", args...).Output()
265+ if err != nil {
266+ return false, err
267+ }
268+
269+ return len(bytes.TrimSpace(out)) > 0, nil
270+}
271+
272+func outputLines(out []byte) []string {
273+ trimmed := strings.TrimRight(string(out), "\n\r")
274+ if trimmed == "" {
275+ return []string{}
276+ }
277+ return strings.Split(trimmed, "\n")
278+}
279+
143280 // validateDomain checks that domain is safe for use in logs and error messages.
144281 func validateDomain(domain string) error {
145282 if domain == "" {
@@ -3,11 +3,14 @@ package logs
3 3
4 import (4 import (
5 "bufio"5 "bufio"
6+ "bytes"
6 "context"7 "context"
7 "encoding/binary"8 "encoding/binary"
8 "fmt"9 "fmt"
9 "io"10 "io"
10 "os"11 "os"
12+ "os/exec"
13+ "strconv"
11 "strings"14 "strings"
12 "time"15 "time"
13 16
@@ -19,24 +22,29 @@ import (
19 22
20 // Service implements the LogService interface.23 // Service implements the LogService interface.
21 type Service struct {24 type Service struct {
22- logFilePath string25+ logFilePath string
23- containerSvc in.ContainerService26+ fileLoggingEnabled bool
24- runtime out.ContainerRuntime27+ containerSvc in.ContainerService
25- log zerowrap.Logger28+ runtime out.ContainerRuntime
29+ log zerowrap.Logger
26 }30 }
27 31
32+var execCommandContext = exec.CommandContext
33+
28 // NewService creates a new log service.34 // NewService creates a new log service.
29 func NewService(35 func NewService(
30 logFilePath string,36 logFilePath string,
37+ fileLoggingEnabled bool,
31 containerSvc in.ContainerService,38 containerSvc in.ContainerService,
32 runtime out.ContainerRuntime,39 runtime out.ContainerRuntime,
33 log zerowrap.Logger,40 log zerowrap.Logger,
34 ) *Service {41 ) *Service {
35 return &Service{42 return &Service{
36- logFilePath: logFilePath,43+ logFilePath: logFilePath,
37- containerSvc: containerSvc,44+ fileLoggingEnabled: fileLoggingEnabled,
38- runtime: runtime,45+ containerSvc: containerSvc,
39- log: log,46+ runtime: runtime,
47+ log: log,
40 }48 }
41 }49 }
42 50
@@ -49,20 +57,26 @@ func (s *Service) GetProcessLogs(ctx context.Context, lines int) ([]string, erro
49 })57 })
50 log := zerowrap.FromCtx(ctx)58 log := zerowrap.FromCtx(ctx)
51 59
52- if s.logFilePath == "" {60+ if s.useFileProcessLogs() {
53- return nil, fmt.Errorf("log file path not configured")61+ if s.logFilePath == "" {
54- }62+ return nil, fmt.Errorf("log file path not configured")
63+ }
55 64
56- file, err := os.Open(s.logFilePath)65+ file, err := os.Open(s.logFilePath)
57- if err != nil {66+ if err != nil {
58- if os.IsNotExist(err) {67+ if os.IsNotExist(err) {
59- return []string{}, nil68+ 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")
60 }73 }
61- return nil, log.WrapErr(err, "failed to open log file")74+ defer file.Close()
75+
76+ return tailLines(file, lines)
62 }77 }
63- defer file.Close()
64 78
65- return tailLines(file, lines)79+ return s.getProcessLogsFromJournal(ctx, lines)
66 }80 }
67 81
68 // FollowProcessLogs returns a channel that streams Gordon process log lines.82 // FollowProcessLogs returns a channel that streams Gordon process log lines.
@@ -74,28 +88,66 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch
74 })88 })
75 log := zerowrap.FromCtx(ctx)89 log := zerowrap.FromCtx(ctx)
76 90
77- if s.logFilePath == "" {91+ if s.useFileProcessLogs() {
78- return nil, fmt.Errorf("log file path not configured")92+ if s.logFilePath == "" {
79- }93+ return nil, fmt.Errorf("log file path not configured")
94+ }
80 95
81- file, err := os.Open(s.logFilePath)96+ file, err := os.Open(s.logFilePath)
82- if err != nil {97+ if err != nil {
83- return nil, log.WrapErr(err, "failed to open log file")98+ if os.IsNotExist(err) {
84- }99+ log.Warn().Str("log_path", s.logFilePath).
100+ Msg("process log file missing, falling back to journalctl follow")
101+ return s.followProcessLogsFromJournal(ctx, initialLines)
102+ }
103+ return nil, log.WrapErr(err, "failed to open log file")
104+ }
85 105
86- ch := make(chan string, 100)106+ ch := make(chan string, 100)
87 107
88- go func() {108+ go func() {
89- defer close(ch)109+ defer close(ch)
90- defer file.Close()110+ defer file.Close()
111+
112+ // First, get initial lines
113+ if initialLines > 0 {
114+ lines, err := tailLines(file, initialLines)
115+ if err != nil {
116+ log.Warn().Err(err).Msg("failed to read initial lines")
117+ } else {
118+ for _, line := range lines {
119+ select {
120+ case ch <- line:
121+ case <-ctx.Done():
122+ return
123+ }
124+ }
125+ }
126+ }
91 127
92- // First, get initial lines128+ // Seek to end for following
93- if initialLines > 0 {129+ if _, err := file.Seek(0, io.SeekEnd); err != nil {
94- lines, err := tailLines(file, initialLines)130+ log.Warn().Err(err).Msg("failed to seek to end")
95- if err != nil {131+ return
96- log.Warn().Err(err).Msg("failed to read initial lines")132+ }
97- } else {133+
98- for _, line := range lines {134+ // Follow new lines
135+ reader := bufio.NewReader(file)
136+ for {
137+ select {
138+ case <-ctx.Done():
139+ return
140+ default:
141+ line, err := reader.ReadString('\n')
142+ if err != nil {
143+ if err == io.EOF {
144+ time.Sleep(100 * time.Millisecond)
145+ continue
146+ }
147+ log.Warn().Err(err).Msg("error reading log file")
148+ return
149+ }
150+ line = strings.TrimRight(line, "\n\r")
99 select {151 select {
100 case ch <- line:152 case ch <- line:
101 case <-ctx.Done():153 case <-ctx.Done():
@@ -103,36 +155,80 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch
103 }155 }
104 }156 }
105 }157 }
106- }158+ }()
107 159
108- // Seek to end for following160+ return ch, nil
109- if _, err := file.Seek(0, io.SeekEnd); err != nil {161+ }
110- log.Warn().Err(err).Msg("failed to seek to end")162+
111- return163+ return s.followProcessLogsFromJournal(ctx, initialLines)
112- }164+}
165+
166+func (s *Service) useFileProcessLogs() bool {
167+ return s.fileLoggingEnabled
168+}
169+
170+func (s *Service) getProcessLogsFromJournal(ctx context.Context, lines int) ([]string, error) {
171+ userScope, err := resolveJournalctlScope(ctx)
172+ if err != nil {
173+ return nil, err
174+ }
175+
176+ args := []string{"-u", "gordon", "-n", strconv.Itoa(lines), "--no-pager", "-o", "cat"}
177+ if userScope {
178+ args = append([]string{"--user"}, args...)
179+ }
180+
181+ out, err := execCommandContext(ctx, "journalctl", args...).Output()
182+ if err != nil {
183+ return nil, fmt.Errorf("failed to read journalctl logs: %w", err)
184+ }
113 185
114- // Follow new lines186+ return outputLines(out), nil
115- reader := bufio.NewReader(file)187+}
116- for {188+
189+func (s *Service) followProcessLogsFromJournal(ctx context.Context, initialLines int) (<-chan string, error) {
190+ userScope, err := resolveJournalctlScope(ctx)
191+ if err != nil {
192+ return nil, err
193+ }
194+
195+ args := []string{
196+ "-u", "gordon",
197+ "-n", strconv.Itoa(initialLines),
198+ "--no-pager",
199+ "-o", "cat",
200+ "-f",
201+ }
202+ if userScope {
203+ args = append([]string{"--user"}, args...)
204+ }
205+
206+ cmd := execCommandContext(ctx, "journalctl", args...) // #nosec G204 -- internal args only
207+ stdout, err := cmd.StdoutPipe()
208+ if err != nil {
209+ return nil, fmt.Errorf("failed to open journalctl stdout: %w", err)
210+ }
211+ cmd.Stderr = io.Discard
212+
213+ if err := cmd.Start(); err != nil {
214+ return nil, fmt.Errorf("failed to start journalctl follow: %w", err)
215+ }
216+
217+ ch := make(chan string, 100)
218+
219+ go func() {
220+ defer close(ch)
221+ defer stdout.Close()
222+ defer cmd.Wait() //nolint:errcheck // best-effort cleanup
223+
224+ scanner := bufio.NewScanner(stdout)
225+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
226+
227+ for scanner.Scan() {
117 select {228 select {
229+ case ch <- scanner.Text():
118 case <-ctx.Done():230 case <-ctx.Done():
119 return231 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 }232 }
137 }233 }
138 }()234 }()
@@ -140,6 +236,47 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch
140 return ch, nil236 return ch, nil
141 }237 }
142 238
239+func resolveJournalctlScope(ctx context.Context) (bool, error) {
240+ userLogs, userErr := hasJournalctlLogs(ctx, true)
241+ if userErr == nil && userLogs {
242+ return true, nil
243+ }
244+
245+ systemLogs, systemErr := hasJournalctlLogs(ctx, false)
246+ if systemErr == nil && systemLogs {
247+ return false, nil
248+ }
249+
250+ if userErr != nil && systemErr != nil {
251+ return false, fmt.Errorf("journalctl unavailable for user and system services")
252+ }
253+
254+ // Default to user scope when logs are currently empty; this matches rootless setups.
255+ return true, nil
256+}
257+
258+func hasJournalctlLogs(ctx context.Context, userService bool) (bool, error) {
259+ args := []string{"-u", "gordon", "-n", "1", "--no-pager", "-q", "-o", "cat"}
260+ if userService {
261+ args = append([]string{"--user"}, args...)
262+ }
263+
264+ out, err := execCommandContext(ctx, "journalctl", args...).Output()
265+ if err != nil {
266+ return false, err
267+ }
268+
269+ return len(bytes.TrimSpace(out)) > 0, nil
270+}
271+
272+func outputLines(out []byte) []string {
273+ trimmed := strings.TrimRight(string(out), "\n\r")
274+ if trimmed == "" {
275+ return []string{}
276+ }
277+ return strings.Split(trimmed, "\n")
278+}
279+
143 // validateDomain checks that domain is safe for use in logs and error messages.280 // validateDomain checks that domain is safe for use in logs and error messages.
144 func validateDomain(domain string) error {281 func validateDomain(domain string) error {
145 if domain == "" {282 if domain == "" {
M internal/usecase/logs/service_test.go +28 -8
@@ -4,6 +4,7 @@ import (
44 "context"
55 "io"
66 "os"
7+ "os/exec"
78 "path/filepath"
89 "testing"
910 "time"
@@ -32,7 +33,7 @@ func TestService_GetProcessLogs(t *testing.T) {
3233 containerSvc := mocks.NewMockContainerService(t)
3334 runtime := outMocks.NewMockContainerRuntime(t)
3435
35- svc := NewService(logPath, containerSvc, runtime, log)
36+ svc := NewService(logPath, true, containerSvc, runtime, log)
3637
3738 lines, err := svc.GetProcessLogs(context.Background(), 3)
3839 require.NoError(t, err)
@@ -44,7 +45,7 @@ func TestService_GetProcessLogs(t *testing.T) {
4445 containerSvc := mocks.NewMockContainerService(t)
4546 runtime := outMocks.NewMockContainerRuntime(t)
4647
47- svc := NewService("/nonexistent/file.log", containerSvc, runtime, log)
48+ svc := NewService("/nonexistent/file.log", true, containerSvc, runtime, log)
4849
4950 lines, err := svc.GetProcessLogs(context.Background(), 10)
5051 require.NoError(t, err)
@@ -55,7 +56,7 @@ func TestService_GetProcessLogs(t *testing.T) {
5556 containerSvc := mocks.NewMockContainerService(t)
5657 runtime := outMocks.NewMockContainerRuntime(t)
5758
58- svc := NewService("", containerSvc, runtime, log)
59+ svc := NewService("", true, containerSvc, runtime, log)
5960
6061 _, err := svc.GetProcessLogs(context.Background(), 10)
6162 assert.Error(t, err)
@@ -72,12 +73,31 @@ func TestService_GetProcessLogs(t *testing.T) {
7273 containerSvc := mocks.NewMockContainerService(t)
7374 runtime := outMocks.NewMockContainerRuntime(t)
7475
75- svc := NewService(logPath, containerSvc, runtime, log)
76+ svc := NewService(logPath, true, containerSvc, runtime, log)
7677
7778 lines, err := svc.GetProcessLogs(context.Background(), 10)
7879 require.NoError(t, err)
7980 assert.Equal(t, []string{"line1", "line2"}, lines)
8081 })
82+
83+ t.Run("falls back to journalctl when file logging disabled", func(t *testing.T) {
84+ origExec := execCommandContext
85+ execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
86+ return exec.CommandContext(ctx, "bash", "-lc", "printf 'journal-line-1\\njournal-line-2\\n'") // #nosec G204
87+ }
88+ defer func() {
89+ execCommandContext = origExec
90+ }()
91+
92+ containerSvc := mocks.NewMockContainerService(t)
93+ runtime := outMocks.NewMockContainerRuntime(t)
94+
95+ svc := NewService("/tmp/unused.log", false, containerSvc, runtime, log)
96+
97+ lines, err := svc.GetProcessLogs(context.Background(), 10)
98+ require.NoError(t, err)
99+ assert.Equal(t, []string{"journal-line-1", "journal-line-2"}, lines)
100+ })
81101 }
82102
83103 func TestService_FollowProcessLogs(t *testing.T) {
@@ -93,7 +113,7 @@ func TestService_FollowProcessLogs(t *testing.T) {
93113 containerSvc := mocks.NewMockContainerService(t)
94114 runtime := outMocks.NewMockContainerRuntime(t)
95115
96- svc := NewService(logPath, containerSvc, runtime, log)
116+ svc := NewService(logPath, true, containerSvc, runtime, log)
97117
98118 ctx, cancel := context.WithCancel(context.Background())
99119 defer cancel()
@@ -135,7 +155,7 @@ func TestService_GetContainerLogs(t *testing.T) {
135155
136156 containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
137157
138- svc := NewService("/tmp/test.log", containerSvc, runtime, log)
158+ svc := NewService("/tmp/test.log", true, containerSvc, runtime, log)
139159
140160 _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10)
141161 assert.Error(t, err)
@@ -156,7 +176,7 @@ func TestService_GetContainerLogs(t *testing.T) {
156176 // Create a mock reader that returns empty content
157177 runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil)
158178
159- svc := NewService("/tmp/test.log", containerSvc, runtime, log)
179+ svc := NewService("/tmp/test.log", true, containerSvc, runtime, log)
160180
161181 lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10)
162182 require.NoError(t, err)
@@ -173,7 +193,7 @@ func TestService_FollowContainerLogs(t *testing.T) {
173193
174194 containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
175195
176- svc := NewService("/tmp/test.log", containerSvc, runtime, log)
196+ svc := NewService("/tmp/test.log", true, containerSvc, runtime, log)
177197
178198 _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10)
179199 assert.Error(t, err)
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "io"5 "io"
6 "os"6 "os"
7+ "os/exec"
7 "path/filepath"8 "path/filepath"
8 "testing"9 "testing"
9 "time"10 "time"
@@ -32,7 +33,7 @@ func TestService_GetProcessLogs(t *testing.T) {
32 containerSvc := mocks.NewMockContainerService(t)33 containerSvc := mocks.NewMockContainerService(t)
33 runtime := outMocks.NewMockContainerRuntime(t)34 runtime := outMocks.NewMockContainerRuntime(t)
34 35
35- svc := NewService(logPath, containerSvc, runtime, log)36+ svc := NewService(logPath, true, containerSvc, runtime, log)
36 37
37 lines, err := svc.GetProcessLogs(context.Background(), 3)38 lines, err := svc.GetProcessLogs(context.Background(), 3)
38 require.NoError(t, err)39 require.NoError(t, err)
@@ -44,7 +45,7 @@ func TestService_GetProcessLogs(t *testing.T) {
44 containerSvc := mocks.NewMockContainerService(t)45 containerSvc := mocks.NewMockContainerService(t)
45 runtime := outMocks.NewMockContainerRuntime(t)46 runtime := outMocks.NewMockContainerRuntime(t)
46 47
47- svc := NewService("/nonexistent/file.log", containerSvc, runtime, log)48+ svc := NewService("/nonexistent/file.log", true, containerSvc, runtime, log)
48 49
49 lines, err := svc.GetProcessLogs(context.Background(), 10)50 lines, err := svc.GetProcessLogs(context.Background(), 10)
50 require.NoError(t, err)51 require.NoError(t, err)
@@ -55,7 +56,7 @@ func TestService_GetProcessLogs(t *testing.T) {
55 containerSvc := mocks.NewMockContainerService(t)56 containerSvc := mocks.NewMockContainerService(t)
56 runtime := outMocks.NewMockContainerRuntime(t)57 runtime := outMocks.NewMockContainerRuntime(t)
57 58
58- svc := NewService("", containerSvc, runtime, log)59+ svc := NewService("", true, containerSvc, runtime, log)
59 60
60 _, err := svc.GetProcessLogs(context.Background(), 10)61 _, err := svc.GetProcessLogs(context.Background(), 10)
61 assert.Error(t, err)62 assert.Error(t, err)
@@ -72,12 +73,31 @@ func TestService_GetProcessLogs(t *testing.T) {
72 containerSvc := mocks.NewMockContainerService(t)73 containerSvc := mocks.NewMockContainerService(t)
73 runtime := outMocks.NewMockContainerRuntime(t)74 runtime := outMocks.NewMockContainerRuntime(t)
74 75
75- svc := NewService(logPath, containerSvc, runtime, log)76+ svc := NewService(logPath, true, containerSvc, runtime, log)
76 77
77 lines, err := svc.GetProcessLogs(context.Background(), 10)78 lines, err := svc.GetProcessLogs(context.Background(), 10)
78 require.NoError(t, err)79 require.NoError(t, err)
79 assert.Equal(t, []string{"line1", "line2"}, lines)80 assert.Equal(t, []string{"line1", "line2"}, lines)
80 })81 })
82+
83+ t.Run("falls back to journalctl when file logging disabled", func(t *testing.T) {
84+ origExec := execCommandContext
85+ execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
86+ return exec.CommandContext(ctx, "bash", "-lc", "printf 'journal-line-1\\njournal-line-2\\n'") // #nosec G204
87+ }
88+ defer func() {
89+ execCommandContext = origExec
90+ }()
91+
92+ containerSvc := mocks.NewMockContainerService(t)
93+ runtime := outMocks.NewMockContainerRuntime(t)
94+
95+ svc := NewService("/tmp/unused.log", false, containerSvc, runtime, log)
96+
97+ lines, err := svc.GetProcessLogs(context.Background(), 10)
98+ require.NoError(t, err)
99+ assert.Equal(t, []string{"journal-line-1", "journal-line-2"}, lines)
100+ })
81 }101 }
82 102
83 func TestService_FollowProcessLogs(t *testing.T) {103 func TestService_FollowProcessLogs(t *testing.T) {
@@ -93,7 +113,7 @@ func TestService_FollowProcessLogs(t *testing.T) {
93 containerSvc := mocks.NewMockContainerService(t)113 containerSvc := mocks.NewMockContainerService(t)
94 runtime := outMocks.NewMockContainerRuntime(t)114 runtime := outMocks.NewMockContainerRuntime(t)
95 115
96- svc := NewService(logPath, containerSvc, runtime, log)116+ svc := NewService(logPath, true, containerSvc, runtime, log)
97 117
98 ctx, cancel := context.WithCancel(context.Background())118 ctx, cancel := context.WithCancel(context.Background())
99 defer cancel()119 defer cancel()
@@ -135,7 +155,7 @@ func TestService_GetContainerLogs(t *testing.T) {
135 155
136 containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)156 containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
137 157
138- svc := NewService("/tmp/test.log", containerSvc, runtime, log)158+ svc := NewService("/tmp/test.log", true, containerSvc, runtime, log)
139 159
140 _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10)160 _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10)
141 assert.Error(t, err)161 assert.Error(t, err)
@@ -156,7 +176,7 @@ func TestService_GetContainerLogs(t *testing.T) {
156 // Create a mock reader that returns empty content176 // Create a mock reader that returns empty content
157 runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil)177 runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil)
158 178
159- svc := NewService("/tmp/test.log", containerSvc, runtime, log)179+ svc := NewService("/tmp/test.log", true, containerSvc, runtime, log)
160 180
161 lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10)181 lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10)
162 require.NoError(t, err)182 require.NoError(t, err)
@@ -173,7 +193,7 @@ func TestService_FollowContainerLogs(t *testing.T) {
173 193
174 containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)194 containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
175 195
176- svc := NewService("/tmp/test.log", containerSvc, runtime, log)196+ svc := NewService("/tmp/test.log", true, containerSvc, runtime, log)
177 197
178 _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10)198 _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10)
179 assert.Error(t, err)199 assert.Error(t, err)

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