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