1// Package app provides the application initialization and wiring.
2package app
3
4import (
5 "bufio"
6 "bytes"
7 "fmt"
8 "os"
9 "os/exec"
10 "os/signal"
11 "path/filepath"
12 "syscall"
13 "time"
14
15 "github.com/spf13/viper"
16)
17
18// ShowLogs displays Gordon process logs from file or journalctl.
19func ShowLogs(configPath string, follow bool, lines int) error {
20 // Try to find log file from config
21 logPath, err := findLogFile(configPath)
22 if err == nil && logPath != "" {
23 return showFileLog(logPath, follow, lines)
24 }
25
26 // Fallback to journalctl
27 if hasJournalctl() {
28 return showJournalctlLog(follow, lines)
29 }
30
31 return fmt.Errorf("no logs available: enable file logging in config.toml or run gordon as a systemd service")
32}
33
34// findLogFile looks for the configured log file path.
35func findLogFile(configPath string) (string, error) {
36 v := viper.New()
37
38 // Set defaults
39 v.SetDefault("server.data_dir", DefaultDataDir())
40 v.SetDefault("logging.file.enabled", false)
41 v.SetDefault("logging.file.path", "")
42
43 // Try to load config if path provided
44 if configPath != "" {
45 v.SetConfigFile(configPath)
46 if err := v.ReadInConfig(); err != nil {
47 return "", fmt.Errorf("failed to read config: %w", err)
48 }
49 } else {
50 // Try default config locations
51 v.SetConfigName("config")
52 v.SetConfigType("toml")
53 v.AddConfigPath(".")
54 v.AddConfigPath(DefaultDataDir())
55 v.AddConfigPath("/etc/gordon")
56 _ = v.ReadInConfig() // Ignore error, we'll check if logging is enabled
57 }
58
59 if !v.GetBool("logging.file.enabled") {
60 return "", nil
61 }
62
63 logPath := v.GetString("logging.file.path")
64 if logPath == "" {
65 return "", nil
66 }
67
68 // Make path absolute if relative
69 if !filepath.IsAbs(logPath) {
70 dataDir := v.GetString("server.data_dir")
71 logPath = filepath.Join(dataDir, logPath)
72 }
73
74 // Check if file exists
75 if _, err := os.Stat(logPath); err != nil {
76 return "", fmt.Errorf("log file not found: %s", logPath)
77 }
78
79 return logPath, nil
80}
81
82// showFileLog displays logs from a file.
83func showFileLog(logPath string, follow bool, lines int) error {
84 // First show the last N lines
85 if err := tailFile(logPath, lines); err != nil {
86 return err
87 }
88
89 if !follow {
90 return nil
91 }
92
93 // Follow mode: watch for new lines
94 return followFile(logPath)
95}
96
97// tailFile reads the last N lines from a file using a ring buffer.
98// This is memory-efficient as it only keeps the last N lines in memory,
99// regardless of total file size.
100func tailFile(path string, lines int) error {
101 if lines <= 0 {
102 return nil
103 }
104
105 file, err := os.Open(path)
106 if err != nil {
107 return fmt.Errorf("failed to open log file: %w", err)
108 }
109 defer file.Close()
110
111 scanner := bufio.NewScanner(file)
112
113 // Use a ring buffer to store only the last N lines in memory
114 buffer := make([]string, lines)
115 index := 0
116 total := 0
117
118 for scanner.Scan() {
119 buffer[index] = scanner.Text()
120 index = (index + 1) % lines
121 total++
122 }
123 if err := scanner.Err(); err != nil {
124 return fmt.Errorf("failed to read log file: %w", err)
125 }
126
127 if total == 0 {
128 return nil
129 }
130
131 // Print lines in correct order
132 if total <= lines {
133 // Fewer than N lines read; buffer is partially filled, in order from index 0
134 for i := 0; i < total; i++ {
135 fmt.Println(buffer[i])
136 }
137 } else {
138 // More than N lines read; buffer is full ring starting at index
139 for i := 0; i < lines; i++ {
140 pos := (index + i) % lines
141 fmt.Println(buffer[pos])
142 }
143 }
144
145 return nil
146}
147
148// followFile watches a file for new content and prints it.
149// It handles interrupt signals (Ctrl+C) gracefully to ensure proper cleanup.
150func followFile(path string) error {
151 file, err := os.Open(path)
152 if err != nil {
153 return fmt.Errorf("failed to open log file: %w", err)
154 }
155 defer file.Close()
156
157 // Seek to end of file
158 _, err = file.Seek(0, 2)
159 if err != nil {
160 return fmt.Errorf("failed to seek to end of file: %w", err)
161 }
162
163 // Set up signal handling for graceful shutdown
164 sigChan := make(chan os.Signal, 1)
165 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
166 defer signal.Stop(sigChan)
167
168 reader := bufio.NewReader(file)
169
170 // Poll for new content with signal checking
171 for {
172 select {
173 case <-sigChan:
174 // Graceful exit on interrupt
175 fmt.Println() // Print newline for cleaner terminal output
176 return nil
177 default:
178 line, err := reader.ReadString('\n')
179 if err != nil {
180 // No new content, wait and retry
181 time.Sleep(100 * time.Millisecond)
182 continue
183 }
184 fmt.Print(line)
185 }
186 }
187}
188
189// hasJournalctl checks if journalctl is available.
190func hasJournalctl() bool {
191 _, err := exec.LookPath("journalctl")
192 return err == nil
193}
194
195// showJournalctlLog displays logs from journalctl.
196// It first tries user service, then falls back to system service.
197func showJournalctlLog(follow bool, lines int) error {
198 var userErr, systemErr error
199
200 // Try user service first (works without special permissions)
201 hasLogs, err := hasJournalctlLogs(true)
202 if err != nil {
203 userErr = err
204 } else if hasLogs {
205 return runJournalctl(follow, lines, true)
206 }
207
208 // Fall back to system service
209 hasLogs, err = hasJournalctlLogs(false)
210 if err != nil {
211 systemErr = err
212 } else if hasLogs {
213 return runJournalctl(follow, lines, false)
214 }
215
216 // If either check failed with an error, include that information
217 if userErr != nil || systemErr != nil {
218 return fmt.Errorf("gordon service not found in journalctl (user: %v, system: %v)", userErr, systemErr)
219 }
220
221 return fmt.Errorf("gordon service not found in journalctl: enable file logging in config.toml or run gordon as a systemd service")
222}
223
224// hasJournalctlLogs checks if there are any logs for the gordon service.
225func hasJournalctlLogs(userService bool) (bool, error) {
226 args := []string{"-u", "gordon", "-n", "1", "--no-pager", "-q"}
227 if userService {
228 args = append([]string{"--user"}, args...)
229 }
230
231 // #nosec G204 -- args are constructed internally with controlled values
232 cmd := exec.Command("journalctl", args...)
233 output, err := cmd.Output()
234 if err != nil {
235 return false, err
236 }
237
238 return len(bytes.TrimSpace(output)) > 0, nil
239}
240
241// runJournalctl runs journalctl and displays the output.
242func runJournalctl(follow bool, lines int, userService bool) error {
243 args := []string{"-u", "gordon", "-n", fmt.Sprintf("%d", lines), "--no-pager"}
244 if userService {
245 args = append([]string{"--user"}, args...)
246 }
247 if follow {
248 args = append(args, "-f")
249 }
250
251 // #nosec G204 -- args are constructed internally with controlled values
252 cmd := exec.Command("journalctl", args...)
253 cmd.Stdout = os.Stdout
254 cmd.Stderr = os.Stderr
255
256 return cmd.Run()
257}