Fallback admin process logs to journalctl when file logging is disabled
- commit
- c0ac371829f9adfd7acefb4aa95b18d0b96f763b
- author
- brice · · 2026-02-08 17:13 UTC
- parent
- d5d6723
- signature
- unsigned
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 | |||
| 492 | 492 | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | |
| 493 | 493 | ||
| 494 | 494 | // 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 | 497 | // Create admin handler for admin API | |
| 498 | 498 | 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 API | 494 | // 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 API | 497 | // 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 | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "bufio" | |
| 6 | + | "bytes" | |
| 6 | 7 | "context" | |
| 7 | 8 | "encoding/binary" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "io" | |
| 10 | 11 | "os" | |
| 12 | + | "os/exec" | |
| 13 | + | "strconv" | |
| 11 | 14 | "strings" | |
| 12 | 15 | "time" | |
| 13 | 16 | ||
| @@ -19,24 +22,29 @@ import ( | |||
| 19 | 22 | ||
| 20 | 23 | // Service implements the LogService interface. | |
| 21 | 24 | 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 | |
| 26 | 30 | } | |
| 27 | 31 | ||
| 32 | + | var execCommandContext = exec.CommandContext | |
| 33 | + | ||
| 28 | 34 | // NewService creates a new log service. | |
| 29 | 35 | func NewService( | |
| 30 | 36 | logFilePath string, | |
| 37 | + | fileLoggingEnabled bool, | |
| 31 | 38 | containerSvc in.ContainerService, | |
| 32 | 39 | runtime out.ContainerRuntime, | |
| 33 | 40 | log zerowrap.Logger, | |
| 34 | 41 | ) *Service { | |
| 35 | 42 | 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, | |
| 40 | 48 | } | |
| 41 | 49 | } | |
| 42 | 50 | ||
| @@ -49,20 +57,26 @@ func (s *Service) GetProcessLogs(ctx context.Context, lines int) ([]string, erro | |||
| 49 | 57 | }) | |
| 50 | 58 | log := zerowrap.FromCtx(ctx) | |
| 51 | 59 | ||
| 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 | + | } | |
| 55 | 64 | ||
| 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") | |
| 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 | 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 | 89 | log := zerowrap.FromCtx(ctx) | |
| 76 | 90 | ||
| 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 | + | } | |
| 80 | 95 | ||
| 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 | + | } | |
| 85 | 105 | ||
| 86 | - | ch := make(chan string, 100) | |
| 106 | + | ch := make(chan string, 100) | |
| 87 | 107 | ||
| 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 | + | } | |
| 91 | 127 | ||
| 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") | |
| 99 | 151 | select { | |
| 100 | 152 | case ch <- line: | |
| 101 | 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 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 | + | } | |
| 113 | 185 | ||
| 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() { | |
| 117 | 228 | select { | |
| 229 | + | case ch <- scanner.Text(): | |
| 118 | 230 | case <-ctx.Done(): | |
| 119 | 231 | 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 | 236 | 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 | 280 | // validateDomain checks that domain is safe for use in logs and error messages. | |
| 144 | 281 | func validateDomain(domain string) error { | |
| 145 | 282 | 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 string | 25 | + | logFilePath string |
| 23 | - | containerSvc in.ContainerService | 26 | + | fileLoggingEnabled bool |
| 24 | - | runtime out.ContainerRuntime | 27 | + | containerSvc in.ContainerService |
| 25 | - | log zerowrap.Logger | 28 | + | 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{}, nil | 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") | |||
| 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 lines | 128 | + | // 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 following | 160 | + | 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 | - | return | 163 | + | 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 lines | 186 | + | 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 | return | 231 | 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, nil | 236 | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "io" | |
| 6 | 6 | "os" | |
| 7 | + | "os/exec" | |
| 7 | 8 | "path/filepath" | |
| 8 | 9 | "testing" | |
| 9 | 10 | "time" | |
| @@ -32,7 +33,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||
| 32 | 33 | containerSvc := mocks.NewMockContainerService(t) | |
| 33 | 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 | 38 | lines, err := svc.GetProcessLogs(context.Background(), 3) | |
| 38 | 39 | require.NoError(t, err) | |
| @@ -44,7 +45,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||
| 44 | 45 | containerSvc := mocks.NewMockContainerService(t) | |
| 45 | 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 | 50 | lines, err := svc.GetProcessLogs(context.Background(), 10) | |
| 50 | 51 | require.NoError(t, err) | |
| @@ -55,7 +56,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||
| 55 | 56 | containerSvc := mocks.NewMockContainerService(t) | |
| 56 | 57 | runtime := outMocks.NewMockContainerRuntime(t) | |
| 57 | 58 | ||
| 58 | - | svc := NewService("", containerSvc, runtime, log) | |
| 59 | + | svc := NewService("", true, containerSvc, runtime, log) | |
| 59 | 60 | ||
| 60 | 61 | _, err := svc.GetProcessLogs(context.Background(), 10) | |
| 61 | 62 | assert.Error(t, err) | |
| @@ -72,12 +73,31 @@ func TestService_GetProcessLogs(t *testing.T) { | |||
| 72 | 73 | containerSvc := mocks.NewMockContainerService(t) | |
| 73 | 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 | 78 | lines, err := svc.GetProcessLogs(context.Background(), 10) | |
| 78 | 79 | require.NoError(t, err) | |
| 79 | 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 | 103 | func TestService_FollowProcessLogs(t *testing.T) { | |
| @@ -93,7 +113,7 @@ func TestService_FollowProcessLogs(t *testing.T) { | |||
| 93 | 113 | containerSvc := mocks.NewMockContainerService(t) | |
| 94 | 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 | 118 | ctx, cancel := context.WithCancel(context.Background()) | |
| 99 | 119 | defer cancel() | |
| @@ -135,7 +155,7 @@ func TestService_GetContainerLogs(t *testing.T) { | |||
| 135 | 155 | ||
| 136 | 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 | 160 | _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10) | |
| 141 | 161 | assert.Error(t, err) | |
| @@ -156,7 +176,7 @@ func TestService_GetContainerLogs(t *testing.T) { | |||
| 156 | 176 | // Create a mock reader that returns empty content | |
| 157 | 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 | 181 | lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10) | |
| 162 | 182 | require.NoError(t, err) | |
| @@ -173,7 +193,7 @@ func TestService_FollowContainerLogs(t *testing.T) { | |||
| 173 | 193 | ||
| 174 | 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 | 198 | _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10) | |
| 179 | 199 | 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 content | 176 | // 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) | ||