log: persist internal runtime logs to disk

Add file-backed persistence to the internal logWriter so runtime logs
survive service restarts. When internal logging is enabled (CD mode,
no explicit log_path), writes are teed to both the existing in-memory
ring buffer and a rotated file on disk (ctrld.log in the home directory).

File rotation: 5MB max with 1 backup (ctrld.log.1), so max ~10MB on disk.
Log view/send now reads from the persisted files (including backup) to
provide complete history across restarts. Live tail continues to use
the in-memory subscriber mechanism unchanged.

Activation: same conditions as existing internal logging — CD mode only,
no log_path configured. No new config options or dependencies.
This commit is contained in:
Codescribe
2026-04-21 17:40:51 -04:00
committed by Cuong Manh Le
parent d1ea70d688
commit afed925404
2 changed files with 323 additions and 1 deletions
+125
View File
@@ -1,6 +1,8 @@
package cli
import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
@@ -83,3 +85,126 @@ func Test_logWriter_MarkerInitEnd(t *testing.T) {
t.Fatalf("unexpected log content: %s", lw.buf.String())
}
}
func Test_logWriter_SetLogFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.log")
lw := newLogWriterWithSize(logWriterSize)
if err := lw.setLogFile(path); err != nil {
t.Fatalf("setLogFile: %v", err)
}
defer lw.closeLogFile()
msg := "hello file\n"
lw.Write([]byte(msg))
// Verify data in memory buffer.
if lw.buf.String() != msg {
t.Fatalf("buffer: got %q, want %q", lw.buf.String(), msg)
}
// Verify data on disk.
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(data) != msg {
t.Fatalf("file: got %q, want %q", data, msg)
}
}
func Test_logWriter_FileRotation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.log")
// Use a tiny max size to trigger rotation quickly.
lw := newLogWriterWithSize(logWriterSize)
if err := lw.setLogFile(path); err != nil {
t.Fatalf("setLogFile: %v", err)
}
defer lw.closeLogFile()
// Write enough to exceed logFileMaxSize.
chunk := strings.Repeat("X", 1024) + "\n"
written := 0
for written < logFileMaxSize+1024 {
lw.Write([]byte(chunk))
written += len(chunk)
}
// Backup file should exist.
backupPath := path + ".1"
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
t.Fatal("expected backup file to exist after rotation")
}
// Current file should be smaller than max (it was rotated).
st, err := os.Stat(path)
if err != nil {
t.Fatalf("stat current: %v", err)
}
if st.Size() > logFileMaxSize {
t.Fatalf("current file too large after rotation: %d", st.Size())
}
}
func Test_logWriter_FilePaths(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.log")
lw := newLogWriterWithSize(logWriterSize)
// No file configured.
c, b := lw.logFilePaths()
if c != "" || b != "" {
t.Fatalf("expected empty paths, got %q %q", c, b)
}
if err := lw.setLogFile(path); err != nil {
t.Fatalf("setLogFile: %v", err)
}
defer lw.closeLogFile()
// Current exists, no backup yet.
c, b = lw.logFilePaths()
if c != path {
t.Fatalf("current: got %q, want %q", c, path)
}
if b != "" {
t.Fatalf("backup should be empty, got %q", b)
}
// Create a backup file manually.
os.WriteFile(path+".1", []byte("old"), 0600)
_, b = lw.logFilePaths()
if b != path+".1" {
t.Fatalf("backup: got %q, want %q", b, path+".1")
}
}
func Test_logWriter_FileAppendOnRestart(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.log")
// Simulate first run.
lw1 := newLogWriterWithSize(logWriterSize)
if err := lw1.setLogFile(path); err != nil {
t.Fatalf("setLogFile: %v", err)
}
lw1.Write([]byte("run1\n"))
lw1.closeLogFile()
// Simulate second run (restart) — file should be appended.
lw2 := newLogWriterWithSize(logWriterSize)
if err := lw2.setLogFile(path); err != nil {
t.Fatalf("setLogFile: %v", err)
}
lw2.Write([]byte("run2\n"))
lw2.closeLogFile()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
want := "run1\nrun2\n"
if string(data) != want {
t.Fatalf("file: got %q, want %q", data, want)
}
}