Add log tail command for live log streaming

This commit adds a new `ctrld log tail` subcommand that streams
runtime debug logs to the terminal in real-time, similar to `tail -f`.

Changes:
- log_writer.go: Add Subscribe/tailLastLines for fan-out to tail clients
- control_server.go: Add /log/tail endpoint with streaming response
  - Internal logging: subscribes to logWriter for live data
  - File-based logging: polls log file for new data (200ms interval)
  - Sends last N lines as initial context on connect
- commands.go: Add `log tail` cobra subcommand with --lines/-n flag
- control_client.go: Add postStream() with no timeout for long-lived connections

Usage:
  sudo ctrld log tail          # shows last 10 lines then follows
  sudo ctrld log tail -n 50    # shows last 50 lines then follows
  Ctrl+C to stop
This commit is contained in:
Codescribe
2026-03-17 03:16:06 -04:00
committed by Cuong Manh Le
parent ca8d07d3f5
commit 86dafc432d
5 changed files with 668 additions and 3 deletions
+69 -3
View File
@@ -103,12 +103,18 @@ type logReader struct {
size int64
}
// logSubscriber represents a subscriber to live log output.
type logSubscriber struct {
ch chan []byte
}
// logWriter is an internal buffer to keep track of runtime log when no logging is enabled.
// This provides in-memory log storage for debugging and monitoring purposes
type logWriter struct {
mu sync.Mutex
buf bytes.Buffer
size int
mu sync.Mutex
buf bytes.Buffer
size int
subscribers []*logSubscriber
}
// newLogWriter creates an internal log writer.
@@ -130,12 +136,72 @@ func newLogWriterWithSize(size int) *logWriter {
return lw
}
// Subscribe returns a channel that receives new log data as it's written,
// and an unsubscribe function to clean up when done.
func (lw *logWriter) Subscribe() (<-chan []byte, func()) {
lw.mu.Lock()
defer lw.mu.Unlock()
sub := &logSubscriber{ch: make(chan []byte, 256)}
lw.subscribers = append(lw.subscribers, sub)
unsub := func() {
lw.mu.Lock()
defer lw.mu.Unlock()
for i, s := range lw.subscribers {
if s == sub {
lw.subscribers = append(lw.subscribers[:i], lw.subscribers[i+1:]...)
close(sub.ch)
break
}
}
}
return sub.ch, unsub
}
// tailLastLines returns the last n lines from the current buffer.
func (lw *logWriter) tailLastLines(n int) []byte {
lw.mu.Lock()
defer lw.mu.Unlock()
data := lw.buf.Bytes()
if n <= 0 || len(data) == 0 {
return nil
}
// Find the last n newlines from the end.
count := 0
pos := len(data)
for pos > 0 {
pos--
if data[pos] == '\n' {
count++
if count == n+1 {
pos++ // move past this newline
break
}
}
}
result := make([]byte, len(data)-pos)
copy(result, data[pos:])
return result
}
// Write implements io.Writer interface for logWriter
// This manages buffer overflow by discarding old data while preserving important markers
func (lw *logWriter) Write(p []byte) (int, error) {
lw.mu.Lock()
defer lw.mu.Unlock()
// Fan-out to subscribers (non-blocking).
if len(lw.subscribers) > 0 {
cp := make([]byte, len(p))
copy(cp, p)
for _, sub := range lw.subscribers {
select {
case sub.ch <- cp:
default:
// Drop if subscriber is slow to avoid blocking the logger.
}
}
}
// If writing p causes overflows, discard old data.
// This prevents unbounded memory growth while maintaining recent logs
if lw.buf.Len()+len(p) > lw.size {