Files
ctrld/cmd/cli/log_path_rotation_test.go
T
Anthony Wong 8934e09428 feat(cli): keep a log journal on disk with file headers and an upload limit
ctrld keeps a second log file, ctrld-journal.log, next to ctrld.log. The
journal holds all warnings and errors and each event with the field
journal=true. It stays on disk after a start of the service and after a
self-upgrade. Each log file starts with a Log header line. Each log send
upload starts with the same line, made at send time, and has a limit of
about 16 MB.
2026-09-28 16:47:40 +07:00

188 lines
5.7 KiB
Go

package cli
import (
"encoding/json"
"io"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
)
const (
logPathTestBackups = 2
logPathTestBudgetMB = 1
logPathTestLineSize = 4 * 1024
logPathTestLineCount = 300
)
// setupLogPathTest points log_path at a temporary file and puts every global
// that initLoggingWithBackup touches back when the test ends. The globals are
// the reason why no test of this package calls t.Parallel.
//
// The temporary directory comes first: its own cleanup removes the directory,
// and a cleanup runs before every cleanup that was added earlier, so the file
// must close before that.
func setupLogPathTest(t *testing.T) string {
t.Helper()
dir := t.TempDir()
origLogPath, origLogLevel := cfg.Service.LogPath, cfg.Service.LogLevel
origMaxSizeMB, origMaxBackups := cfg.Service.LogMaxSizeMB, cfg.Service.LogMaxBackups
origSilent, origVerbose := silent, verbose
origConsoleWriter := consoleWriter
origMainLog, origProxyLogger := mainLog.Load(), ctrld.ProxyLogger.Load()
origLevel, origTimeFormat := zerolog.GlobalLevel(), zerolog.TimeFieldFormat
origLogPathFile, origPendingRotation := logPathFile.Load(), pendingLogPathRotation.Load()
t.Cleanup(func() {
if rf := logPathFile.Load(); rf != nil && rf != origLogPathFile {
_ = rf.close()
}
logPathFile.Store(origLogPathFile)
pendingLogPathRotation.Store(origPendingRotation)
cfg.Service.LogPath, cfg.Service.LogLevel = origLogPath, origLogLevel
cfg.Service.LogMaxSizeMB, cfg.Service.LogMaxBackups = origMaxSizeMB, origMaxBackups
silent, verbose = origSilent, origVerbose
consoleWriter = origConsoleWriter
mainLog.Store(origMainLog)
ctrld.ProxyLogger.Store(origProxyLogger)
zerolog.SetGlobalLevel(origLevel)
zerolog.TimeFieldFormat = origTimeFormat
})
backups := logPathTestBackups
cfg.Service.LogPath = filepath.Join(dir, "ctrld.log")
cfg.Service.LogLevel = "info"
cfg.Service.LogMaxSizeMB = logPathTestBudgetMB
cfg.Service.LogMaxBackups = &backups
silent, verbose = false, 0
consoleWriter = zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { w.Out = io.Discard })
discard := zerolog.New(io.Discard)
mainLog.Store(&discard)
logPathFile.Store(nil)
pendingLogPathRotation.Store(nil)
return cfg.Service.LogPath
}
func Test_logPathBackupChainOnStart(t *testing.T) {
path := setupLogPathTest(t)
if err := os.WriteFile(path, []byte("run1\n"), 0o600); err != nil {
t.Fatalf("seed log file: %v", err)
}
initLoggingWithBackup(true)
if _, err := logPathFile.Load().Write([]byte("run2\n")); err != nil {
t.Fatalf("write run2: %v", err)
}
initLoggingWithBackup(true)
newest := rotatingFileTestContent(t, path+".1")
if !strings.Contains(newest, "run2") {
t.Fatalf("%s.1 = %q, want the run2 line", path, newest)
}
if strings.Contains(newest, "run1") {
t.Fatalf("%s.1 = %q, want no run1 line", path, newest)
}
oldest := rotatingFileTestContent(t, path+".2")
if !strings.Contains(oldest, "run1") {
t.Fatalf("%s.2 = %q, want the run1 line", path, oldest)
}
current := rotatingFileTestContent(t, path)
if strings.Contains(current, "run1") || strings.Contains(current, "run2") {
t.Fatalf("%s = %q, want neither run line", path, current)
}
}
func Test_logPathRotatesBySize(t *testing.T) {
path := setupLogPathTest(t)
initLoggingWithBackup(false)
line := strings.Repeat("x", logPathTestLineSize)
for i := 0; i < logPathTestLineCount; i++ {
mainLog.Load().Info().Msg(line)
}
if _, err := os.Stat(path + ".2"); !os.IsNotExist(err) {
t.Fatalf("%s.2 exists although the lines fill one backup (stat err = %v)", path, err)
}
want := []string{path + ".1", path}
if got := logPathFile.Load().paths(); !slices.Equal(got, want) {
t.Fatalf("paths() = %v, want %v", got, want)
}
rotatedLines := 0
for _, logLine := range strings.Split(rotatingFileTestContent(t, path), "\n") {
if !strings.Contains(logLine, "Log rotated") {
continue
}
rotatedLines++
if !strings.Contains(logLine, string(journalMarker)) {
t.Fatalf("Log rotated line %q carries no journal marker", logLine)
}
}
if rotatedLines != 1 {
t.Fatalf("Log rotated lines in %s = %d, want 1", path, rotatedLines)
}
}
func Test_logPathHeaderLeadsEveryFile(t *testing.T) {
path := setupLogPathTest(t)
stubLogHeaderNetworkRead(t, "en0")
p := &prog{cfg: &cfg}
// A start wires its logging twice: once for the config it reads from
// disk, and once more for the config that the API sends.
p.initLogging(true)
p.initLogging(false)
line := strings.Repeat("x", logPathTestLineSize)
for i := 0; i < logPathTestLineCount; i++ {
mainLog.Load().Info().Msg(line)
}
for _, name := range []string{path + ".1", path} {
firstLine, headers := logHeaderFileLines(t, name)
if firstLine.Message != logHeaderMessage {
t.Errorf("line 1 of %s = %q, want %q", name, firstLine.Message, logHeaderMessage)
}
if headers != 1 {
t.Errorf("%s holds %d header lines, want 1", name, headers)
}
}
}
func Test_logPathStartBackupLogsRotation(t *testing.T) {
path := setupLogPathTest(t)
stubLogHeaderNetworkRead(t, "en0")
if err := os.WriteFile(path, []byte(`{"level":"info","message":"run1"}`+"\n"), 0o600); err != nil {
t.Fatalf("seed log file: %v", err)
}
p := &prog{cfg: &cfg}
p.initLogging(true)
rotatedLines := 0
for _, logLine := range strings.Split(rotatingFileTestContent(t, path), "\n") {
if !strings.Contains(logLine, "Log rotated") {
continue
}
rotatedLines++
var event struct {
File string `json:"file"`
}
if err := json.Unmarshal([]byte(logLine), &event); err != nil {
t.Fatalf("parse %q: %v", logLine, err)
}
if event.File != path+".1" {
t.Errorf("file = %q, want %q", event.File, path+".1")
}
}
if rotatedLines != 1 {
t.Fatalf("Log rotated lines in %s = %d, want 1", path, rotatedLines)
}
}