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.
This commit is contained in:
Anthony Wong authored and Cuong Manh Le committed 2026-09-28 16:47:40 +07:00
1 parent d1690545f9
commit 8934e09428
41 files changed
+5661 -495

No files matched your search

+1
View File
@@ -18,3 +18,4 @@ ctrld.exe
# Local planning artifacts - never commit
/SPEC.md
/tasks/
/SPEC-*.md
+2 -1
View File
@@ -49,7 +49,6 @@ const selfCheckInternalTestDomain = "ctrld" + loopTestDomain
const (
windowsForwardersFilename = ".forwarders.txt"
oldBinSuffix = "_previous"
oldLogSuffix = ".1"
msgExit = "$$EXIT$$"
)
@@ -1497,6 +1496,8 @@ func fieldErrorMsg(fe validator.FieldError) string {
return fmt.Sprintf("minimum len: %q", fe.Param())
case "gte":
return fmt.Sprintf("must be greater than or equal to: %s", fe.Param())
case "lte":
return fmt.Sprintf("must be less than or equal to: %s", fe.Param())
case "cidr":
return fmt.Sprintf("invalid value: %s", fe.Value())
case "required_unless", "required":
+58 -10
View File
@@ -36,11 +36,52 @@ import (
// dialSocketControlServerTimeout is the default timeout to wait when ping control server.
const dialSocketControlServerTimeout = 30 * time.Second
// logRequestPath adds the query that makes the control server read every log
// file, not only the newest debug bytes.
func logRequestPath(path string, full bool) string {
if !full {
return path
}
return path + "?full=1"
}
// removeLogFiles deletes the log files of this installation and returns what
// it could not remove. A missing file is not an error.
func removeLogFiles(logPath string, backups int, internalPaths []string) []error {
for _, path := range internalPaths {
pruneNumberedBackups(path, 0)
}
var errs []error
for _, path := range logFilesToRemove(logPath, backups, internalPaths) {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Errorf("remove %s: %w", path, err))
}
}
return errs
}
// logFilesToRemove names the log files of this installation. The log_path
// backups come from the configured count, because log_path can name a file in
// a directory that holds the files of other programs, and a scan of that
// directory would take their files too. An empty logPath leaves the internal
// files alone.
func logFilesToRemove(logPath string, backups int, internalPaths []string) []string {
var paths []string
if logPath != "" {
paths = append(paths, logPath)
for index := 1; index <= backups; index++ {
paths = append(paths, fmt.Sprintf("%s.%d", logPath, index))
}
}
return append(paths, internalPaths...)
}
func initLogCmd() *cobra.Command {
warnRuntimeLoggingNotEnabled := func() {
mainLog.Load().Warn().Msg("runtime debug logging is not enabled")
mainLog.Load().Warn().Msg(`ctrld may be running without "--cd" flag or logging is already enabled`)
}
var sendFullLogs bool
logSendCmd := &cobra.Command{
Use: "send",
Short: "Send runtime debug logs to ControlD",
@@ -68,7 +109,7 @@ func initLogCmd() *cobra.Command {
mainLog.Load().Fatal().Err(err).Msg("failed to find ctrld home dir")
}
cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock))
resp, err := cc.post(sendLogsPath, nil)
resp, err := cc.post(logRequestPath(sendLogsPath, sendFullLogs), nil)
if err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to send logs")
}
@@ -94,6 +135,9 @@ func initLogCmd() *cobra.Command {
}
},
}
logSendCmd.Flags().BoolVar(&sendFullLogs, "full", false, "Send every log file, not only the newest 10 MB of debug")
var viewFullLogs bool
logViewCmd := &cobra.Command{
Use: "view",
Short: "View current runtime debug logs",
@@ -121,7 +165,7 @@ func initLogCmd() *cobra.Command {
mainLog.Load().Fatal().Err(err).Msg("failed to find ctrld home dir")
}
cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock))
resp, err := cc.post(viewLogsPath, nil)
resp, err := cc.post(logRequestPath(viewLogsPath, viewFullLogs), nil)
if err != nil {
mainLog.Load().Fatal().Err(err).Msg("failed to get logs")
}
@@ -148,6 +192,8 @@ func initLogCmd() *cobra.Command {
fmt.Println(logs.Data)
},
}
logViewCmd.Flags().BoolVar(&viewFullLogs, "full", false, "Show every log file, not only the newest 10 MB of debug")
var tailLines int
logTailCmd := &cobra.Command{
Use: "tail",
@@ -1247,14 +1293,16 @@ NOTE: Uninstalling will set DNS to values provided by DHCP.`,
var files []string
// Config file.
files = append(files, v.ConfigFileUsed())
// Log file and backup log file.
// For safety, only process if log file path is absolute.
if logFile := normalizeLogFilePath(cfg.Service.LogPath); filepath.IsAbs(logFile) {
files = append(files, logFile)
oldLogFile := logFile + oldLogSuffix
if _, err := os.Stat(oldLogFile); err == nil {
files = append(files, oldLogFile)
}
// Log files. For safety, only remove the log_path chain if
// that path is absolute.
logFile := normalizeLogFilePath(cfg.Service.LogPath)
if !filepath.IsAbs(logFile) {
logFile = ""
}
backups := debugLogBudget(&cfg.Service, router.Name() != "").backups
internalLogs := []string{absHomeDir(logFileName), absHomeDir(journalLogFileName)}
for _, err := range removeLogFiles(logFile, backups, internalLogs) {
mainLog.Load().Warn().Err(err).Msg("failed to remove log file")
}
// Socket files.
if dir, _ := socketDir(); dir != "" {
+71
View File
@@ -0,0 +1,71 @@
package cli
import (
"os"
"path/filepath"
"testing"
)
// writeCleanupTestFile creates a file that a cleanup test can watch.
func writeCleanupTestFile(t *testing.T, path string) string {
t.Helper()
if err := os.WriteFile(path, []byte("line\n"), 0600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}
func assertRemoved(t *testing.T, paths []string) {
t.Helper()
for _, path := range paths {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("%s is still there: %v", path, err)
}
}
}
func Test_removeLogFiles(t *testing.T) {
dir := t.TempDir()
logPath := writeCleanupTestFile(t, filepath.Join(dir, "syslog"))
chain := []string{
writeCleanupTestFile(t, filepath.Join(dir, "syslog.1")),
writeCleanupTestFile(t, filepath.Join(dir, "syslog.2")),
}
// A log_path can name a file in a directory of another program, so a
// number above the configured count belongs to that program.
foreign := writeCleanupTestFile(t, filepath.Join(dir, "syslog.5"))
home := t.TempDir()
internal := []string{
writeCleanupTestFile(t, filepath.Join(home, logFileName)),
writeCleanupTestFile(t, filepath.Join(home, journalLogFileName)),
}
internalBackups := []string{
writeCleanupTestFile(t, filepath.Join(home, logFileName+".3")),
writeCleanupTestFile(t, filepath.Join(home, journalLogFileName+".1")),
}
if errs := removeLogFiles(logPath, len(chain), internal); len(errs) > 0 {
t.Fatalf("removeLogFiles: %v", errs)
}
assertRemoved(t, append([]string{logPath}, chain...))
assertRemoved(t, append(internal, internalBackups...))
if _, err := os.Stat(foreign); err != nil {
t.Fatalf("the cleanup removed %s, which sits above the backup count: %v", foreign, err)
}
}
func Test_removeLogFilesWithoutALogPath(t *testing.T) {
home := t.TempDir()
internal := []string{writeCleanupTestFile(t, filepath.Join(home, logFileName))}
kept := writeCleanupTestFile(t, filepath.Join(home, "other.log"))
if errs := removeLogFiles("", 4, internal); len(errs) > 0 {
t.Fatalf("removeLogFiles: %v", errs)
}
assertRemoved(t, internal)
if _, err := os.Stat(kept); err != nil {
t.Fatalf("the cleanup removed %s, which is not a ctrld log: %v", kept, err)
}
}
+16 -2
View File
@@ -5,6 +5,8 @@ import (
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
@@ -25,13 +27,25 @@ func newControlClient(addr string) *controlClient {
}
func (c *controlClient) post(path string, data io.Reader) (*http.Response, error) {
// for log/send, set the timeout to 5 minutes
if path == sendLogsPath {
// A log send uploads many megabytes, so it needs more time than the other
// calls.
if isSendLogsPath(path) {
c.c.Timeout = time.Minute * 5
}
return c.c.Post("http://unix"+path, contentTypeJson, data)
}
// isSendLogsPath reports whether path addresses the log send handler. The path
// can carry a query, so the comparison takes the path alone. A path that does
// not parse keeps the prefix rule, because it still reaches that handler.
func isSendLogsPath(path string) bool {
parsed, err := url.Parse(path)
if err != nil {
return strings.HasPrefix(path, sendLogsPath)
}
return parsed.Path == sendLogsPath
}
// postStream sends a POST request with no timeout, suitable for long-lived streaming connections.
func (c *controlClient) postStream(path string, data io.Reader) (*http.Response, error) {
c.c.Timeout = 0
+107 -63
View File
@@ -309,34 +309,13 @@ func (p *prog) registerControlServerHandler() {
return
}
}))
p.cs.register(viewLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
lr, err := p.logReader()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer lr.r.Close()
if lr.size == 0 {
w.WriteHeader(http.StatusMovedPermanently)
return
}
data, err := io.ReadAll(lr.r)
if err != nil {
http.Error(w, fmt.Sprintf("could not read log: %v", err), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(&logViewResponse{Data: string(data)}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
http.Error(w, fmt.Sprintf("could not marshal log data: %v", err), http.StatusInternalServerError)
return
}
}))
p.cs.register(viewLogsPath, http.HandlerFunc(p.handleLogView))
p.cs.register(sendLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if time.Since(p.internalLogSent) < logWriterSentInterval {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
r, err := p.logReader()
r, err := p.logReader(wantFullLogs(request))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -430,50 +409,115 @@ func (p *prog) registerControlServerHandler() {
}
} else {
// File-based logging mode: tail the log file.
logFile := normalizeLogFilePath(p.cfg.Service.LogPath)
f, err := os.Open(logFile)
if err != nil {
// Already committed 200, just return.
return
}
defer f.Close()
// Seek to show last N lines.
if numLines > 0 {
if tail := tailFileLastLines(f, numLines); len(tail) > 0 {
w.Write(tail)
flusher.Flush()
}
} else {
// Seek to end.
f.Seek(0, io.SeekEnd)
}
// Poll for new data.
buf := make([]byte, 4096)
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
n, err := f.Read(buf)
if n > 0 {
if _, werr := w.Write(buf[:n]); werr != nil {
return
}
flusher.Flush()
}
if err != nil && err != io.EOF {
return
}
case <-request.Context().Done():
return
}
}
followLogFile(request.Context(), w, flusher, normalizeLogFilePath(p.cfg.Service.LogPath), numLines)
}
}))
}
// jsonStringChunkSize bounds the memory that one log view answer needs. A log
// of tens of megabytes must not get a second copy in the answer.
const jsonStringChunkSize = 32 * 1024
// wantFullLogs reports whether the request asks for every log file instead of
// the newest debug bytes.
func wantFullLogs(r *http.Request) bool {
return r.URL.Query().Get("full") == "1"
}
// handleLogView answers with the log bytes in the data field. It writes the
// body while it reads the log, so the answer needs no copy of the whole log.
func (p *prog) handleLogView(w http.ResponseWriter, r *http.Request) {
lr, err := p.logReader(wantFullLogs(r))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer lr.r.Close()
if lr.size == 0 {
w.WriteHeader(http.StatusMovedPermanently)
return
}
w.Header().Set("Content-Type", contentTypeJson)
if _, err := io.WriteString(w, `{"data":`); err != nil {
return
}
if err := writeJSONString(w, lr.r); err != nil {
// The answer is on its way, so it can only end short. This line tells
// the operator why.
mainLog.Load().Error().Err(err).Msg("could not send log view answer")
return
}
_, _ = io.WriteString(w, `}`)
}
// writeJSONString writes what r yields as one JSON string, in chunks.
func writeJSONString(w io.Writer, r io.Reader) error {
if _, err := io.WriteString(w, `"`); err != nil {
return err
}
chunk := make([]byte, jsonStringChunkSize)
for {
n, readErr := r.Read(chunk)
if n > 0 {
if err := writeJSONStringChunk(w, chunk[:n]); err != nil {
return err
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return readErr
}
}
_, err := io.WriteString(w, `"`)
return err
}
// writeJSONStringChunk writes one chunk and escapes the bytes that a JSON
// string cannot carry. It writes the bytes between two escapes in one call.
func writeJSONStringChunk(w io.Writer, chunk []byte) error {
start := 0
for i, b := range chunk {
escaped := jsonStringEscape(b)
if escaped == "" {
continue
}
if _, err := w.Write(chunk[start:i]); err != nil {
return err
}
if _, err := io.WriteString(w, escaped); err != nil {
return err
}
start = i + 1
}
_, err := w.Write(chunk[start:])
return err
}
// jsonStringEscape returns the escape of one byte, or an empty string for a
// byte that a JSON string carries as it is. Every byte of a multi-byte
// character is above 0x1f, so a chunk that ends inside a character still
// writes the character whole.
func jsonStringEscape(b byte) string {
switch b {
case '"':
return `\"`
case '\\':
return `\\`
case '\n':
return `\n`
case '\r':
return `\r`
case '\t':
return `\t`
}
if b < 0x20 {
return fmt.Sprintf(`\u%04x`, b)
}
return ""
}
// tailFileLastLines reads the last n lines from a file and returns them.
// The file position is left at the end of the file after this call.
func tailFileLastLines(f *os.File, n int) []byte {
+104
View File
@@ -0,0 +1,104 @@
package cli
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
)
// streamRecorder collects a streamed response under a lock, because the
// handler writes from its own goroutine while the test reads.
type streamRecorder struct {
mu sync.Mutex
header http.Header
body strings.Builder
}
func (r *streamRecorder) Header() http.Header { return r.header }
func (r *streamRecorder) Write(p []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.body.Write(p)
}
func (r *streamRecorder) WriteHeader(int) {}
func (r *streamRecorder) Flush() {}
func (r *streamRecorder) String() string {
r.mu.Lock()
defer r.mu.Unlock()
return r.body.String()
}
// waitForText polls the recorder until want arrives or the deadline passes.
func waitForText(rec *streamRecorder, want string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if strings.Contains(rec.String(), want) {
return true
}
time.Sleep(20 * time.Millisecond)
}
return false
}
// Test_logTailFollowsTheRotatedLogPath keeps one tail request open while the
// log_path file rotates. The lines after the rotation must reach the client.
func Test_logTailFollowsTheRotatedLogPath(t *testing.T) {
origUID := cdUID
cdUID = ""
t.Cleanup(func() { cdUID = origUID })
path := filepath.Join(t.TempDir(), "ctrld.log")
rf, err := newRotatingFile(path, logBudget{maxSize: 64, backups: 1}, nil)
if err != nil {
t.Fatalf("newRotatingFile: %v", err)
}
t.Cleanup(func() { _ = rf.close() })
if _, err := rf.Write([]byte("before rotation\n")); err != nil {
t.Fatal(err)
}
p := &prog{
cfg: &ctrld.Config{Service: ctrld.ServiceConfig{LogPath: path}},
cs: &controlServer{mux: http.NewServeMux()},
}
p.registerControlServerHandler()
ctx, cancel := context.WithCancel(context.Background())
req := httptest.NewRequest(http.MethodGet, tailLogsPath+"?lines=1", nil).WithContext(ctx)
rec := &streamRecorder{header: http.Header{}}
done := make(chan struct{})
go func() {
defer close(done)
p.cs.mux.ServeHTTP(rec, req)
}()
t.Cleanup(func() {
cancel()
<-done
})
if !waitForText(rec, "before rotation", 2*time.Second) {
t.Fatalf("the first line did not arrive: %q", rec.String())
}
for i := 0; i < 4; i++ {
if _, err := rf.Write([]byte("filler line that crosses the budget\n")); err != nil {
t.Fatal(err)
}
}
if _, err := rf.Write([]byte("after rotation sentinel\n")); err != nil {
t.Fatal(err)
}
if !waitForText(rec, "after rotation sentinel", 3*time.Second) {
t.Fatalf("the sentinel did not arrive after the rotation: %q", rec.String())
}
}
+108
View File
@@ -2,10 +2,14 @@ package cli
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
"strings"
"testing"
"github.com/Control-D-Inc/ctrld"
)
func TestControlServer(t *testing.T) {
@@ -52,3 +56,107 @@ func TestControlServer(t *testing.T) {
t.Fatal(err)
}
}
// jsonStringTestPayload holds every byte class that a JSON string must escape
// and splits a multi-byte character across the chunk boundary, so a chunk that
// ends inside a character is covered.
func jsonStringTestPayload() []byte {
var payload bytes.Buffer
payload.WriteString("quotes \" backslash \\ tab\t return\r newline\n")
payload.Write([]byte{0x00, 0x01, 0x1f})
for payload.Len() < jsonStringChunkSize-1 {
payload.WriteByte('x')
}
payload.WriteString("é日本語 and an emoji 🙂\n")
payload.WriteString("the tail after the boundary\n")
return payload.Bytes()
}
func Test_writeJSONString(t *testing.T) {
payload := jsonStringTestPayload()
var written bytes.Buffer
if err := writeJSONString(&written, bytes.NewReader(payload)); err != nil {
t.Fatalf("writeJSONString: %v", err)
}
var got string
if err := json.Unmarshal(written.Bytes(), &got); err != nil {
t.Fatalf("unmarshal %d bytes: %v", written.Len(), err)
}
if got != string(payload) {
t.Fatalf("the round trip returned %d bytes, want the %d bytes of the payload", len(got), len(payload))
}
}
// newLogViewTestServer serves the log view handler of p on a temporary socket
// and returns a client of it.
func newLogViewTestServer(t *testing.T, p *prog) *controlClient {
t.Helper()
f, err := os.CreateTemp("", "")
if err != nil {
t.Fatalf("temporary socket: %v", err)
}
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
s, err := newControlServer(f.Name())
if err != nil {
t.Fatalf("newControlServer: %v", err)
}
s.register(viewLogsPath, http.HandlerFunc(p.handleLogView))
if err := s.start(); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() { _ = s.stop() })
return newControlClient(f.Name())
}
func Test_logViewHandler(t *testing.T) {
t.Run("answers the log as one JSON string", func(t *testing.T) {
p := setupMemoryLogReaderTest(t)
line := "a line with \"quotes\", a tab\there and 日本語\n"
if _, err := p.internalLogWriter.Write([]byte(line)); err != nil {
t.Fatalf("write the debug buffer: %v", err)
}
resp, err := newLogViewTestServer(t, p).post(viewLogsPath, nil)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if ct := resp.Header.Get("content-type"); ct != contentTypeJson {
t.Fatalf("content type = %s, want %s", ct, contentTypeJson)
}
var view logViewResponse
if err := json.NewDecoder(resp.Body).Decode(&view); err != nil {
t.Fatalf("decode the answer: %v", err)
}
if !strings.Contains(view.Data, line) {
t.Fatalf("the answer holds %.64q, want the line %q in it", view.Data, line)
}
if !strings.Contains(view.Data, logWriterLogEndMarker) {
t.Fatalf("the answer holds no log end marker: %.64q", view.Data)
}
})
t.Run("answers 301 without a log", func(t *testing.T) {
origSilent, origCdUID := silent, cdUID
t.Cleanup(func() { silent, cdUID = origSilent, origCdUID })
silent, cdUID = false, ""
p := &prog{cfg: &ctrld.Config{}}
resp, err := newLogViewTestServer(t, p).post(viewLogsPath, nil)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMovedPermanently {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMovedPermanently)
}
})
}
+33 -16
View File
@@ -270,7 +270,7 @@ func (p *prog) serveDNS(listenerNum string) error {
p.forceFetchingAPI(domain)
}()
if err := w.WriteMsg(answer); err != nil {
ctrld.Log(ctx, mainLog.Load().Error().Err(err), "serveDNS: failed to send DNS response to client")
ctrld.Log(ctx, p.querySampler.event(sampleClassSendResponseFailed, "").Err(err), "serveDNS: failed to send DNS response to client")
}
})
@@ -842,26 +842,25 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, req.ci)
}
answer, err := resolve1(upstream, upstreamConfig, msg)
// if we have an answer, we should reset the failure count
// we dont use reset here since we dont want to prevent failure counts from being incremented
// reset is not used here, because it also stops the failure count for
// one second, and the failures already in flight must still count.
if answer != nil {
p.um.mu.Lock()
p.um.failureReq[upstream] = 0
p.um.down[upstream] = false
p.um.mu.Unlock()
p.um.noteSuccess(upstream)
return answer
}
// A failing minute must not push the state events out of the retained log.
// A transport error names the endpoint it failed to reach, which for a
// generated Internal Domain upstream is the organization's private
// resolver address. Report the classification at error level and keep
// the address-bearing error at debug.
// resolver address. Report the classification through the sampler and
// keep the address-bearing error at debug.
if isInternalDomainUpstream(upstream) {
ctrld.Log(ctx, mainLog.Load().Debug().Err(err), "failed to resolve query")
ctrld.Log(ctx, mainLog.Load().Error().Str("failure", internalDomainFailureReason(err)),
ctrld.Log(ctx, p.querySampler.event(sampleClassResolveFailed, upstream).Str("failure", internalDomainFailureReason(err)),
"failed to resolve query using an Internal Domain resolver")
} else {
ctrld.Log(ctx, mainLog.Load().Error().Err(err), "failed to resolve query")
ctrld.Log(ctx, p.querySampler.event(sampleClassResolveFailed, upstream).Err(err), "failed to resolve query")
}
// increase failure count when there is no answer
@@ -973,7 +972,7 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
res.upstream = upstreamConfig.Endpoint
return res
}
ctrld.Log(ctx, mainLog.Load().Error(), "all %v endpoints failed", upstreams)
ctrld.Log(ctx, p.querySampler.event(sampleClassAllEndpointsFailed, ""), "all %v endpoints failed", upstreams)
// An Internal Domain with explicit resolvers is served only by the
// configured resolvers. When all of them are unreachable the query fails
@@ -1713,6 +1712,7 @@ func (p *prog) handleNetworkChange(delta *netmon.ChangeDelta, isMajorChange bool
validateDefaultLocalIPsFromDelta(sourceState, transitionID)
after4, after6 := ctrld.GetDefaultLocalIPv4(), ctrld.GetDefaultLocalIPv6()
p.networkSourceMu.Unlock()
p.noteNetworkState(currentState)
p.handleDNS64NetworkChange(delta, isMajorChange)
validIfaces := networkChangeValidInterfacesFn()
@@ -2055,9 +2055,26 @@ func interfaceIPsEqual(a, b []netip.Prefix) bool {
var errOsHealthcheckSuppressed = errors.New("upstream os health check suppressed")
// upstreamFailureLog keeps one error line for each upstream of one recovery
// pass. The pass retries every two seconds until the upstream answers, and the
// first line already names the fault, so the failures that follow it go to
// debug.
type upstreamFailureLog struct{ reported bool }
// report logs one failed check of upstream.
func (l *upstreamFailureLog) report(upstream string, uc *ctrld.UpstreamConfig, err error, duration time.Duration) {
level := mainLog.Load().Error
if l.reported {
level = mainLog.Load().Debug
}
l.reported = true
logUpstreamProbeFailure(isGeneratedInternalDomainUpstreamRef(upstream, uc), uc, err, level,
"Upstream %s check failed after %v", upstream, duration)
}
// checkUpstreamOnce sends a test query to the specified upstream.
// Returns nil if the upstream responds successfully.
func (p *prog) checkUpstreamOnce(upstream string, uc *ctrld.UpstreamConfig) error {
func (p *prog) checkUpstreamOnce(upstream string, uc *ctrld.UpstreamConfig, failures *upstreamFailureLog) error {
mainLog.Load().Debug().Msgf("Starting check for upstream: %s", upstream)
resolver, err := ctrld.NewResolver(uc)
@@ -2102,8 +2119,7 @@ func (p *prog) checkUpstreamOnce(upstream string, uc *ctrld.UpstreamConfig) erro
mainLog.Load().Debug().Err(err).Msgf("Upstream %s check failed after %v (network unreachable)", upstream, duration)
return err
}
logUpstreamProbeFailure(isGeneratedInternalDomainUpstreamRef(upstream, uc), uc, err,
mainLog.Load().Error, "Upstream %s check failed after %v", upstream, duration)
failures.report(upstream, uc, err, duration)
return err
}
mainLog.Load().Debug().Msgf("Upstream %s responded successfully in %v", upstream, duration)
@@ -2339,6 +2355,7 @@ func (p *prog) waitForUpstreamRecovery(ctx context.Context, upstreams map[string
mainLog.Load().Debug().Msgf("Starting recovery check loop for upstream: %s", name)
attempts := 0
unreachableStreak := 0
var failures upstreamFailureLog
for {
select {
case <-recoveryCtx.Done():
@@ -2347,7 +2364,7 @@ func (p *prog) waitForUpstreamRecovery(ctx context.Context, upstreams map[string
default:
attempts++
// checkUpstreamOnce will reset any failure counters on success.
err := p.checkUpstreamOnce(name, uc)
err := p.checkUpstreamOnce(name, uc, &failures)
if err == nil || errors.Is(err, errOsHealthcheckSuppressed) {
mainLog.Load().Debug().Msgf("Upstream %s recovered successfully", name)
select {
+124
View File
@@ -0,0 +1,124 @@
package cli
import (
"context"
"strings"
"testing"
"time"
"github.com/miekg/dns"
"github.com/Control-D-Inc/ctrld"
)
// deadUpstreamEndpoint is a port that nothing listens on, so every query to it
// fails at once.
const deadUpstreamEndpoint = "127.0.0.1:1"
// countSamplingLines counts the captured lines of one level whose message
// starts with the given text. A request id rides in front of the message.
func countSamplingLines(t *testing.T, logs *syncBuffer, level, message string) int {
t.Helper()
count := 0
for _, event := range jsonLogEvents(t, logs, "") {
text, _ := event["message"].(string)
if !strings.HasPrefix(text, message) || event["level"] != level {
continue
}
count++
}
return count
}
func Test_querySamplingBoundsPerQueryErrors(t *testing.T) {
logs := captureDebugMainLog(t)
p := &prog{}
// The zero value sampler arms a real timer. Close its window after the
// assertions, so no late summary line reaches another test.
t.Cleanup(func() { p.querySampler.closeExpired(time.Now().Add(querySampleWindow)) })
for i := 0; i < querySampleLimit+1; i++ {
ctrld.Log(context.Background(), p.querySampler.event(sampleClassResolveFailed, "upstream.0"), samplerTestMessage)
}
if got := countSamplingLines(t, logs, "error", samplerTestMessage); got != querySampleLimit {
t.Fatalf("error lines: got %d, want %d", got, querySampleLimit)
}
if got := countSamplingLines(t, logs, "debug", samplerTestMessage); got != 1 {
t.Fatalf("debug lines: got %d, want 1", got)
}
}
// askThroughProxy sends one query through the real proxy path and returns the
// answer.
func askThroughProxy(t *testing.T, p *prog, ctx context.Context) *dns.Msg {
t.Helper()
msg := new(dns.Msg)
msg.SetQuestion("sampling.test.", dns.TypeA)
res := p.proxy(ctx, &proxyRequest{msg: msg, ufr: &upstreamForResult{srcAddr: "192.168.0.1:1234"}})
if res == nil || res.answer == nil {
t.Fatal("the proxy returned no answer")
}
return res.answer
}
// Test_querySamplingOnTheQueryPath drives the real query path with an upstream
// that answers nothing: the flood keeps its request id, the sampler bounds it,
// and one state event brackets each end of the outage.
func Test_querySamplingOnTheQueryPath(t *testing.T) {
logs := captureDebugMainLog(t)
cfg := &ctrld.Config{}
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
p := &prog{cfg: cfg, um: newUpstreamMonitor(cfg)}
// A new monitor holds back the failure counts for one second. The test
// clears the flag instead of waiting for that timer.
p.um.clearRecovered(upstreamOS)
var armed []func()
p.um.after = func(_ time.Duration, fn func()) { armed = append(armed, fn) }
// The zero value sampler arms a real timer. Close its window after the
// assertions, so no late summary line reaches another test.
t.Cleanup(func() { p.querySampler.closeExpired(time.Now().Add(querySampleWindow)) })
origOSUpstream := osUpstreamConfig
t.Cleanup(func() { osUpstreamConfig = origOSUpstream })
osUpstreamConfig = &ctrld.UpstreamConfig{
Name: "dead resolver",
Type: ctrld.ResolverTypeLegacy,
Endpoint: deadUpstreamEndpoint,
Timeout: 500,
}
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, "r1")
for i := 0; i < querySampleLimit+2; i++ {
if rcode := askThroughProxy(t, p, ctx).Rcode; rcode != dns.RcodeServerFailure {
t.Fatalf("query %d: rcode %s, want SERVFAIL", i+1, dns.RcodeToString[rcode])
}
}
const failedMessage = "[r1] " + samplerTestMessage
if got := countSamplingLines(t, logs, "error", failedMessage); got != querySampleLimit {
t.Fatalf("error lines: got %d, want %d", got, querySampleLimit)
}
if got := countSamplingLines(t, logs, "debug", failedMessage); got != 2 {
t.Fatalf("debug lines: got %d, want 2", got)
}
if len(armed) != 1 {
t.Fatalf("armed timers: got %d, want 1", len(armed))
}
armed[0]()
startOSResolverStub(t)
if rcode := askThroughProxy(t, p, ctx).Rcode; rcode != dns.RcodeSuccess {
t.Fatalf("the answered query returned %s, want NOERROR", dns.RcodeToString[rcode])
}
events := jsonLogEvents(t, logs, upstreamStateMessage)
if len(events) != 2 {
t.Fatalf("state events: got %d, want 2", len(events))
}
wantField(t, events[0], "upstream", upstreamOS)
wantField(t, events[0], "state", "down")
wantField(t, events[0], "reason", "timer")
wantField(t, events[1], "upstream", upstreamOS)
wantField(t, events[1], "state", "up")
}
+1 -1
View File
@@ -592,7 +592,7 @@ func TestInternalDomainsRecoveryCheckHidesResolverAddress(t *testing.T) {
}
mark := len(logOutput.String())
if err := p.checkUpstreamOnce("upstream.internal_0", uc); err == nil {
if err := p.checkUpstreamOnce("upstream.internal_0", uc, &upstreamFailureLog{}); err == nil {
t.Fatal("expected the probe to fail")
}
above := logLinesAboveDebug(mark)
+45
View File
@@ -0,0 +1,45 @@
package cli
import (
"github.com/Control-D-Inc/ctrld"
)
// journalLogFileName is the journal file next to the debug log in the ctrld
// home directory.
const journalLogFileName = "ctrld-journal.log"
// logBudgets splits the disk space by platform. A router keeps the 10 MB total
// that QA measured on an EdgeRouter-X, because its flash is small. A desktop
// holds more, so a log covers more days.
func logBudgets(isRouter bool) (debug, journal logBudget) {
if isRouter {
return logBudget{maxSize: 5 << 20, backups: 1}, logBudget{maxSize: 1 << 20, backups: 1}
}
return logBudget{maxSize: 10 << 20, backups: 4}, logBudget{maxSize: 2 << 20, backups: 2}
}
const (
// logMaxSizeMBLimit and logMaxBackupsLimit bound the two keys. The log
// file opens before ctrld validates its configuration, so a key outside
// these bounds reaches this code: a size near the largest integer never
// rotates, and a count of one million backups renames one million files.
// The validation rules are the first line of defense, and these bounds
// are the second.
logMaxSizeMBLimit = 1024
logMaxBackupsLimit = 64
)
// debugLogBudget starts from the platform default, so a router keeps its small
// total until an operator asks for more. The two keys size the debug stream
// only. The journal budget stays fixed. A key outside its bounds keeps the
// default.
func debugLogBudget(svc *ctrld.ServiceConfig, isRouter bool) logBudget {
budget, _ := logBudgets(isRouter)
if mb := svc.LogMaxSizeMB; mb >= 1 && mb <= logMaxSizeMBLimit {
budget.maxSize = int64(mb) << 20
}
if count := svc.LogMaxBackups; count != nil && *count >= 0 && *count <= logMaxBackupsLimit {
budget.backups = *count
}
return budget
}
+60
View File
@@ -0,0 +1,60 @@
package cli
import (
"testing"
"github.com/Control-D-Inc/ctrld"
)
func Test_logBudgets(t *testing.T) {
for _, tc := range []struct {
name string
isRouter bool
wantDebug logBudget
wantJournal logBudget
}{
{"desktop", false, logBudget{maxSize: 10 << 20, backups: 4}, logBudget{maxSize: 2 << 20, backups: 2}},
{"router", true, logBudget{maxSize: 5 << 20, backups: 1}, logBudget{maxSize: 1 << 20, backups: 1}},
} {
t.Run(tc.name, func(t *testing.T) {
debug, journalStream := logBudgets(tc.isRouter)
if debug != tc.wantDebug {
t.Errorf("debug budget = %+v, want %+v", debug, tc.wantDebug)
}
if journalStream != tc.wantJournal {
t.Errorf("journal budget = %+v, want %+v", journalStream, tc.wantJournal)
}
})
}
}
func Test_debugLogBudget(t *testing.T) {
noBackup, oneBackup := 0, 1
negativeBackups, tooManyBackups, mostBackups := -1, logMaxBackupsLimit+1, logMaxBackupsLimit
for _, tc := range []struct {
name string
svc ctrld.ServiceConfig
isRouter bool
want logBudget
}{
{"both keys set", ctrld.ServiceConfig{LogMaxSizeMB: 20, LogMaxBackups: &oneBackup}, false, logBudget{maxSize: 20 << 20, backups: 1}},
{"size zero keeps the default size", ctrld.ServiceConfig{LogMaxBackups: &oneBackup}, false, logBudget{maxSize: 10 << 20, backups: 1}},
{"absent backups keep the default count", ctrld.ServiceConfig{LogMaxSizeMB: 20}, false, logBudget{maxSize: 20 << 20, backups: 4}},
{"zero backups keep no backup file", ctrld.ServiceConfig{LogMaxBackups: &noBackup}, false, logBudget{maxSize: 10 << 20, backups: 0}},
{"empty config on a desktop", ctrld.ServiceConfig{}, false, logBudget{maxSize: 10 << 20, backups: 4}},
{"empty config on a router", ctrld.ServiceConfig{}, true, logBudget{maxSize: 5 << 20, backups: 1}},
{"keys win on a router", ctrld.ServiceConfig{LogMaxSizeMB: 20, LogMaxBackups: &noBackup}, true, logBudget{maxSize: 20 << 20, backups: 0}},
{"negative size keeps the default size", ctrld.ServiceConfig{LogMaxSizeMB: -1}, false, logBudget{maxSize: 10 << 20, backups: 4}},
{"size above the limit keeps the default size", ctrld.ServiceConfig{LogMaxSizeMB: logMaxSizeMBLimit + 1}, false, logBudget{maxSize: 10 << 20, backups: 4}},
{"the largest size the key takes", ctrld.ServiceConfig{LogMaxSizeMB: logMaxSizeMBLimit}, false, logBudget{maxSize: logMaxSizeMBLimit << 20, backups: 4}},
{"negative backups keep the default count", ctrld.ServiceConfig{LogMaxBackups: &negativeBackups}, false, logBudget{maxSize: 10 << 20, backups: 4}},
{"backups above the limit keep the default count", ctrld.ServiceConfig{LogMaxBackups: &tooManyBackups}, false, logBudget{maxSize: 10 << 20, backups: 4}},
{"the largest backup count the key takes", ctrld.ServiceConfig{LogMaxBackups: &mostBackups}, false, logBudget{maxSize: 10 << 20, backups: logMaxBackupsLimit}},
} {
t.Run(tc.name, func(t *testing.T) {
if got := debugLogBudget(&tc.svc, tc.isRouter); got != tc.want {
t.Errorf("debug budget = %+v, want %+v", got, tc.want)
}
})
}
}
+416
View File
@@ -0,0 +1,416 @@
package cli
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// errLogFileNotOpen tells a caller that the file is closed, so a write that
// returns no bytes never looks like a success.
var errLogFileNotOpen = errors.New("log file is not open")
// retryOpenInterval is the wait between two attempts to open a log file that
// could not open. A file error is often short-lived, and one attempt for each
// line would cost a system call for every line.
var retryOpenInterval = time.Minute
// logBudget is the disk space of one log stream: the size of the current
// file and the number of numbered backups that follow it.
type logBudget struct {
maxSize int64
backups int
}
// logRotation describes the file that a rotation moved away. A set err means
// that the file could not move, so the stream continues in the same file.
type logRotation struct {
file string
bytesWritten int64
firstWrite time.Time
lastWrite time.Time
backups int
err error
}
// rotatingFile writes one log file and rotates it by size with numbered
// backups. It holds the header bytes, so every file it opens starts with
// the header, and it records the write times that the rotation event needs.
type rotatingFile struct {
mu sync.Mutex
path string
budget logBudget
open func(string, int) (*os.File, error)
rename func(oldpath, newpath string) error
f *os.File
size int64
firstWrite time.Time
lastWrite time.Time
header []byte
openErr error
onRotate func(logRotation)
// headerWritten tells whether the file that is open now already holds the
// header. A start wires its logging more than once, and each file takes
// one header.
headerWritten bool
// rotateFailedAt is the size at which a rotation could not move the file.
// The next try waits for another budget of data, so a directory that
// refuses the move does not cost one attempt per line.
rotateFailedAt int64
// retryOpenAt is the time from which a closed file may open again.
retryOpenAt time.Time
// rotatedInFlight keeps onRotate from reporting a rotation that the
// rotation event itself caused.
rotatedInFlight atomic.Bool
}
// newRotatingFile opens path in append mode and counts what it already holds,
// so a restart continues the current file instead of starting a new one.
func newRotatingFile(path string, budget logBudget, open func(string, int) (*os.File, error)) (*rotatingFile, error) {
if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
return nil, fmt.Errorf("creating log directory: %w", err)
}
rf := &rotatingFile{path: path, budget: budget, open: open}
f, err := rf.openFile(os.O_CREATE | os.O_RDWR | os.O_APPEND)
if err != nil {
return nil, fmt.Errorf("opening log file: %w", err)
}
st, err := f.Stat()
if err != nil {
f.Close()
return nil, fmt.Errorf("stat log file: %w", err)
}
rf.f = f
rf.size = st.Size()
return rf, nil
}
// openFile opens the file through the platform helper, which holds the flags
// that each platform needs: a share mode on Windows, and a refusal to follow
// a symlink elsewhere.
func (rf *rotatingFile) openFile(flags int) (*os.File, error) {
if rf.open == nil {
return openLogFile(rf.path, flags)
}
return rf.open(rf.path, flags)
}
func (rf *rotatingFile) renameFile(oldpath, newpath string) error {
if rf.rename == nil {
return os.Rename(oldpath, newpath)
}
return rf.rename(oldpath, newpath)
}
// writeReporting appends p and reports a rotation that happened first. It
// never calls onRotate, because the caller must log the rotation outside of
// the lock.
func (rf *rotatingFile) writeReporting(p []byte) (int, *logRotation, error) {
rf.mu.Lock()
defer rf.mu.Unlock()
var rotated *logRotation
if rf.needsRotation(len(p)) {
var err error
if rotated, err = rf.rotateLocked(); err != nil {
return 0, rotated, err
}
}
n, err := rf.writeLocked(p)
if err != nil {
return n, rotated, err
}
rf.noteEventWrite()
return n, rotated, nil
}
// Write makes the file an io.Writer for the logger. It reports the rotation
// after the lock is free, so the rotation event can go back into this file.
func (rf *rotatingFile) Write(p []byte) (int, error) {
n, rotated, err := rf.writeReporting(p)
if rotated == nil || !rf.rotatedInFlight.CompareAndSwap(false, true) {
return n, err
}
defer rf.rotatedInFlight.Store(false)
rf.mu.Lock()
onRotate := rf.onRotate
rf.mu.Unlock()
if onRotate != nil {
onRotate(*rotated)
}
return n, err
}
// setHeader stores a copy of the header bytes for the next file.
func (rf *rotatingFile) setHeader(b []byte) {
header := make([]byte, len(b))
copy(header, b)
rf.mu.Lock()
defer rf.mu.Unlock()
rf.header = header
}
// writeHeader appends the stored header to the current file, once for each
// file that the writer opens. A restart uses it to mark the point where the
// new run continues an old file.
func (rf *rotatingFile) writeHeader() error {
rf.mu.Lock()
defer rf.mu.Unlock()
if rf.headerWritten {
return nil
}
return rf.writeHeaderLocked()
}
// setBudget takes the disk space of a later config. The file stays open, so a
// changed key costs no second header.
func (rf *rotatingFile) setBudget(budget logBudget) {
rf.mu.Lock()
defer rf.mu.Unlock()
rf.budget = budget
}
// rotateNow rotates the file although the budget is not spent.
func (rf *rotatingFile) rotateNow() (*logRotation, error) {
rf.mu.Lock()
defer rf.mu.Unlock()
return rf.rotateLocked()
}
// paths returns the files that exist, oldest first, for the log readers.
func (rf *rotatingFile) paths() []string {
rf.mu.Lock()
defer rf.mu.Unlock()
files := make([]string, 0, rf.budget.backups+1)
for i := rf.budget.backups; i >= 1; i-- {
backup := rf.backupPath(i)
if _, err := os.Stat(backup); err == nil {
files = append(files, backup)
}
}
if _, err := os.Stat(rf.path); err == nil {
files = append(files, rf.path)
}
return files
}
func (rf *rotatingFile) currentPath() string {
return rf.path
}
func (rf *rotatingFile) close() error {
rf.mu.Lock()
defer rf.mu.Unlock()
if rf.f == nil {
return nil
}
err := rf.f.Close()
rf.f = nil
return err
}
func (rf *rotatingFile) backupPath(index int) string {
return fmt.Sprintf("%s.%d", rf.path, index)
}
// rotatingSuffix marks the file that a rotation with no backup moves away
// before it opens the new file. The old bytes stay behind the readers that
// hold them open, and the file goes as soon as the new file is open.
const rotatingSuffix = ".rotating"
func (rf *rotatingFile) rotatingPath() string {
return rf.path + rotatingSuffix
}
// rotatedPath names the file that the next rotation leaves behind.
func (rf *rotatingFile) rotatedPath() string {
if rf.budget.backups == 0 {
return rf.path
}
return rf.backupPath(1)
}
// needsRotation reports whether p makes the file cross its budget. A closed
// file never rotates, because a rotation would shift the backups away for a
// write that cannot land.
func (rf *rotatingFile) needsRotation(n int) bool {
if rf.f == nil || rf.budget.maxSize <= 0 {
return false
}
return rf.size+int64(n) > rf.rotateFailedAt+rf.budget.maxSize
}
// rotateLocked moves the current file away and opens an empty one in its
// place. It must run with rf.mu held.
func (rf *rotatingFile) rotateLocked() (*logRotation, error) {
record := &logRotation{
file: rf.rotatedPath(),
bytesWritten: rf.size,
firstWrite: rf.firstWrite,
lastWrite: rf.lastWrite,
backups: rf.budget.backups,
}
if rf.f != nil {
rf.f.Close()
rf.f = nil
}
if err := rf.shiftBackups(); err != nil {
record.err = err
return record, rf.keepFileAfterFailedShift()
}
f, err := rf.openFile(os.O_CREATE | os.O_RDWR | os.O_TRUNC)
if err != nil {
rf.size = 0
return record, rf.noteOpenFailure(err)
}
if rf.budget.backups == 0 {
// The readers that hold the old file keep their bytes; the name goes.
_ = os.Remove(rf.rotatingPath())
}
rf.f = f
rf.openErr = nil
rf.size = 0
rf.rotateFailedAt = 0
rf.firstWrite = time.Time{}
rf.lastWrite = time.Time{}
rf.headerWritten = false
return record, rf.writeHeaderLocked()
}
// keepFileAfterFailedShift opens the file that could not move. The append flag
// keeps the lines that are already in it, which a truncating open would drop
// although no backup holds them. It must run with rf.mu held.
func (rf *rotatingFile) keepFileAfterFailedShift() error {
f, err := rf.openFile(os.O_CREATE | os.O_RDWR | os.O_APPEND)
if err != nil {
rf.size = 0
return rf.noteOpenFailure(err)
}
rf.f = f
rf.openErr = nil
rf.rotateFailedAt = rf.size
return nil
}
// noteOpenFailure keeps the error of a failed open and holds the next attempt
// back. It must run with rf.mu held.
func (rf *rotatingFile) noteOpenFailure(err error) error {
rf.openErr = fmt.Errorf("reopening log file: %w", err)
rf.retryOpenAt = time.Now().Add(retryOpenInterval)
return rf.openErr
}
// shiftBackups moves each backup one number up and drops the oldest one. A
// missing backup is normal here, so those moves are best effort: a rotation
// must continue even when one backup cannot move. The move of the current
// file decides the rotation, so its error goes back to the caller. With no
// backup allowed, the current file still moves away, because a truncating
// reopen would change the bytes under an open upload reader.
func (rf *rotatingFile) shiftBackups() error {
if rf.budget.backups == 0 {
return rf.moveCurrentFile(rf.rotatingPath())
}
for i := rf.budget.backups - 1; i >= 1; i-- {
_ = rf.renameFile(rf.backupPath(i), rf.backupPath(i+1))
}
return rf.moveCurrentFile(rf.backupPath(1))
}
// moveCurrentFile renames the current file. A file that is not there is not
// an error, because a fresh path has nothing to move.
func (rf *rotatingFile) moveCurrentFile(to string) error {
if err := rf.renameFile(rf.path, to); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// pruneNumberedBackups removes the backups of path above keep. A lowered
// log_max_backups would otherwise leave old files that no reader lists. It
// reads the directory, because the numbers can have gaps. Only a name with an
// exact number after the path goes, because the directory can hold the files
// of another writer.
func pruneNumberedBackups(path string, keep int) {
dir := filepath.Dir(path)
entries, err := os.ReadDir(dir)
if err != nil {
return
}
prefix := filepath.Base(path) + "."
for _, entry := range entries {
suffix, found := strings.CutPrefix(entry.Name(), prefix)
if !found {
continue
}
index, err := strconv.Atoi(suffix)
if err != nil || index <= keep {
continue
}
os.Remove(filepath.Join(dir, entry.Name()))
}
}
// writeHeaderLocked must run with rf.mu held.
func (rf *rotatingFile) writeHeaderLocked() error {
if len(rf.header) == 0 {
return nil
}
rf.headerWritten = true
_, err := rf.writeLocked(rf.header)
return err
}
// writeLocked appends to the open file and counts the bytes. It must run
// with rf.mu held.
func (rf *rotatingFile) writeLocked(p []byte) (int, error) {
if rf.f == nil {
if rf.openErr == nil {
return 0, errLogFileNotOpen
}
if err := rf.retryOpenLocked(); err != nil {
return 0, err
}
}
n, err := rf.f.Write(p)
rf.size += int64(n)
return n, err
}
// retryOpenLocked opens a file again that could not open. A file whose open
// failed once must not stay closed for the life of the process, because the
// cause is often a full disk or a directory that comes back. It must run with
// rf.mu held.
func (rf *rotatingFile) retryOpenLocked() error {
if time.Now().Before(rf.retryOpenAt) {
return rf.openErr
}
f, err := rf.openFile(os.O_CREATE | os.O_RDWR | os.O_APPEND)
if err != nil {
return rf.noteOpenFailure(err)
}
rf.f = f
rf.openErr = nil
rf.size = 0
if st, err := f.Stat(); err == nil {
rf.size = st.Size()
}
rf.headerWritten = false
return rf.writeHeaderLocked()
}
// noteEventWrite keeps the times that the rotation event reports. A header
// is not an event, so it does not move these times.
func (rf *rotatingFile) noteEventWrite() {
now := time.Now()
if rf.firstWrite.IsZero() {
rf.firstWrite = now
}
rf.lastWrite = now
}
+553
View File
@@ -0,0 +1,553 @@
package cli
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
)
const rotatingFileTestLineSize = 1024
// rotatingFileTestLine returns a line of a fixed size that names its
// generation, so a test can tell the content of one file from another.
func rotatingFileTestLine(generation, seq int) []byte {
head := fmt.Sprintf("gen=%d seq=%d ", generation, seq)
return []byte(head + strings.Repeat("x", rotatingFileTestLineSize-len(head)-1) + "\n")
}
func newTestRotatingFile(t *testing.T, budget logBudget) *rotatingFile {
t.Helper()
path := filepath.Join(t.TempDir(), "ctrld.log")
rf, err := newRotatingFile(path, budget, nil)
if err != nil {
t.Fatalf("newRotatingFile: %v", err)
}
t.Cleanup(func() { _ = rf.close() })
return rf
}
func rotatingFileTestContent(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}
func writeRotatingFileTestLine(t *testing.T, rf *rotatingFile, generation, seq int) *logRotation {
t.Helper()
line := rotatingFileTestLine(generation, seq)
n, rotated, err := rf.writeReporting(line)
if err != nil {
t.Fatalf("write gen=%d seq=%d: %v", generation, seq, err)
}
if n != len(line) {
t.Fatalf("write gen=%d seq=%d wrote %d bytes, want %d", generation, seq, n, len(line))
}
return rotated
}
func Test_rotatingFileKeepsNumberedBackups(t *testing.T) {
rf := newTestRotatingFile(t, logBudget{maxSize: 4096, backups: 4})
rotations := 0
for generation := 1; generation <= 7; generation++ {
for seq := 1; seq <= 4; seq++ {
if writeRotatingFileTestLine(t, rf, generation, seq) != nil {
rotations++
}
}
}
if rotations != 6 {
t.Fatalf("rotations = %d, want 6", rotations)
}
generationOf := map[string]int{"": 7, ".1": 6, ".2": 5, ".3": 4, ".4": 3}
for suffix, generation := range generationOf {
content := rotatingFileTestContent(t, rf.path+suffix)
if want := fmt.Sprintf("gen=%d seq=1 ", generation); !strings.HasPrefix(content, want) {
t.Fatalf("file %q starts with %.16q, want %q", rf.path+suffix, content, want)
}
if newer := fmt.Sprintf("gen=%d ", generation+1); strings.Contains(content, newer) {
t.Fatalf("file %q holds %q", rf.path+suffix, newer)
}
}
if _, err := os.Stat(rf.path + ".5"); !os.IsNotExist(err) {
t.Fatalf("stat %s.5 = %v, want a not exist error", rf.path, err)
}
}
func Test_rotatingFileHeaderStartsEveryFile(t *testing.T) {
rf := newTestRotatingFile(t, logBudget{maxSize: 4096, backups: 1})
header := []byte(`{"level":"info","journal":true,"message":"Log header"}` + "\n")
rf.setHeader(header)
if err := rf.writeHeader(); err != nil {
t.Fatalf("writeHeader: %v", err)
}
if rf.size != int64(len(header)) {
t.Fatalf("size = %d, want %d", rf.size, len(header))
}
if got := strings.Count(rotatingFileTestContent(t, rf.path), string(header)); got != 1 {
t.Fatalf("header count in the first file = %d, want 1", got)
}
rotations := 0
for seq := 1; seq <= 5; seq++ {
if writeRotatingFileTestLine(t, rf, 1, seq) != nil {
rotations++
}
}
if rotations != 1 {
t.Fatalf("rotations = %d, want 1", rotations)
}
if !strings.HasPrefix(rotatingFileTestContent(t, rf.path+".1"), string(header)) {
t.Fatal("the backup file does not start with the header")
}
content := rotatingFileTestContent(t, rf.path)
if !strings.HasPrefix(content, string(header)) {
t.Fatal("the new file does not start with the header")
}
if got := strings.Count(content, string(header)); got != 1 {
t.Fatalf("header count in the new file = %d, want 1", got)
}
if want := int64(len(header) + 2*rotatingFileTestLineSize); rf.size != want {
t.Fatalf("size = %d, want %d", rf.size, want)
}
}
func Test_rotatingFileRotationRecord(t *testing.T) {
rf := newTestRotatingFile(t, logBudget{maxSize: 4096, backups: 2})
if rf.currentPath() != rf.path {
t.Fatalf("currentPath = %q, want %q", rf.currentPath(), rf.path)
}
start := time.Now()
for seq := 1; seq <= 3; seq++ {
writeRotatingFileTestLine(t, rf, 1, seq)
}
sizeBeforeRotation := rf.size
record, err := rf.rotateNow()
if err != nil {
t.Fatalf("rotateNow: %v", err)
}
if record == nil {
t.Fatal("rotateNow returned no record")
}
if want := rf.path + ".1"; record.file != want {
t.Fatalf("record.file = %q, want %q", record.file, want)
}
if record.backups != 2 {
t.Fatalf("record.backups = %d, want 2", record.backups)
}
if record.bytesWritten != sizeBeforeRotation {
t.Fatalf("record.bytesWritten = %d, want %d", record.bytesWritten, sizeBeforeRotation)
}
st, err := os.Stat(record.file)
if err != nil {
t.Fatalf("stat %s: %v", record.file, err)
}
if st.Size() != record.bytesWritten {
t.Fatalf("%s holds %d bytes, record.bytesWritten = %d", record.file, st.Size(), record.bytesWritten)
}
if record.firstWrite.Before(start) {
t.Fatalf("record.firstWrite = %v, want at or after %v", record.firstWrite, start)
}
if record.lastWrite.Before(record.firstWrite) {
t.Fatalf("record.lastWrite = %v is before record.firstWrite = %v", record.lastWrite, record.firstWrite)
}
want := []string{rf.path + ".1", rf.path}
if got := rf.paths(); !slices.Equal(got, want) {
t.Fatalf("paths = %v, want %v", got, want)
}
writeRotatingFileTestLine(t, rf, 2, 1)
if _, err := rf.rotateNow(); err != nil {
t.Fatalf("second rotateNow: %v", err)
}
want = []string{rf.path + ".2", rf.path + ".1", rf.path}
if got := rf.paths(); !slices.Equal(got, want) {
t.Fatalf("paths after two rotations = %v, want %v", got, want)
}
}
func Test_rotatingFileZeroBackupsTruncatesInPlace(t *testing.T) {
rf := newTestRotatingFile(t, logBudget{maxSize: 2048, backups: 0})
var record *logRotation
for seq := 1; seq <= 3; seq++ {
if rotated := writeRotatingFileTestLine(t, rf, 1, seq); rotated != nil {
record = rotated
}
}
if record == nil {
t.Fatal("no rotation happened")
}
if record.file != rf.path {
t.Fatalf("record.file = %q, want %q", record.file, rf.path)
}
if record.backups != 0 {
t.Fatalf("record.backups = %d, want 0", record.backups)
}
if want := int64(2 * rotatingFileTestLineSize); record.bytesWritten != want {
t.Fatalf("record.bytesWritten = %d, want %d", record.bytesWritten, want)
}
if _, err := os.Stat(rf.path + ".1"); !os.IsNotExist(err) {
t.Fatalf("stat %s.1 = %v, want a not exist error", rf.path, err)
}
if content := rotatingFileTestContent(t, rf.path); strings.Contains(content, "seq=1 ") {
t.Fatal("the truncated file still holds the old content")
}
if got, want := rf.paths(), []string{rf.path}; !slices.Equal(got, want) {
t.Fatalf("paths = %v, want %v", got, want)
}
}
func Test_rotatingFileWriteCallsOnRotate(t *testing.T) {
t.Run("one call per rotation", func(t *testing.T) {
rf := newTestRotatingFile(t, logBudget{maxSize: 4096, backups: 4})
var rotations atomic.Int64
rf.onRotate = func(logRotation) { rotations.Add(1) }
for generation := 1; generation <= 7; generation++ {
for seq := 1; seq <= 4; seq++ {
if _, err := rf.Write(rotatingFileTestLine(generation, seq)); err != nil {
t.Fatalf("Write: %v", err)
}
}
}
if got := rotations.Load(); got != 6 {
t.Fatalf("onRotate calls = %d, want 6", got)
}
})
t.Run("reentrant callback does not deadlock", func(t *testing.T) {
rf := newTestRotatingFile(t, logBudget{maxSize: 4096, backups: 1})
const reentrantLine = "written from onRotate\n"
var rotations atomic.Int64
rf.onRotate = func(logRotation) {
rotations.Add(1)
if _, err := rf.Write([]byte(reentrantLine)); err != nil {
t.Errorf("reentrant Write: %v", err)
}
}
done := make(chan struct{})
go func() {
defer close(done)
for seq := 1; seq <= 5; seq++ {
if _, err := rf.Write(rotatingFileTestLine(1, seq)); err != nil {
t.Errorf("Write: %v", err)
return
}
}
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("Write did not return, a reentrant onRotate deadlocks it")
}
if got := rotations.Load(); got != 1 {
t.Fatalf("onRotate calls = %d, want 1", got)
}
if !strings.Contains(rotatingFileTestContent(t, rf.path), reentrantLine) {
t.Fatal("the new file does not hold the reentrant line")
}
})
}
func Test_rotatingFileCountsExistingSize(t *testing.T) {
path := filepath.Join(t.TempDir(), "logs", "ctrld.log")
budget := logBudget{maxSize: 8192, backups: 1}
rf, err := newRotatingFile(path, budget, nil)
if err != nil {
t.Fatalf("newRotatingFile: %v", err)
}
for seq := 1; seq <= 3; seq++ {
writeRotatingFileTestLine(t, rf, 1, seq)
}
if err := rf.close(); err != nil {
t.Fatalf("close: %v", err)
}
reopened, err := newRotatingFile(path, budget, nil)
if err != nil {
t.Fatalf("newRotatingFile on an existing file: %v", err)
}
t.Cleanup(func() { _ = reopened.close() })
if want := int64(3 * rotatingFileTestLineSize); reopened.size != want {
t.Fatalf("size = %d, want %d", reopened.size, want)
}
record, err := reopened.rotateNow()
if err != nil {
t.Fatalf("rotateNow: %v", err)
}
if want := int64(3 * rotatingFileTestLineSize); record.bytesWritten != want {
t.Fatalf("record.bytesWritten = %d, want %d", record.bytesWritten, want)
}
st, err := os.Stat(path)
if err != nil {
t.Fatalf("stat %s: %v", path, err)
}
if st.Size() != 0 {
t.Fatalf("the new file holds %d bytes, want 0", st.Size())
}
}
func Test_rotatingFileKeepsReopenError(t *testing.T) {
reopenErr := errors.New("reopen refused")
var opens atomic.Int64
open := func(path string, flags int) (*os.File, error) {
if opens.Add(1) > 1 {
return nil, reopenErr
}
return os.OpenFile(path, flags, 0600)
}
path := filepath.Join(t.TempDir(), "ctrld.log")
rf, err := newRotatingFile(path, logBudget{maxSize: 1024, backups: 1}, open)
if err != nil {
t.Fatalf("newRotatingFile: %v", err)
}
t.Cleanup(func() { _ = rf.close() })
writeRotatingFileTestLine(t, rf, 1, 1)
if _, _, err := rf.writeReporting(rotatingFileTestLine(1, 2)); !errors.Is(err, reopenErr) {
t.Fatalf("write after a failed reopen = %v, want %v", err, reopenErr)
}
if rf.f != nil {
t.Fatal("the file is still open after a failed reopen")
}
if _, _, err := rf.writeReporting(rotatingFileTestLine(1, 3)); !errors.Is(err, reopenErr) {
t.Fatalf("later write = %v, want %v", err, reopenErr)
}
oversized := make([]byte, 2*rotatingFileTestLineSize)
_, rotated, err := rf.writeReporting(oversized)
if !errors.Is(err, reopenErr) {
t.Fatalf("oversized write = %v, want %v", err, reopenErr)
}
if rotated != nil {
t.Fatalf("a closed file reported a rotation of %q", rotated.file)
}
if _, err := os.Stat(path + ".2"); !os.IsNotExist(err) {
t.Fatalf("stat %s.2 = %v, want a not exist error: a closed file shifted the backups", path, err)
}
}
func Test_rotatingFileRetriesAFailedOpen(t *testing.T) {
origInterval := retryOpenInterval
retryOpenInterval = 10 * time.Millisecond
t.Cleanup(func() { retryOpenInterval = origInterval })
openErr := errors.New("open refused")
opens := 0
open := func(path string, flags int) (*os.File, error) {
opens++
if opens == 2 {
return nil, openErr
}
return os.OpenFile(path, flags, 0600)
}
path := filepath.Join(t.TempDir(), "ctrld.log")
rf, err := newRotatingFile(path, logBudget{maxSize: rotatingFileTestLineSize, backups: 1}, open)
if err != nil {
t.Fatalf("newRotatingFile: %v", err)
}
t.Cleanup(func() { _ = rf.close() })
header := []byte(`{"level":"info","journal":true,"message":"Log header"}` + "\n")
rf.setHeader(header)
writeRotatingFileTestLine(t, rf, 1, 1)
if _, _, err := rf.writeReporting(rotatingFileTestLine(1, 2)); !errors.Is(err, openErr) {
t.Fatalf("write after a failed reopen = %v, want %v", err, openErr)
}
if _, _, err := rf.writeReporting(rotatingFileTestLine(1, 3)); !errors.Is(err, openErr) {
t.Fatalf("write inside the retry interval = %v, want %v", err, openErr)
}
if opens != 2 {
t.Fatalf("opens = %d, want 2: a write inside the retry interval opened the file again", opens)
}
time.Sleep(2 * retryOpenInterval)
writeRotatingFileTestLine(t, rf, 1, 4)
content := rotatingFileTestContent(t, path)
if !strings.HasPrefix(content, string(header)) {
t.Fatalf("the reopened file starts with %.40q, want the header", content)
}
if !strings.Contains(content, "gen=1 seq=4 ") {
t.Fatalf("the reopened file misses the line that followed the retry: %.80q", content)
}
}
func Test_pruneNumberedBackups(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, logFileName)
numbered := []string{path + ".1", path + ".2", path + ".3", path + ".4"}
// A prune must keep every file that another writer owns, because log_path
// can name a file in a directory full of them.
foreign := []string{path + ".bak", filepath.Join(dir, journalLogFileName+".3"), filepath.Join(dir, "syslog.5")}
for _, name := range append(slices.Clone(numbered), foreign...) {
if err := os.WriteFile(name, []byte("old\n"), 0600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
pruneNumberedBackups(path, 1)
if _, err := os.Stat(numbered[0]); err != nil {
t.Fatalf("the backup inside the count went away: %v", err)
}
for _, name := range numbered[1:] {
if _, err := os.Stat(name); !os.IsNotExist(err) {
t.Fatalf("%s still exists after a prune that keeps one backup (err %v)", name, err)
}
}
for _, name := range foreign {
if _, err := os.Stat(name); err != nil {
t.Fatalf("the prune removed %s, which it does not own: %v", name, err)
}
}
}
func Test_rotatingFileKeepsFilesAboveTheCount(t *testing.T) {
rf := newTestRotatingFile(t, logBudget{maxSize: 2 * rotatingFileTestLineSize, backups: 1})
late := rf.backupPath(3)
if err := os.WriteFile(late, []byte("late\n"), 0600); err != nil {
t.Fatalf("write the late backup: %v", err)
}
for seq := 1; seq <= 3; seq++ {
if _, err := rf.Write(rotatingFileTestLine(1, seq)); err != nil {
t.Fatalf("write %d: %v", seq, err)
}
}
if _, err := os.Stat(late); err != nil {
t.Fatalf("a rotation removed %s, which sits above the backup count: %v", late, err)
}
if _, err := os.Stat(rf.backupPath(1)); err != nil {
t.Fatalf("the rotation left no .1 file: %v", err)
}
}
func Test_rotatingFileDoesNotFollowASymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("the Windows opener takes no symlink flag")
}
dir := t.TempDir()
planted := filepath.Join(dir, "planted.log")
if err := os.WriteFile(planted, []byte("planted\n"), 0600); err != nil {
t.Fatalf("write the planted file: %v", err)
}
path := filepath.Join(dir, "ctrld.log")
if err := os.Symlink(planted, path); err != nil {
t.Fatalf("symlink: %v", err)
}
rf, err := newRotatingFile(path, logBudget{maxSize: 4096, backups: 1}, nil)
if err == nil {
_ = rf.close()
t.Fatal("newRotatingFile followed the symlink")
}
if got := rotatingFileTestContent(t, planted); got != "planted\n" {
t.Fatalf("the planted file = %q, want it untouched", got)
}
}
func Test_rotatingFileKeepsContentWhenRenameFails(t *testing.T) {
renameErr := errors.New("rename refused")
renameFails := true
path := filepath.Join(t.TempDir(), "ctrld.log")
rf, err := newRotatingFile(path, logBudget{maxSize: 2 * rotatingFileTestLineSize, backups: 1}, nil)
if err != nil {
t.Fatalf("newRotatingFile: %v", err)
}
t.Cleanup(func() { _ = rf.close() })
rf.rename = func(oldpath, newpath string) error {
if renameFails {
return renameErr
}
return os.Rename(oldpath, newpath)
}
for seq := 1; seq <= 2; seq++ {
writeRotatingFileTestLine(t, rf, 1, seq)
}
record := writeRotatingFileTestLine(t, rf, 1, 3)
if record == nil {
t.Fatal("the write past the budget reported no rotation")
}
if !errors.Is(record.err, renameErr) {
t.Fatalf("record.err = %v, want %v", record.err, renameErr)
}
content := rotatingFileTestContent(t, path)
for seq := 1; seq <= 3; seq++ {
if want := fmt.Sprintf("gen=1 seq=%d ", seq); !strings.Contains(content, want) {
t.Fatalf("the file lost %q after a failed rename", want)
}
}
if _, err := os.Stat(path + ".1"); !os.IsNotExist(err) {
t.Fatalf("stat %s.1 = %v, want a not exist error", path, err)
}
if writeRotatingFileTestLine(t, rf, 1, 4) != nil {
t.Fatal("the next write rotated again instead of waiting for another budget")
}
renameFails = false
var second *logRotation
for seq := 5; seq <= 6; seq++ {
if rotated := writeRotatingFileTestLine(t, rf, 1, seq); rotated != nil {
second = rotated
}
}
if second == nil {
t.Fatal("no rotation happened after the rename worked again")
}
if second.err != nil {
t.Fatalf("record.err = %v, want no error", second.err)
}
if _, err := os.Stat(path + ".1"); err != nil {
t.Fatalf("stat %s.1 after a working rename: %v", path, err)
}
}
// Test_zeroBackupRotationKeepsTheOpenReader opens an upload reader over the
// current file, then rotates with no backup. The reader must return the bytes
// and the size that it advertised, and the current file holds the new
// generation only.
func Test_zeroBackupRotationKeepsTheOpenReader(t *testing.T) {
rf := newTestRotatingFile(t, logBudget{maxSize: 1 << 20, backups: 0})
if _, err := rf.Write([]byte("old generation!\n")); err != nil {
t.Fatal(err)
}
var upload logParts
upload.addBoundedFiles(rf.paths(), 0)
t.Cleanup(upload.close)
if _, err := rf.rotateNow(); err != nil {
t.Fatalf("rotate: %v", err)
}
if _, err := rf.Write([]byte("new\n")); err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(io.MultiReader(upload.readers...))
if err != nil {
t.Fatal(err)
}
if upload.size != 16 || string(got) != "old generation!\n" {
t.Fatalf("reader gave %q (%d bytes advertised), want the old generation of 16 bytes", got, upload.size)
}
if content := rotatingFileTestContent(t, rf.currentPath()); content != "new\n" {
t.Fatalf("current file = %q, want the new generation only", content)
}
if _, err := os.Stat(rf.currentPath() + rotatingSuffix); !os.IsNotExist(err) {
t.Fatalf("the moved file is still on disk: %v", err)
}
}
+339
View File
@@ -0,0 +1,339 @@
package cli
import (
"bytes"
"cmp"
"net"
"net/netip"
"os"
"runtime"
"slices"
"strconv"
"sync"
"time"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
"tailscale.com/net/netmon"
)
// processStartTime is the time this process started. A reader compares it with
// the time of the header line to tell a restart from a rotation.
var processStartTime = time.Now()
const (
// logHeaderMessage is the message of the header line. Log tools select the
// header by this message.
logHeaderMessage = "Log header"
// interceptModeOff is the mode word for a ctrld that does not intercept.
interceptModeOff = "off"
// logHeaderTriggerSend marks the header that a log request renders. It
// tells a reader that the line came from an upload, not from a file open.
logHeaderTriggerSend = "send"
// upstreamTypeUnspecified names an upstream that has no type in the config.
// ctrld resolves the type at start, so the header must not claim one.
upstreamTypeUnspecified = "unspecified"
)
// headerOSVersion reads the OS version once. The read starts a subprocess on
// macOS, and a header refresh follows every network change.
var headerOSVersion = sync.OnceValue(osVersion)
// logHeaderInterface is one interface of the header network snapshot.
type logHeaderInterface struct {
Name string
Up bool
IPs []string
}
// logHeaderNetwork is the minimal network snapshot of a header line.
type logHeaderNetwork struct {
DefaultRouteInterface string
HaveV4 bool
HaveV6 bool
Interfaces []logHeaderInterface
}
// logHeaderInput holds every value of a header line. The caller collects the
// values, so the renderer stays free of locks and of system calls.
type logHeaderInput struct {
Version string
Commit string
OS string
Arch string
PID int
StartTime time.Time
InterceptMode string
Listeners []string
UpstreamCount int
UpstreamTypes []string
ResolverUID string
LogFiles []string
Network logHeaderNetwork
Trigger string
}
// renderLogHeader renders the header as one log line. A file writer stores the
// bytes and writes them itself, so the header reaches a file that the logger
// never fills.
func renderLogHeader(in logHeaderInput) []byte {
var buf bytes.Buffer
logger := zerolog.New(&buf).With().Timestamp().Logger()
event := journal(logHeaderEvent(&logger)).
Str("version", in.Version).
Str("commit", in.Commit).
Str("os", in.OS).
Str("arch", in.Arch).
Int("pid", in.PID).
Str("start_time", in.StartTime.Format(time.RFC3339)).
Str("intercept_mode", in.InterceptMode).
Strs("listeners", in.Listeners).
Int("upstream_count", in.UpstreamCount).
Strs("upstream_types", in.UpstreamTypes).
Str("resolver_uid", in.ResolverUID).
Strs("log_files", in.LogFiles).
Dict("network", logHeaderNetworkDict(in.Network))
if in.Trigger != "" {
event = event.Str("trigger", in.Trigger)
}
event.Msg(logHeaderMessage)
// The header is the first line of every file and of every upload, and it
// bypasses the writers, so it passes the journal redaction here.
return redactRetainedLine(buf.Bytes())
}
// logHeaderEvent starts the header event at info level. The global level sits
// above info on a default start, and a log file must hold its header at every
// level, so the event names its level itself.
func logHeaderEvent(logger *zerolog.Logger) *zerolog.Event {
return logger.Log().Str(zerolog.LevelFieldName, zerolog.LevelInfoValue)
}
func logHeaderNetworkDict(network logHeaderNetwork) *zerolog.Event {
interfaces := zerolog.Arr()
for _, iface := range network.Interfaces {
interfaces = interfaces.Dict(zerolog.Dict().
Str("name", iface.Name).
Bool("up", iface.Up).
Strs("ips", iface.IPs))
}
return zerolog.Dict().
Str("default_route_interface", network.DefaultRouteInterface).
Bool("have_v4", network.HaveV4).
Bool("have_v6", network.HaveV6).
Array("interfaces", interfaces)
}
// logHeaderNetworkFromState makes the header snapshot from a netmon state.
func logHeaderNetworkFromState(st *netmon.State) logHeaderNetwork {
if st == nil {
return logHeaderNetwork{}
}
network := logHeaderNetwork{
DefaultRouteInterface: st.DefaultRouteInterface,
HaveV4: st.HaveV4,
HaveV6: st.HaveV6,
}
for name, iface := range st.Interface {
network.Interfaces = append(network.Interfaces, logHeaderInterface{
Name: name,
Up: iface.Interface != nil && iface.IsUp(),
IPs: prefixStrings(st.InterfaceIPs[name]),
})
}
slices.SortFunc(network.Interfaces, func(a, b logHeaderInterface) int {
return cmp.Compare(a.Name, b.Name)
})
return network
}
// logHeaderNetworkFresh reads the network now. A header that no network
// callback reached yet, and the header of an upload, need the live picture.
func logHeaderNetworkFresh() logHeaderNetwork {
state, err := readNetworkSourceStateFn()
if err != nil {
state = nil
}
network := logHeaderNetworkFromState(state)
if route, err := netmon.DefaultRouteInterface(); err == nil {
network.DefaultRouteInterface = route
}
network.HaveV4, network.HaveV6 = globalAddressFamilies(state)
return network
}
// globalAddressFamilies reports which families the host can reach. A loopback
// or a link-local address reaches no other host, so it proves nothing.
func globalAddressFamilies(st *netmon.State) (haveV4, haveV6 bool) {
if st == nil {
return false, false
}
for name, iface := range st.Interface {
if iface.Interface == nil || !iface.IsUp() {
continue
}
for _, prefix := range st.InterfaceIPs[name] {
addr := prefix.Addr().Unmap()
if !addr.IsGlobalUnicast() || addr.IsLoopback() || addr.IsLinkLocalUnicast() {
continue
}
if addr.Is4() {
haveV4 = true
continue
}
haveV6 = true
}
}
return haveV4, haveV6
}
// logHeaderInput collects every header value of the running program.
func (p *prog) logHeaderInput() logHeaderInput {
in := logHeaderInput{
Version: curVersion(),
Commit: commit,
OS: headerOSVersion(),
Arch: runtime.GOARCH,
PID: os.Getpid(),
StartTime: processStartTime,
// The resolver UID is a secret that the provisioning code strips from
// every artifact, so the header keeps its first characters only.
ResolverUID: redactToken(cdUID),
LogFiles: p.logHeaderFiles(),
Network: p.logHeaderNetwork(),
}
p.headerConfigInto(&in)
return in
}
// headerConfigInto reads the config under p.mu, because a reload replaces the
// config under that lock while log view and log send render a header.
func (p *prog) headerConfigInto(in *logHeaderInput) {
p.mu.Lock()
defer p.mu.Unlock()
logHeaderFromConfig(p.cfg, in)
}
// sendLogHeader renders the header that leads one upload.
func (p *prog) sendLogHeader() []byte {
in := p.logHeaderInput()
// The stored state can be older than the request, and support reads this
// line to learn the network of the moment the user asked.
in.Network = logHeaderNetworkFresh()
in.Trigger = logHeaderTriggerSend
return renderLogHeader(in)
}
// noteNetworkState keeps the newest network that ctrld saw and puts it in the
// header bytes of every open log file. The caller must hold no lock, because
// the render reads the config and the network.
func (p *prog) noteNetworkState(st *netmon.State) {
if st != nil {
p.lastNetworkState.Store(st)
}
p.refreshLogHeader()
}
// logHeaderNetwork takes the network that the last monitor callback reported.
func (p *prog) logHeaderNetwork() logHeaderNetwork {
state := p.lastNetworkState.Load()
if state == nil {
return logHeaderNetworkFresh()
}
return logHeaderNetworkFromState(state)
}
// logHeaderFiles names the files that this run writes, debug first.
func (p *prog) logHeaderFiles() []string {
files := make([]string, 0, 3)
for _, rf := range p.openLogFiles() {
files = append(files, rf.currentPath())
}
return files
}
// refreshLogHeader renders the header and hands the bytes to every open file.
// The caller must hold no writer lock, because the render reads the config
// and the network.
func (p *prog) refreshLogHeader() {
header := renderLogHeader(p.logHeaderInput())
for _, rf := range p.openLogFiles() {
rf.setHeader(header)
}
}
// writeLogHeaders puts the header at the append point of each open file. A
// header that cannot land leaves the file usable, so the error only earns a
// warning.
func (p *prog) writeLogHeaders() {
for _, rf := range p.openLogFiles() {
if err := rf.writeHeader(); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not write log header")
}
}
}
func prefixStrings(prefixes []netip.Prefix) []string {
ips := make([]string, 0, len(prefixes))
for _, prefix := range prefixes {
ips = append(ips, prefix.String())
}
return ips
}
// logHeaderFromConfig fills the header fields that the config holds. It keeps
// every upstream endpoint, name, and bootstrap IP out of them, because a token
// can hide in each of them.
func logHeaderFromConfig(cfg *ctrld.Config, in *logHeaderInput) {
if cfg == nil {
in.InterceptMode = interceptModeOff
return
}
in.InterceptMode = headerInterceptMode(cfg)
in.Listeners = listenerAddresses(cfg)
in.UpstreamCount = len(cfg.Upstream)
in.UpstreamTypes = upstreamTypeWords(cfg)
}
// headerInterceptMode names the mode for the header. An unset mode means that
// ctrld does not intercept, which reads as off.
func headerInterceptMode(cfg *ctrld.Config) string {
if mode := listenerInterceptMode(cfg); mode != "" {
return mode
}
return interceptModeOff
}
func listenerAddresses(cfg *ctrld.Config) []string {
addresses := make([]string, 0, len(cfg.Listener))
for _, listener := range cfg.Listener {
if listener == nil {
continue
}
addresses = append(addresses, net.JoinHostPort(listener.IP, strconv.Itoa(listener.Port)))
}
slices.Sort(addresses)
return addresses
}
func upstreamTypeWords(cfg *ctrld.Config) []string {
words := make([]string, 0, len(cfg.Upstream))
for _, upstream := range cfg.Upstream {
if upstream == nil {
continue
}
word := upstream.Type
if word == "" {
word = upstreamTypeUnspecified
}
if !slices.Contains(words, word) {
words = append(words, word)
}
}
slices.Sort(words)
return words
}
+617
View File
@@ -0,0 +1,617 @@
package cli
import (
"encoding/json"
"io"
"net"
"net/netip"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
"tailscale.com/net/netmon"
)
// logHeaderTestSecret is the string that must never reach a header line.
const logHeaderTestSecret = "secret-token"
// logHeaderTestLine is the parsed form of a header line. The json tags name
// the fields that a log tool reads, so a renamed field fails the test.
type logHeaderTestLine struct {
Level string `json:"level"`
Time string `json:"time"`
Journal bool `json:"journal"`
Message string `json:"message"`
Version string `json:"version"`
Commit string `json:"commit"`
OS string `json:"os"`
Arch string `json:"arch"`
PID int `json:"pid"`
StartTime string `json:"start_time"`
InterceptMode string `json:"intercept_mode"`
Listeners []string `json:"listeners"`
UpstreamCount int `json:"upstream_count"`
UpstreamTypes []string `json:"upstream_types"`
ResolverUID string `json:"resolver_uid"`
LogFiles []string `json:"log_files"`
Network struct {
DefaultRouteInterface string `json:"default_route_interface"`
HaveV4 bool `json:"have_v4"`
HaveV6 bool `json:"have_v6"`
Interfaces []struct {
Name string `json:"name"`
Up bool `json:"up"`
IPs []string `json:"ips"`
} `json:"interfaces"`
} `json:"network"`
Trigger string `json:"trigger"`
}
func logHeaderTestInput() logHeaderInput {
return logHeaderInput{
Version: "1.5.8",
Commit: "abc1234",
OS: "darwin 15.6",
Arch: "arm64",
PID: 4242,
StartTime: time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC),
InterceptMode: "dns",
Listeners: []string{"127.0.0.1:53", "127.0.0.1:5354"},
UpstreamCount: 2,
UpstreamTypes: []string{"doh", "os"},
ResolverUID: redactToken("abcd1234"),
LogFiles: []string{"/tmp/ctrld.log", "/tmp/ctrld-journal.log"},
Network: logHeaderNetwork{
DefaultRouteInterface: "en0",
HaveV4: true,
Interfaces: []logHeaderInterface{
{Name: "en0", Up: true, IPs: []string{"192.0.2.10/24"}},
},
},
}
}
// parseLogHeader checks that the render is one line and returns it twice: as
// values, and as raw fields for the tests that ask whether a field is there.
func parseLogHeader(t *testing.T, line []byte) (logHeaderTestLine, map[string]json.RawMessage) {
t.Helper()
if len(line) == 0 {
t.Fatal("renderLogHeader returned no bytes")
}
if line[len(line)-1] != '\n' {
t.Fatalf("header does not end with a newline: %q", line)
}
if count := strings.Count(string(line), "\n"); count != 1 {
t.Fatalf("header holds %d newlines, want 1: %q", count, line)
}
var parsed logHeaderTestLine
if err := json.Unmarshal(line, &parsed); err != nil {
t.Fatalf("parse header %q: %v", line, err)
}
fields := map[string]json.RawMessage{}
if err := json.Unmarshal(line, &fields); err != nil {
t.Fatalf("parse header fields %q: %v", line, err)
}
return parsed, fields
}
func setLogHeaderTestGlobalLevel(t *testing.T, level zerolog.Level) {
t.Helper()
old := zerolog.GlobalLevel()
zerolog.SetGlobalLevel(level)
t.Cleanup(func() { zerolog.SetGlobalLevel(old) })
}
func Test_renderLogHeaderHoldsEveryField(t *testing.T) {
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
in := logHeaderTestInput()
parsed, fields := parseLogHeader(t, renderLogHeader(in))
checks := []struct {
field string
got any
want any
}{
{"level", parsed.Level, "info"},
{"journal", parsed.Journal, true},
{"message", parsed.Message, "Log header"},
{"version", parsed.Version, in.Version},
{"commit", parsed.Commit, in.Commit},
{"os", parsed.OS, in.OS},
{"arch", parsed.Arch, in.Arch},
{"pid", parsed.PID, in.PID},
{"start_time", parsed.StartTime, in.StartTime.Format(time.RFC3339)},
{"intercept_mode", parsed.InterceptMode, in.InterceptMode},
{"listeners", parsed.Listeners, in.Listeners},
{"upstream_count", parsed.UpstreamCount, in.UpstreamCount},
{"upstream_types", parsed.UpstreamTypes, in.UpstreamTypes},
// The resolver UID is a secret, so the header keeps the first four
// characters only.
{"resolver_uid", parsed.ResolverUID, "abcd***"},
{"log_files", parsed.LogFiles, in.LogFiles},
{"network.default_route_interface", parsed.Network.DefaultRouteInterface, in.Network.DefaultRouteInterface},
{"network.have_v4", parsed.Network.HaveV4, in.Network.HaveV4},
{"network.have_v6", parsed.Network.HaveV6, in.Network.HaveV6},
}
for _, c := range checks {
if !reflect.DeepEqual(c.got, c.want) {
t.Errorf("%s = %v, want %v", c.field, c.got, c.want)
}
}
if len(parsed.Network.Interfaces) != 1 {
t.Fatalf("network.interfaces holds %d entries, want 1", len(parsed.Network.Interfaces))
}
iface := parsed.Network.Interfaces[0]
if iface.Name != "en0" || !iface.Up || !reflect.DeepEqual(iface.IPs, []string{"192.0.2.10/24"}) {
t.Errorf("network.interfaces[0] = %+v, want en0 up with 192.0.2.10/24", iface)
}
if parsed.Time == "" {
t.Error("time is empty")
}
if _, ok := fields["trigger"]; ok {
t.Errorf("trigger is present for an empty Trigger: %s", fields["trigger"])
}
}
func Test_renderLogHeaderAddsTriggerWhenSet(t *testing.T) {
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
in := logHeaderTestInput()
in.Trigger = "send"
parsed, _ := parseLogHeader(t, renderLogHeader(in))
if parsed.Trigger != "send" {
t.Errorf("trigger = %q, want %q", parsed.Trigger, "send")
}
}
// Test_renderLogHeaderIgnoresGlobalLevel guards the promise that every log file
// starts with a header. The global level sits above info on a default start.
func Test_renderLogHeaderIgnoresGlobalLevel(t *testing.T) {
setLogHeaderTestGlobalLevel(t, zerolog.WarnLevel)
parsed, _ := parseLogHeader(t, renderLogHeader(logHeaderTestInput()))
if parsed.Level != "info" {
t.Errorf("level = %q, want %q", parsed.Level, "info")
}
if parsed.Message != "Log header" {
t.Errorf("message = %q, want %q", parsed.Message, "Log header")
}
}
func Test_logHeaderFromConfigKeepsUpstreamSecretsOut(t *testing.T) {
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
old := interceptMode
t.Cleanup(func() { interceptMode = old })
interceptMode = ""
cfg := &ctrld.Config{
Listener: map[string]*ctrld.ListenerConfig{
"0": {IP: "127.0.0.1", Port: 53},
logHeaderTestSecret + "-l": {IP: "10.0.0.1", Port: 5354},
},
Upstream: map[string]*ctrld.UpstreamConfig{
"0": {
Name: logHeaderTestSecret + "-name",
Type: "doh",
Endpoint: "https://dns.example/" + logHeaderTestSecret,
BootstrapIP: "192.0.2.53",
},
"1": {Endpoint: "https://dns.example/" + logHeaderTestSecret},
},
}
cfg.Service.InterceptMode = "dns"
in := logHeaderTestInput()
logHeaderFromConfig(cfg, &in)
if in.InterceptMode != "dns" {
t.Errorf("intercept mode = %q, want %q", in.InterceptMode, "dns")
}
if want := []string{"10.0.0.1:5354", "127.0.0.1:53"}; !reflect.DeepEqual(in.Listeners, want) {
t.Errorf("listeners = %v, want %v", in.Listeners, want)
}
if in.UpstreamCount != 2 {
t.Errorf("upstream count = %d, want 2", in.UpstreamCount)
}
if want := []string{"doh", "unspecified"}; !reflect.DeepEqual(in.UpstreamTypes, want) {
t.Errorf("upstream types = %v, want %v", in.UpstreamTypes, want)
}
line := renderLogHeader(in)
if strings.Contains(string(line), logHeaderTestSecret) {
t.Errorf("header holds %q: %s", logHeaderTestSecret, line)
}
}
func Test_logHeaderFromConfigReportsOffWhenUnset(t *testing.T) {
old := interceptMode
t.Cleanup(func() { interceptMode = old })
interceptMode = ""
var in logHeaderInput
logHeaderFromConfig(&ctrld.Config{}, &in)
if in.InterceptMode != "off" {
t.Errorf("intercept mode = %q, want %q", in.InterceptMode, "off")
}
}
func Test_logHeaderNetworkFromState(t *testing.T) {
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
state := &netmon.State{
DefaultRouteInterface: "en0",
HaveV4: true,
Interface: map[string]netmon.Interface{
"en0": {Interface: &net.Interface{Name: "en0", Flags: net.FlagUp}},
"awdl0": {Interface: &net.Interface{Name: "awdl0"}},
},
InterfaceIPs: map[string][]netip.Prefix{
"en0": {netip.MustParsePrefix("192.0.2.10/24"), netip.MustParsePrefix("2001:db8::1/64")},
},
}
network := logHeaderNetworkFromState(state)
want := logHeaderNetwork{
DefaultRouteInterface: "en0",
HaveV4: true,
Interfaces: []logHeaderInterface{
{Name: "awdl0", Up: false, IPs: []string{}},
{Name: "en0", Up: true, IPs: []string{"192.0.2.10/24", "2001:db8::1/64"}},
},
}
if !reflect.DeepEqual(network, want) {
t.Fatalf("network = %+v, want %+v", network, want)
}
in := logHeaderTestInput()
in.Network = network
parsed, _ := parseLogHeader(t, renderLogHeader(in))
if parsed.Network.DefaultRouteInterface != "en0" {
t.Errorf("network.default_route_interface = %q, want %q", parsed.Network.DefaultRouteInterface, "en0")
}
if len(parsed.Network.Interfaces) != 2 {
t.Fatalf("network.interfaces holds %d entries, want 2", len(parsed.Network.Interfaces))
}
if parsed.Network.Interfaces[0].Name != "awdl0" || parsed.Network.Interfaces[0].Up {
t.Errorf("network.interfaces[0] = %+v, want awdl0 down", parsed.Network.Interfaces[0])
}
if got := parsed.Network.Interfaces[1].IPs; !reflect.DeepEqual(got, []string{"192.0.2.10/24", "2001:db8::1/64"}) {
t.Errorf("network.interfaces[1].ips = %v", got)
}
}
func Test_logHeaderNetworkFromStateWithoutState(t *testing.T) {
if network := logHeaderNetworkFromState(nil); !reflect.DeepEqual(network, logHeaderNetwork{}) {
t.Errorf("network = %+v, want the zero value", network)
}
}
// setupLogHeaderWiringTest puts ctrld in cd mode with a temporary home
// directory, so initInternalLogging writes its files there. Every global that
// the logging setup touches goes back when the test ends.
func setupLogHeaderWiringTest(t *testing.T) string {
t.Helper()
origSilent, origCdUID, origHomedir, origVerbose := silent, cdUID, homedir, verbose
origMainLog, origProxyLogger := mainLog.Load(), ctrld.ProxyLogger.Load()
origLevel := zerolog.GlobalLevel()
origLogPathFile := logPathFile.Load()
t.Cleanup(func() {
silent, cdUID, homedir, verbose = origSilent, origCdUID, origHomedir, origVerbose
mainLog.Store(origMainLog)
ctrld.ProxyLogger.Store(origProxyLogger)
zerolog.SetGlobalLevel(origLevel)
logPathFile.Store(origLogPathFile)
})
homedir = t.TempDir()
cdUID = "header-wiring-uid"
silent, verbose = false, 0
discard := zerolog.New(io.Discard)
mainLog.Store(&discard)
logPathFile.Store(nil)
return homedir
}
// startLogHeaderWiringProg runs the internal logging setup and closes both
// files when the test ends.
func startLogHeaderWiringProg(t *testing.T) *prog {
t.Helper()
p := &prog{cfg: &ctrld.Config{}}
p.initInternalLogging(nil)
t.Cleanup(func() {
p.internalLogWriter.closeLogFile()
p.internalJournalWriter.closeLogFile()
})
return p
}
// logHeaderFileLines parses every line of a log file. It returns line 1 and
// the number of header lines, so a test can tell a missing header from a
// repeated one.
func logHeaderFileLines(t *testing.T, path string) (logHeaderTestLine, int) {
t.Helper()
content := strings.TrimRight(rotatingFileTestContent(t, path), "\n")
if content == "" {
t.Fatalf("%s is empty", path)
}
var firstLine logHeaderTestLine
headers := 0
for i, line := range strings.Split(content, "\n") {
var parsed logHeaderTestLine
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
t.Fatalf("parse line %d of %s (%q): %v", i+1, path, line, err)
}
if i == 0 {
firstLine = parsed
}
if parsed.Message == logHeaderMessage {
headers++
}
}
return firstLine, headers
}
func Test_logHeaderWiring_writesHeaderOnStart(t *testing.T) {
dir := setupLogHeaderWiringTest(t)
startLogHeaderWiringProg(t)
for _, name := range []string{logFileName, journalLogFileName} {
path := filepath.Join(dir, name)
firstLine, headers := logHeaderFileLines(t, path)
if firstLine.Message != logHeaderMessage {
t.Errorf("line 1 of %s = %q, want %q", path, firstLine.Message, logHeaderMessage)
}
if !firstLine.Journal {
t.Errorf("line 1 of %s carries no journal marker", path)
}
if want := redactToken(cdUID); firstLine.ResolverUID != want {
t.Errorf("resolver_uid in %s = %q, want %q", path, firstLine.ResolverUID, want)
}
if headers != 1 {
t.Errorf("%s holds %d header lines, want 1", path, headers)
}
}
}
func Test_logHeaderInput_keepsSecretsOut(t *testing.T) {
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
dir := setupLogHeaderWiringTest(t)
origInterceptMode := interceptMode
t.Cleanup(func() { interceptMode = origInterceptMode })
interceptMode = ""
p := &prog{cfg: &ctrld.Config{
Listener: map[string]*ctrld.ListenerConfig{
logHeaderTestSecret + "-listener": {IP: "127.0.0.1", Port: 53},
},
Upstream: map[string]*ctrld.UpstreamConfig{
"0": {
Name: logHeaderTestSecret + "-name",
Type: "doh",
Endpoint: "https://dns.example/" + logHeaderTestSecret,
BootstrapIP: "192.0.2.53",
Domain: logHeaderTestSecret + ".example",
},
},
}}
p.initInternalLogging(nil)
t.Cleanup(func() {
p.internalLogWriter.closeLogFile()
p.internalJournalWriter.closeLogFile()
})
if line := renderLogHeader(p.logHeaderInput()); strings.Contains(string(line), logHeaderTestSecret) {
t.Errorf("the header holds %q: %s", logHeaderTestSecret, line)
}
for _, name := range []string{logFileName, journalLogFileName} {
path := filepath.Join(dir, name)
if content := rotatingFileTestContent(t, path); strings.Contains(content, logHeaderTestSecret) {
t.Errorf("%s holds %q: %s", path, logHeaderTestSecret, content)
}
}
}
func Test_logHeaderWiring_secondInitAddsNoHeader(t *testing.T) {
dir := setupLogHeaderWiringTest(t)
p := startLogHeaderWiringProg(t)
p.initInternalLogging(nil)
for _, name := range []string{logFileName, journalLogFileName} {
path := filepath.Join(dir, name)
if _, headers := logHeaderFileLines(t, path); headers != 1 {
t.Errorf("%s holds %d header lines after a second start, want 1", path, headers)
}
}
}
func Test_logHeaderWiring_headerLeadsRotatedFile(t *testing.T) {
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
captureMainLog(t)
lw, path := newTestFileLogWriter(t, testLogWriterBudget)
header := string(renderLogHeader(logHeaderInput{Version: "v1"}))
rf := lw.rotating()
rf.setHeader([]byte(header))
if err := rf.writeHeader(); err != nil {
t.Fatalf("writeHeader: %v", err)
}
linesPerFile := int(testLogWriterBudget.maxSize) / rotatingFileTestLineSize
for seq := 1; seq <= linesPerFile+1; seq++ {
if _, err := lw.Write(rotatingFileTestLine(1, seq)); err != nil {
t.Fatalf("write line %d: %v", seq, err)
}
}
for _, name := range []string{path + ".1", path} {
if content := rotatingFileTestContent(t, name); !strings.HasPrefix(content, header) {
t.Errorf("%s starts with %q, want the header", name, strings.SplitN(content, "\n", 2)[0])
}
}
}
func Test_logHeaderWiring_restartAppendsHeader(t *testing.T) {
dir := setupLogHeaderWiringTest(t)
path := filepath.Join(dir, logFileName)
const seed = `{"level":"info","message":"line of the run before"}`
if err := os.WriteFile(path, []byte(seed+"\n"), 0o600); err != nil {
t.Fatalf("seed %s: %v", path, err)
}
startLogHeaderWiringProg(t)
lines := strings.Split(strings.TrimRight(rotatingFileTestContent(t, path), "\n"), "\n")
if len(lines) < 2 {
t.Fatalf("%s holds %d lines, want the seed line and the header", path, len(lines))
}
if lines[0] != seed {
t.Errorf("line 1 of %s = %q, want the seed line", path, lines[0])
}
var header logHeaderTestLine
if err := json.Unmarshal([]byte(lines[1]), &header); err != nil {
t.Fatalf("parse line 2 of %s (%q): %v", path, lines[1], err)
}
if header.Message != logHeaderMessage {
t.Errorf("line 2 of %s = %q, want %q", path, header.Message, logHeaderMessage)
}
if _, headers := logHeaderFileLines(t, path); headers != 1 {
t.Errorf("%s holds %d header lines, want 1", path, headers)
}
}
func Test_logHeaderWiring_usesLastNetworkState(t *testing.T) {
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
origRead := readNetworkSourceStateFn
t.Cleanup(func() { readNetworkSourceStateFn = origRead })
p := &prog{cfg: &ctrld.Config{}}
p.lastNetworkState.Store(&netmon.State{DefaultRouteInterface: "en7"})
if got := p.logHeaderInput().Network.DefaultRouteInterface; got != "en7" {
t.Errorf("default_route_interface = %q, want %q", got, "en7")
}
p.lastNetworkState.Store(nil)
readNetworkSourceStateFn = func() (*netmon.State, error) {
return &netmon.State{
Interface: map[string]netmon.Interface{"en9": {Interface: &net.Interface{Name: "en9", Flags: net.FlagUp}}},
InterfaceIPs: map[string][]netip.Prefix{"en9": {netip.MustParsePrefix("192.0.2.9/24")}},
}, nil
}
network := p.logHeaderInput().Network
if len(network.Interfaces) != 1 || network.Interfaces[0].Name != "en9" || !network.Interfaces[0].Up {
t.Fatalf("interfaces = %+v, want one up en9", network.Interfaces)
}
if !network.HaveV4 {
t.Error("have_v4 = false, want true for a global unicast v4 address")
}
if network.HaveV6 {
t.Error("have_v6 = true, want false without a v6 address")
}
}
// stubLogHeaderNetworkRead makes the fresh network read return one up
// interface, so a header test never depends on the host network.
func stubLogHeaderNetworkRead(t *testing.T, name string) {
t.Helper()
origRead := readNetworkSourceStateFn
t.Cleanup(func() { readNetworkSourceStateFn = origRead })
readNetworkSourceStateFn = func() (*netmon.State, error) {
return &netmon.State{
Interface: map[string]netmon.Interface{name: {Interface: &net.Interface{Name: name, Flags: net.FlagUp}}},
InterfaceIPs: map[string][]netip.Prefix{name: {netip.MustParsePrefix("192.0.2.9/24")}},
}, nil
}
}
func Test_sendLogHeader_usesFreshNetworkAndTrigger(t *testing.T) {
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
stubLogHeaderNetworkRead(t, "en9")
p := &prog{cfg: &ctrld.Config{}}
p.lastNetworkState.Store(&netmon.State{
DefaultRouteInterface: "en7",
Interface: map[string]netmon.Interface{"en7": {Interface: &net.Interface{Name: "en7", Flags: net.FlagUp}}},
InterfaceIPs: map[string][]netip.Prefix{"en7": {netip.MustParsePrefix("192.0.2.7/24")}},
})
parsed, _ := parseLogHeader(t, p.sendLogHeader())
if parsed.Trigger != "send" {
t.Errorf("trigger = %q, want %q", parsed.Trigger, "send")
}
if parsed.Message != logHeaderMessage {
t.Errorf("message = %q, want %q", parsed.Message, logHeaderMessage)
}
names := make([]string, 0, len(parsed.Network.Interfaces))
for _, iface := range parsed.Network.Interfaces {
names = append(names, iface.Name)
}
if want := []string{"en9"}; !reflect.DeepEqual(names, want) {
t.Fatalf("network.interfaces = %v, want %v from the fresh read", names, want)
}
}
// Test_logHeaderInputReadsTheConfigUnderTheLock renders headers beside the
// config swap of a reload. The race detector fails the test when the render
// reads the config outside the lock.
func Test_logHeaderInputReadsTheConfigUnderTheLock(t *testing.T) {
stubLogHeaderNetworkRead(t, "en0")
p := &prog{cfg: &ctrld.Config{
Listener: map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 53}},
Upstream: map[string]*ctrld.UpstreamConfig{"0": {Type: "doh"}},
}}
reloaded := &ctrld.Config{
Listener: map[string]*ctrld.ListenerConfig{"0": {IP: "127.0.0.1", Port: 5354}},
Upstream: map[string]*ctrld.UpstreamConfig{"0": {Type: "doq"}, "1": {Type: "legacy"}},
}
stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
for {
select {
case <-stop:
return
default:
}
p.mu.Lock()
*p.cfg = *reloaded
p.mu.Unlock()
}
}()
for i := 0; i < 300; i++ {
if in := p.logHeaderInput(); len(in.Listeners) != 1 {
t.Fatalf("listeners = %v, want one", in.Listeners)
}
}
close(stop)
<-done
}
// Test_renderLogHeaderPassesTheSink puts a provisioning secret where the
// header does not expect one. The header is the first line of every file and
// of every upload, so it must pass the same redaction as every other line.
func Test_renderLogHeaderPassesTheSink(t *testing.T) {
origUID := cdUID
cdUID = "abcd1234SECRETUID"
t.Cleanup(func() { cdUID = origUID })
line := string(renderLogHeader(logHeaderInput{Version: "1.0", LogFiles: []string{"/var/log/" + cdUID + "/ctrld.log"}}))
if strings.Contains(line, cdUID) {
t.Fatalf("the header holds the secret: %s", line)
}
}
+68
View File
@@ -0,0 +1,68 @@
package cli
import (
"bytes"
"io"
"regexp"
"github.com/rs/zerolog"
)
// journalField marks an event for the retained journal stream.
const journalField = "journal"
// journalMarker is the serialized form of the journal field. The journal
// writer keeps a line below warn level only when the line contains it.
var journalMarker = []byte(`"` + journalField + `":true`)
// retainedURLPath matches the part of a URL that follows the host. The class
// stops the match at the characters that end a value inside a JSON line.
var retainedURLPath = regexp.MustCompile(`(https?://[^/?"\\\s]+)[/?][^"\\\s]*`)
// redactRetainedLine strips the secrets of one line that the journal keeps.
// Support downloads the journal whole, and any error text can carry a DoH URL
// with the resolver path and the packed query of the user.
func redactRetainedLine(p []byte) []byte {
stripped := retainedURLPath.ReplaceAll(p, []byte(`${1}/[redacted]`))
return []byte(redactSecrets(string(stripped), provisionSecrets()...))
}
// journal marks an event for the retained journal stream. The event keeps
// its own level, so an info event stays info on the console.
func journal(e *zerolog.Event) *zerolog.Event {
return e.Bool(journalField, true)
}
// journalLevelWriter passes on the lines that the journal keeps and drops the
// rest, so the journal holds a small history that survives a restart.
type journalLevelWriter struct {
w io.Writer
}
var _ zerolog.LevelWriter = (*journalLevelWriter)(nil)
func newJournalLevelWriter(w io.Writer) *journalLevelWriter {
return &journalLevelWriter{w: w}
}
// WriteLevel reads the level first, because a compare costs less than a scan
// of the serialized line.
func (j *journalLevelWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) {
if l < zerolog.WarnLevel && !bytes.Contains(p, journalMarker) {
// A dropped line reports a full write, because zerolog.MultiLevelWriter
// turns a short write into an error for the whole group of writers.
return len(p), nil
}
if _, err := j.w.Write(redactRetainedLine(p)); err != nil {
return 0, err
}
// The redacted line is shorter than the line the logger handed over, and a
// short write is an error for the whole group of writers.
return len(p), nil
}
// Write has no level to read, so the marker alone decides. Every level below
// warn takes that path.
func (j *journalLevelWriter) Write(p []byte) (int, error) {
return j.WriteLevel(zerolog.DebugLevel, p)
}
+255
View File
@@ -0,0 +1,255 @@
package cli
import (
"bytes"
"io"
"path/filepath"
"strings"
"testing"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
)
func Test_journalSetsMarker(t *testing.T) {
var buf bytes.Buffer
logger := zerolog.New(&buf)
journal(logger.Info()).Msg("marked")
if !bytes.Contains(buf.Bytes(), journalMarker) {
t.Fatalf("journal event lacks the marker: %s", buf.String())
}
if !bytes.Contains(buf.Bytes(), []byte(`"level":"info"`)) {
t.Fatalf("journal event changed its level: %s", buf.String())
}
}
// journalTestLine has the shape of a serialized event, so the direct write
// path sees the marker where zerolog puts it.
func journalTestLine(marked bool) []byte {
if marked {
return []byte(`{"level":"info","` + journalField + `":true,"message":"state"}` + "\n")
}
return []byte(`{"level":"info","message":"state"}` + "\n")
}
func Test_journalLevelWriterKeepsWarningsAndMarkedLines(t *testing.T) {
level := zerolog.GlobalLevel()
zerolog.SetGlobalLevel(zerolog.DebugLevel)
t.Cleanup(func() { zerolog.SetGlobalLevel(level) })
for _, tc := range []struct {
name string
log func(logger zerolog.Logger)
kept bool
}{
{"info with the marker", func(logger zerolog.Logger) { journal(logger.Info()).Msg("state") }, true},
{"info without the marker", func(logger zerolog.Logger) { logger.Info().Msg("state") }, false},
{"warn without the marker", func(logger zerolog.Logger) { logger.Warn().Msg("slow") }, true},
{"debug with the marker", func(logger zerolog.Logger) { journal(logger.Debug()).Msg("state") }, true},
{"notice without the marker", func(logger zerolog.Logger) { logger.Notice().Msg("note") }, false},
{"error without the marker", func(logger zerolog.Logger) { logger.Error().Msg("failed") }, true},
} {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
tc.log(zerolog.New(zerolog.MultiLevelWriter(newJournalLevelWriter(&buf))))
if kept := buf.Len() > 0; kept != tc.kept {
t.Fatalf("kept = %v, want %v, line: %q", kept, tc.kept, buf.String())
}
})
}
}
func Test_journalLevelWriterWriteKeepsMarkedLineOnly(t *testing.T) {
var buf bytes.Buffer
writer := newJournalLevelWriter(&buf)
marked := journalTestLine(true)
n, err := writer.Write(marked)
if err != nil || n != len(marked) {
t.Fatalf("write of a marked line = (%d, %v), want (%d, nil)", n, err, len(marked))
}
if buf.String() != string(marked) {
t.Fatalf("journal holds %q, want %q", buf.String(), marked)
}
plain := journalTestLine(false)
n, err = writer.Write(plain)
if err != nil || n != len(plain) {
t.Fatalf("write of a plain line = (%d, %v), want (%d, nil)", n, err, len(plain))
}
if buf.String() != string(marked) {
t.Fatalf("journal kept an unmarked line: %q", buf.String())
}
}
// journalDoHError has the shape of a resolver error text: the DoH URL carries
// the resolver path and the packed query.
const journalDoHError = `Get "https://dns.example/org-v1-SECRET0123?dns=pOgBAAAB": dial tcp`
// journalRedactedURL is what the sink keeps of journalDoHError.
const journalRedactedURL = "https://dns.example/[redacted]"
// journalTestLogger returns a logger that writes through the journal sink.
func journalTestLogger(t *testing.T) (*bytes.Buffer, zerolog.Logger) {
t.Helper()
level := zerolog.GlobalLevel()
zerolog.SetGlobalLevel(zerolog.DebugLevel)
t.Cleanup(func() { zerolog.SetGlobalLevel(level) })
var buf bytes.Buffer
return &buf, zerolog.New(zerolog.MultiLevelWriter(newJournalLevelWriter(&buf)))
}
func Test_journalLevelWriterRedactsAKeptLine(t *testing.T) {
buf, logger := journalTestLogger(t)
logger.Warn().Str("error", journalDoHError).Msg("upstream failed")
line := buf.String()
if !strings.Contains(line, journalRedactedURL) {
t.Fatalf("the sink kept the URL path: %s", line)
}
for _, secret := range []string{"SECRET0123", "pOgBAAAB"} {
if strings.Contains(line, secret) {
t.Fatalf("the sink kept %q: %s", secret, line)
}
}
}
func Test_journalLevelWriterRedactsTheProvisionSecrets(t *testing.T) {
origCdUID := cdUID
t.Cleanup(func() { cdUID = origCdUID })
cdUID = "org-v1-uidofthisdevice"
buf, logger := journalTestLogger(t)
logger.Warn().Str("detail", "device "+cdUID+" is gone").Msg("provision failed")
line := buf.String()
if strings.Contains(line, cdUID) {
t.Fatalf("the sink kept the resolver uid: %s", line)
}
if !strings.Contains(line, "[redacted]") {
t.Fatalf("the sink wrote no redaction mark: %s", line)
}
}
func Test_journalLevelWriterDropsADebugLineWithAURL(t *testing.T) {
buf, logger := journalTestLogger(t)
logger.Debug().Str("error", journalDoHError).Msg("upstream failed")
if buf.Len() != 0 {
t.Fatalf("the sink kept a debug line: %s", buf.String())
}
}
func Test_journalLevelWriterRedactsAMarkedInfoLine(t *testing.T) {
buf, logger := journalTestLogger(t)
journal(logger.Info()).Str("error", journalDoHError).Msg("upstream failed")
line := buf.String()
if !strings.Contains(line, journalRedactedURL) {
t.Fatalf("the sink kept the URL path: %s", line)
}
if !strings.Contains(line, `"level":"info"`) {
t.Fatalf("the sink changed the level: %s", line)
}
}
func Test_journalLevelWriterReportsTheOriginalLength(t *testing.T) {
var buf bytes.Buffer
writer := newJournalLevelWriter(&buf)
line := []byte(`{"level":"warn","error":"https://dns.example/org-v1-SECRET0123","message":"state"}` + "\n")
n, err := writer.WriteLevel(zerolog.WarnLevel, line)
if err != nil || n != len(line) {
t.Fatalf("write of a kept line = (%d, %v), want (%d, nil)", n, err, len(line))
}
if buf.Len() >= len(line) {
t.Fatalf("the sink wrote no shorter line: %s", buf.String())
}
}
// startInternalLogging drives the real cd mode setup and returns the program
// and the directory that holds the debug file and the journal file.
func startInternalLogging(t *testing.T) (*prog, string) {
t.Helper()
origSilent, origCdUID, origHomedir, origVerbose := silent, cdUID, homedir, verbose
origMainLog, origProxyLog := mainLog.Load(), ctrld.ProxyLogger.Load()
origLevel := zerolog.GlobalLevel()
t.Cleanup(func() {
silent, cdUID, homedir, verbose = origSilent, origCdUID, origHomedir, origVerbose
mainLog.Store(origMainLog)
ctrld.ProxyLogger.Store(origProxyLog)
zerolog.SetGlobalLevel(origLevel)
})
if origMainLog == nil {
discard := zerolog.New(io.Discard)
mainLog.Store(&discard)
}
dir := t.TempDir()
homedir = dir
cdUID = "test-uid"
silent = false
verbose = 0
p := &prog{cfg: &ctrld.Config{}}
p.initInternalLogging(nil)
t.Cleanup(p.closeInternalLogs)
return p, dir
}
// Test_initInternalLogging_redactsTheJournalFile drives a cd mode run: support
// downloads the journal file, so it holds the redacted URL, while the debug
// file stays raw for the developer who reads it on the host.
func Test_initInternalLogging_redactsTheJournalFile(t *testing.T) {
_, dir := startInternalLogging(t)
mainLog.Load().Warn().Str("error", journalDoHError).Msg("upstream failed")
journalText := rotatingFileTestContent(t, filepath.Join(dir, journalLogFileName))
if !strings.Contains(journalText, journalRedactedURL) {
t.Fatalf("journal file misses the redacted URL: %s", journalText)
}
if strings.Contains(journalText, "SECRET0123") {
t.Fatalf("journal file kept the resolver path: %s", journalText)
}
debugText := rotatingFileTestContent(t, filepath.Join(dir, logFileName))
if !strings.Contains(debugText, "org-v1-SECRET0123?dns=pOgBAAAB") {
t.Fatalf("debug file lost the raw URL: %s", debugText)
}
}
// Test_stopClosesTheInternalLogFiles proves that a stopped service leaves no
// open handle on the debug file and the journal file.
func Test_stopClosesTheInternalLogFiles(t *testing.T) {
origPin := cdDeactivationPin.Load()
t.Cleanup(func() { cdDeactivationPin.Store(origPin) })
cdDeactivationPin.Store(defaultDeactivationPin)
p, _ := startInternalLogging(t)
p.stopCh = make(chan struct{})
p.dnsWatcherStopCh = make(chan struct{})
for _, writer := range []*logWriter{p.internalLogWriter, p.internalJournalWriter} {
if writer.rotating() == nil {
t.Fatal("the setup opened no file")
}
}
if err := p.Stop(nil); err != nil {
t.Fatalf("Stop: %v", err)
}
for name, writer := range map[string]*logWriter{
logFileName: p.internalLogWriter,
journalLogFileName: p.internalJournalWriter,
} {
if writer.rotating() != nil {
t.Fatalf("%s stayed open after the stop", name)
}
}
}
+73
View File
@@ -0,0 +1,73 @@
package cli
import (
"os"
"sync/atomic"
"github.com/Control-D-Inc/ctrld/internal/router"
)
// logPathFile is the open log_path file. The header renderer and the log
// readers need it, and both run outside this file.
var logPathFile atomic.Pointer[rotatingFile]
// pendingLogPathRotation holds the rotation of a start-time backup. The
// logger reaches no file at that point, so initLogging reports it later.
var pendingLogPathRotation atomic.Pointer[logRotation]
// openLogPathWriter opens the log_path file of this run and stores it for the
// header renderer and the log readers. It returns nil when no log_path is set.
// A start calls it more than once, so a call for the file that is already open
// keeps that file: a second open would put a second header in it.
func openLogPathWriter(doBackup bool) *rotatingFile {
logFilePath := normalizeLogFilePath(cfg.Service.LogPath)
if logFilePath == "" {
return nil
}
budget := debugLogBudget(&cfg.Service, router.Name() != "")
rf := logPathFile.Load()
if rf == nil || rf.currentPath() != logFilePath {
rf = newLogPathFile(logFilePath, budget)
} else {
rf.setBudget(budget)
}
if doBackup {
backupLogPathFile(rf)
}
return rf
}
// newLogPathFile opens path and puts it in place of the file of an earlier
// call. A ctrld that cannot write the log file its operator asked for leaves
// no record of the run, so the open failure ends the process.
func newLogPathFile(path string, budget logBudget) *rotatingFile {
rf, err := newRotatingFile(path, budget, nil)
if err != nil {
mainLog.Load().Error().Msgf("failed to create log file: %v", err)
os.Exit(1)
}
rf.onRotate = emitLogRotated
if old := logPathFile.Swap(rf); old != nil {
_ = old.close()
}
return rf
}
// backupLogPathFile moves the file of an earlier run away, so no run
// overwrites another.
func backupLogPathFile(rf *rotatingFile) {
if !logFileHasContent(rf.currentPath()) {
return
}
record, err := rf.rotateNow()
if err != nil {
mainLog.Load().Error().Msgf("could not backup old log file: %v", err)
return
}
pendingLogPathRotation.Store(record)
}
func logFileHasContent(path string) bool {
st, err := os.Stat(path)
return err == nil && st.Size() > 0
}
+187
View File
@@ -0,0 +1,187 @@
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)
}
}
+291
View File
@@ -0,0 +1,291 @@
package cli
import (
"bytes"
"errors"
"io"
"os"
"strings"
)
// logSendDebugBudget bounds the debug bytes of one upload. The journal is
// always whole, so an upload stays small and keeps the retained stream. A test
// lowers it, because a few small files must cross it.
var logSendDebugBudget int64 = 10 << 20
// lineScanBufferSize bounds the memory that the search for a line boundary
// needs, whatever the length of the line.
const lineScanBufferSize = 32 * 1024
var (
errInternalLogEmpty = errors.New("internal log is empty")
errLogFileEmpty = errors.New("log file is empty")
)
type logReader struct {
r io.ReadCloser
size int64
}
// logFilePart is the part of one open log file that an upload carries. An
// offset of 0 means the whole file. size is the size at the open, so a write
// after the selection stays out of the upload and the budget holds.
type logFilePart struct {
path string
f *os.File
offset int64
size int64
}
// logReader composes the upload of the current logging mode and leads it with
// the header of this request. full lifts the bound on the debug bytes.
func (p *prog) logReader(full bool) (*logReader, error) {
lr, err := p.modeLogReader(full)
if err != nil {
return nil, err
}
// An empty upload must stay empty, because the handlers answer 301 on a
// size of 0.
if lr.size == 0 {
return lr, nil
}
header := p.sendLogHeader()
return &logReader{
r: &multiCloser{
Reader: io.MultiReader(bytes.NewReader(header), lr.r),
closers: []io.Closer{lr.r},
},
size: lr.size + int64(len(header)),
}, nil
}
// modeLogReader composes the body of the current logging mode.
func (p *prog) modeLogReader(full bool) (*logReader, error) {
if p.needInternalLogging() {
return p.internalLogReader(full)
}
return p.logPathReader(full)
}
// internalLogReader puts the newest debug bytes first, then the marker, then
// the journal, so a support reader finds the retained stream after the marker.
func (p *prog) internalLogReader(full bool) (*logReader, error) {
lw, jlw := p.internalWriters()
if lw == nil {
return nil, errors.New("nil internal log writer")
}
if jlw == nil {
return nil, errors.New("nil internal journal writer")
}
var upload logParts
debugBytes := upload.addStream(lw, logSendBudget(full))
upload.addBytes([]byte(logWriterLogEndMarker))
journalBytes := upload.addStream(jlw, 0)
// The marker alone is not a log, and the handlers answer 301 on an empty
// upload.
if debugBytes+journalBytes == 0 {
upload.close()
return nil, errInternalLogEmpty
}
return upload.reader(errInternalLogEmpty)
}
// logPathReader composes the upload of the log_path stream. The journal needs
// internal logging, so this upload holds the debug files alone. A run that
// opened no log_path file has nothing to send.
func (p *prog) logPathReader(full bool) (*logReader, error) {
rf := logPathFile.Load()
if p.cfg.Service.LogPath == "" || rf == nil {
return emptyLogReader(), nil
}
var upload logParts
upload.addBoundedFiles(rf.paths(), logSendBudget(full))
return upload.reader(errLogFileEmpty)
}
// emptyLogReader yields no bytes, so the handlers answer 301 instead of
// sending an upload without a line.
func emptyLogReader() *logReader {
return &logReader{r: io.NopCloser(strings.NewReader(""))}
}
// logSendBudget reports the debug bytes that one upload may carry. Zero means
// every file.
func logSendBudget(full bool) int64 {
if full {
return 0
}
return logSendDebugBudget
}
// boundedLogFiles opens the files, reads each size once, and picks the newest
// bytes that fit in budget. files come oldest first, and so do the parts. The
// oldest part starts at a line boundary, so the upload holds whole lines only.
// A budget of 0 or less takes every file. The files that fall out of the
// budget close here; the parts own the files they hold.
func boundedLogFiles(files []string, budget int64) []logFilePart {
parts := openLogParts(files)
remaining := budget
first := len(parts)
for first > 0 {
part := &parts[first-1]
if budget <= 0 || part.size <= remaining {
remaining -= part.size
first--
continue
}
if offset, ok := lineStartAt(part.f, part.size-remaining); ok {
part.offset = offset
first--
}
// Without a line boundary in the tail, the upload would start inside a
// line, so the file stays out.
break
}
for _, part := range parts[:first] {
part.f.Close()
}
return parts[first:]
}
// openLogParts opens each file through the platform opener, whose share flags
// let a rotation move the file away while Windows reads it. A rotation can
// also move a file away between the listing and the open, so a file that does
// not open must not stop the rest.
func openLogParts(files []string) []logFilePart {
parts := make([]logFilePart, 0, len(files))
for _, path := range files {
f, err := openLogFile(path, os.O_RDONLY)
if err != nil {
continue
}
st, err := f.Stat()
if err != nil {
f.Close()
continue
}
parts = append(parts, logFilePart{path: path, f: f, size: st.Size()})
}
return parts
}
// lineStartAt returns the offset where a whole line starts at or after from.
// A cut that already lands on a line start keeps the line that follows it. It
// reports false when no newline follows.
func lineStartAt(f *os.File, from int64) (int64, bool) {
if startsLine(f, from) {
return from, true
}
if _, err := f.Seek(from, io.SeekStart); err != nil {
return 0, false
}
buf := make([]byte, lineScanBufferSize)
offset := from
for {
n, readErr := f.Read(buf)
if index := bytes.IndexByte(buf[:n], '\n'); index >= 0 {
return offset + int64(index) + 1, true
}
offset += int64(n)
if readErr != nil {
return 0, false
}
}
}
// startsLine reports whether from is the first byte of a line.
func startsLine(f *os.File, from int64) bool {
if from == 0 {
return true
}
var previous [1]byte
if _, err := f.ReadAt(previous[:], from-1); err != nil {
return false
}
return previous[0] == '\n'
}
// logParts collects the readers of one upload and the files it must close.
type logParts struct {
readers []io.Reader
closers []io.Closer
size int64
}
func (lp *logParts) addBytes(b []byte) {
if len(b) == 0 {
return
}
lp.readers = append(lp.readers, bytes.NewReader(b))
lp.size += int64(len(b))
}
// addFiles takes the open parts. Each reader ends at the size of the open, so
// the bytes that a writer adds later stay out of this upload.
func (lp *logParts) addFiles(parts []logFilePart) {
for _, part := range parts {
length := part.size - part.offset
if length <= 0 {
part.f.Close()
continue
}
lp.readers = append(lp.readers, io.NewSectionReader(part.f, part.offset, length))
lp.closers = append(lp.closers, part.f)
lp.size += length
}
}
// addBoundedFiles adds the newest bytes of files that fit in budget. A budget
// of 0 or less takes every file.
func (lp *logParts) addBoundedFiles(files []string, budget int64) {
lp.addFiles(boundedLogFiles(files, budget))
}
// addStream adds the bytes of one stream and reports how many it added. A
// stream that has a file takes its files, and a stream that keeps its lines in
// memory takes its buffer, so one failed file never hides the other stream.
func (lp *logParts) addStream(lw *logWriter, budget int64) int64 {
before := lp.size
if rf := lw.rotating(); rf != nil {
lp.addBoundedFiles(rf.paths(), budget)
return lp.size - before
}
lp.addBytes(lw.bufferedBytes())
return lp.size - before
}
// reader joins the parts. The size is the number of bytes that the reader
// yields, so the caller reports what it uploads.
func (lp *logParts) reader(empty error) (*logReader, error) {
if lp.size == 0 {
lp.close()
return nil, empty
}
return &logReader{
r: &multiCloser{Reader: io.MultiReader(lp.readers...), closers: lp.closers},
size: lp.size,
}, nil
}
func (lp *logParts) close() {
for _, c := range lp.closers {
c.Close()
}
}
// multiCloser wraps an io.Reader and closes multiple underlying closers.
type multiCloser struct {
io.Reader
closers []io.Closer
}
func (mc *multiCloser) Close() error {
var firstErr error
for _, c := range mc.closers {
if err := c.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
+572
View File
@@ -0,0 +1,572 @@
package cli
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
)
const (
boundedLogTestLineSize = 100
boundedLogTestLineCount = 30
boundedLogTestFileCount = 5
boundedLogTestBudget = 10000
)
// boundedLogTestLine returns a line of a fixed size that names its file, so a
// test can tell one part from another.
func boundedLogTestLine(file, line int) []byte {
head := fmt.Sprintf("file=%d line=%d ", file, line)
return []byte(head + strings.Repeat("x", boundedLogTestLineSize-len(head)-1) + "\n")
}
// writeBoundedLogTestFiles creates the log files of one stream, oldest first.
func writeBoundedLogTestFiles(t *testing.T) []string {
t.Helper()
dir := t.TempDir()
files := make([]string, 0, boundedLogTestFileCount)
for file := 0; file < boundedLogTestFileCount; file++ {
var content bytes.Buffer
for line := 0; line < boundedLogTestLineCount; line++ {
content.Write(boundedLogTestLine(file, line))
}
path := filepath.Join(dir, fmt.Sprintf("ctrld.log.%d", boundedLogTestFileCount-file))
if err := os.WriteFile(path, content.Bytes(), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
files = append(files, path)
}
return files
}
func logFilesContent(t *testing.T, files []string) []byte {
t.Helper()
var joined []byte
for _, path := range files {
joined = append(joined, []byte(rotatingFileTestContent(t, path))...)
}
return joined
}
// boundedLogTestFileSize is the size of each file that the bounded reader
// tests select from.
const boundedLogTestFileSize = int64(boundedLogTestLineCount * boundedLogTestLineSize)
// wantTailLine reports the first line that fits in budget, for a budget that
// ends inside the fourth newest file. A cut that lands on a line start keeps
// that line.
func wantTailLine(budget int64) int {
cut := boundedLogTestFileSize - (budget - 3*boundedLogTestFileSize)
line := cut / boundedLogTestLineSize
if cut%boundedLogTestLineSize != 0 {
line++
}
return int(line)
}
// assertTailPart checks that a part starts at a line boundary and that it
// starts with the expected whole line.
func assertTailPart(t *testing.T, part logFilePart, file, line int) {
t.Helper()
content := []byte(rotatingFileTestContent(t, part.path))
if part.offset <= 0 || part.offset >= int64(len(content)) {
t.Fatalf("offset = %d, want a position inside %s", part.offset, part.path)
}
if content[part.offset-1] != '\n' {
t.Fatalf("offset %d in %s does not follow a newline", part.offset, part.path)
}
if want := boundedLogTestLine(file, line); !bytes.HasPrefix(content[part.offset:], want) {
t.Fatalf("tail of %s starts with %.24q, want %.24q", part.path, content[part.offset:], want)
}
}
// closeLogParts closes the files of parts that no upload took.
func closeLogParts(parts []logFilePart) {
for _, part := range parts {
part.f.Close()
}
}
// partsSize reports the bytes that the parts hold on disk.
func partsSize(t *testing.T, parts []logFilePart) int64 {
t.Helper()
var size int64
for _, part := range parts {
content := rotatingFileTestContent(t, part.path)
size += int64(len(content)) - part.offset
}
return size
}
// assertBoundedParts checks the shape that every bounded selection shares:
// the newest three files whole, oldest first, after the tail of the fourth.
func assertBoundedParts(t *testing.T, files []string, parts []logFilePart, tailLine int) {
t.Helper()
wantPaths := files[1:]
if len(parts) != len(wantPaths) {
t.Fatalf("parts = %d, want %d", len(parts), len(wantPaths))
}
for i, part := range parts {
if part.path != wantPaths[i] {
t.Fatalf("parts[%d].path = %s, want %s", i, part.path, wantPaths[i])
}
}
assertTailPart(t, parts[0], 1, tailLine)
for i, part := range parts[1:] {
if part.offset != 0 {
t.Fatalf("parts[%d].offset = %d, want the whole file", i+1, part.offset)
}
}
}
func Test_boundedLogFiles(t *testing.T) {
files := writeBoundedLogTestFiles(t)
t.Run("budget ends at a line boundary", func(t *testing.T) {
parts := boundedLogFiles(files, boundedLogTestBudget)
t.Cleanup(func() { closeLogParts(parts) })
assertBoundedParts(t, files, parts, wantTailLine(boundedLogTestBudget))
// The cut lands on a line start, so the tail begins at the cut and the
// parts fill the budget exactly.
if size := partsSize(t, parts); size != boundedLogTestBudget {
t.Fatalf("size = %d, want the whole budget of %d", size, boundedLogTestBudget)
}
})
t.Run("budget ends inside a line", func(t *testing.T) {
const budget = boundedLogTestBudget + boundedLogTestLineSize/2
parts := boundedLogFiles(files, budget)
t.Cleanup(func() { closeLogParts(parts) })
assertBoundedParts(t, files, parts, wantTailLine(budget))
if size := partsSize(t, parts); size > budget {
t.Fatalf("size = %d, want at most %d", size, budget)
}
})
t.Run("no budget keeps every file", func(t *testing.T) {
parts := boundedLogFiles(files, 0)
t.Cleanup(func() { closeLogParts(parts) })
if len(parts) != len(files) {
t.Fatalf("parts = %d, want %d", len(parts), len(files))
}
for i, part := range parts {
if part.path != files[i] || part.offset != 0 {
t.Fatalf("parts[%d] = %+v, want %s whole", i, part, files[i])
}
}
want := int64(boundedLogTestFileCount) * boundedLogTestFileSize
if size := partsSize(t, parts); size != want {
t.Fatalf("size = %d, want %d", size, want)
}
})
}
// Test_logReaderDoesNotFollowASymlink covers the one open of the reader. A
// symlink in place of a log file points at a file of another owner, and the
// writer refuses it in the same way.
func Test_logReaderDoesNotFollowASymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("the Windows opener takes no symlink flag")
}
dir := t.TempDir()
planted := filepath.Join(dir, "planted.log")
if err := os.WriteFile(planted, []byte("planted line\n"), 0600); err != nil {
t.Fatalf("write the planted file: %v", err)
}
path := filepath.Join(dir, "ctrld.log")
if err := os.Symlink(planted, path); err != nil {
t.Fatalf("symlink: %v", err)
}
parts := boundedLogFiles([]string{path}, 4)
if len(parts) != 0 {
t.Fatalf("the selection opened a symlink: %+v", parts)
}
var upload logParts
upload.addFiles(parts)
if upload.size != 0 {
t.Fatalf("the upload holds %d bytes of a symlink, want none", upload.size)
}
}
// internalLogReaderTestBudget keeps the files small: four lines fill one file,
// so a few writes build the whole backup chain.
var internalLogReaderTestBudget = logBudget{maxSize: 4 * rotatingFileTestLineSize, backups: 2}
const internalLogReaderTestLines = 12
// newTestStreamWriter opens one persisted stream in the temporary home
// directory.
func newTestStreamWriter(t *testing.T, name string) *logWriter {
t.Helper()
lw := newLogWriterWithSize(logWriterSize)
if err := lw.setLogFile(absHomeDir(name), internalLogReaderTestBudget); err != nil {
t.Fatalf("setLogFile %s: %v", name, err)
}
t.Cleanup(lw.closeLogFile)
return lw
}
// internalLogReaderTestSendBudget holds one debug file and a half, so the
// default upload cuts inside the oldest of the three files.
var internalLogReaderTestSendBudget = internalLogReaderTestBudget.maxSize * 3 / 2
// useTestSendBudget lowers the bound on the debug bytes of one upload, so a
// few small files cross it.
func useTestSendBudget(t *testing.T, budget int64) {
t.Helper()
origBudget := logSendDebugBudget
t.Cleanup(func() { logSendDebugBudget = origBudget })
logSendDebugBudget = budget
}
// setupInternalLogReaderTest returns a cd mode prog whose debug stream and
// journal stream both hold a full backup chain on disk.
func setupInternalLogReaderTest(t *testing.T) *prog {
t.Helper()
origSilent, origCdUID, origHomedir := silent, cdUID, homedir
t.Cleanup(func() { silent, cdUID, homedir = origSilent, origCdUID, origHomedir })
captureMainLog(t)
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
stubLogHeaderNetworkRead(t, "en9")
silent, cdUID, homedir = false, "test-uid", t.TempDir()
p := &prog{cfg: &ctrld.Config{}}
p.internalLogWriter = newTestStreamWriter(t, logFileName)
p.internalJournalWriter = newTestStreamWriter(t, journalLogFileName)
for seq := 1; seq <= internalLogReaderTestLines; seq++ {
if _, err := p.internalLogWriter.Write(rotatingFileTestLine(1, seq)); err != nil {
t.Fatalf("write debug line %d: %v", seq, err)
}
if _, err := p.internalJournalWriter.Write(rotatingFileTestLine(2, seq)); err != nil {
t.Fatalf("write journal line %d: %v", seq, err)
}
}
return p
}
// readLogReader reads one upload and checks that the reported size is the
// number of bytes it yields.
func readLogReader(t *testing.T, p *prog, full bool) []byte {
t.Helper()
lr, err := p.logReader(full)
if err != nil {
t.Fatalf("logReader(%v): %v", full, err)
}
defer lr.r.Close()
data, err := io.ReadAll(lr.r)
if err != nil {
t.Fatalf("read upload: %v", err)
}
if lr.size != int64(len(data)) {
t.Fatalf("size = %d, want %d", lr.size, len(data))
}
return data
}
// splitSendHeader takes the header line off the front of an upload and checks
// that it names this request. It returns the rest of the upload.
func splitSendHeader(t *testing.T, upload []byte, since time.Time) []byte {
t.Helper()
end := bytes.IndexByte(upload, '\n')
if end < 0 {
t.Fatalf("upload holds no header line: %.64q", upload)
}
parsed, _ := parseLogHeader(t, upload[:end+1])
if parsed.Message != logHeaderMessage {
t.Fatalf("line 1 of the upload = %q, want %q", parsed.Message, logHeaderMessage)
}
if parsed.Trigger != "send" {
t.Fatalf("trigger = %q, want %q", parsed.Trigger, "send")
}
rendered, err := time.Parse(time.RFC3339, parsed.Time)
if err != nil {
t.Fatalf("parse header time %q: %v", parsed.Time, err)
}
// The header format drops the fraction of a second, so the window starts
// at the second that holds since.
if rendered.Before(since.Truncate(time.Second)) || rendered.After(time.Now().Add(time.Second)) {
t.Fatalf("header time = %s, want a time between %s and now", rendered, since)
}
return upload[end+1:]
}
// splitUpload takes the header off an upload and cuts the rest at the log end
// marker, which parts the debug bytes from the journal bytes.
func splitUpload(t *testing.T, upload []byte, since time.Time) (debugPart, journalPart []byte) {
t.Helper()
body := splitSendHeader(t, upload, since)
debugPart, journalPart, found := bytes.Cut(body, []byte(logWriterLogEndMarker))
if !found {
t.Fatalf("upload holds no log end marker: %.64q", body)
}
return debugPart, journalPart
}
func Test_logRequestPath(t *testing.T) {
if got := logRequestPath(sendLogsPath, false); got != sendLogsPath {
t.Fatalf("logRequestPath(%q, false) = %q, want the path itself", sendLogsPath, got)
}
want := viewLogsPath + "?full=1"
if got := logRequestPath(viewLogsPath, true); got != want {
t.Fatalf("logRequestPath(%q, true) = %q, want %q", viewLogsPath, got, want)
}
}
func Test_logReader_internalComposition(t *testing.T) {
p := setupInternalLogReaderTest(t)
useTestSendBudget(t, internalLogReaderTestSendBudget)
debugFiles := p.internalLogWriter.rotating().paths()
journalFiles := p.internalJournalWriter.rotating().paths()
wantFiles := internalLogReaderTestBudget.backups + 1
if len(debugFiles) != wantFiles || len(journalFiles) != wantFiles {
t.Fatalf("files: debug %v, journal %v, want %d of each", debugFiles, journalFiles, wantFiles)
}
wholeDebug := logFilesContent(t, debugFiles)
wholeJournal := logFilesContent(t, journalFiles)
start := time.Now()
debugPart, journalPart := splitUpload(t, readLogReader(t, p, false), start)
if int64(len(debugPart)) > logSendDebugBudget {
t.Fatalf("debug part = %d bytes, want at most the budget of %d", len(debugPart), logSendDebugBudget)
}
if len(debugPart) >= len(wholeDebug) {
t.Fatalf("debug part = %d bytes, want fewer than the %d bytes of %v", len(debugPart), len(wholeDebug), debugFiles)
}
if !bytes.HasSuffix(wholeDebug, debugPart) {
t.Fatalf("debug part = %.24q, want the newest %d bytes of %v", debugPart, len(debugPart), debugFiles)
}
cut := len(wholeDebug) - len(debugPart)
if wholeDebug[cut-1] != '\n' {
t.Fatalf("the debug part starts at %d, which does not follow a newline", cut)
}
if !bytes.Equal(journalPart, wholeJournal) {
t.Fatalf("journal part = %d bytes, want the %d bytes of %v", len(journalPart), len(wholeJournal), journalFiles)
}
// The full upload lifts the bound, so it holds every debug file.
fullDebug, fullJournal := splitUpload(t, readLogReader(t, p, true), start)
if !bytes.Equal(fullDebug, wholeDebug) {
t.Fatalf("full debug part = %d bytes, want the %d bytes of %v", len(fullDebug), len(wholeDebug), debugFiles)
}
if !bytes.Equal(fullJournal, wholeJournal) {
t.Fatalf("full journal part = %d bytes, want the %d bytes of %v", len(fullJournal), len(wholeJournal), journalFiles)
}
}
func Test_logReader_logPathComposition(t *testing.T) {
path := setupLogPathTest(t)
stubLogHeaderNetworkRead(t, "en9")
initLoggingWithBackup(false)
line := strings.Repeat("x", logPathTestLineSize)
for i := 0; i < logPathTestLineCount; i++ {
mainLog.Load().Info().Msg(line)
}
files := logPathFile.Load().paths()
if want := []string{path + ".1", path}; !slices.Equal(files, want) {
t.Fatalf("paths() = %v, want %v", files, want)
}
p := &prog{cfg: &cfg}
start := time.Now()
got := splitSendHeader(t, readLogReader(t, p, false), start)
if want := logFilesContent(t, files); !bytes.Equal(got, want) {
t.Fatalf("upload = %d bytes, want the %d bytes of %v", len(got), len(want), files)
}
if bytes.Contains(got, []byte(logWriterLogEndMarker)) {
t.Fatalf("log_path upload holds the log end marker")
}
}
// setupMemoryLogReaderTest returns a cd mode prog whose streams keep their
// lines in memory, because no file is open.
func setupMemoryLogReaderTest(t *testing.T) *prog {
t.Helper()
origSilent, origCdUID := silent, cdUID
t.Cleanup(func() { silent, cdUID = origSilent, origCdUID })
captureMainLog(t)
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
stubLogHeaderNetworkRead(t, "en9")
silent, cdUID = false, "test-uid"
p := &prog{cfg: &ctrld.Config{}}
p.internalLogWriter = newLogWriterWithSize(logWriterSize)
p.internalJournalWriter = newLogWriterWithSize(logWriterSize)
return p
}
func Test_logReader_memoryFallbackStartsWithHeader(t *testing.T) {
p := setupMemoryLogReaderTest(t)
debugLine := []byte("debug line\n")
journalLine := []byte("journal line\n")
if _, err := p.internalLogWriter.Write(debugLine); err != nil {
t.Fatalf("write the debug buffer: %v", err)
}
if _, err := p.internalJournalWriter.Write(journalLine); err != nil {
t.Fatalf("write the journal buffer: %v", err)
}
start := time.Now()
got := splitSendHeader(t, readLogReader(t, p, false), start)
var want []byte
want = append(want, debugLine...)
want = append(want, []byte(logWriterLogEndMarker)...)
want = append(want, journalLine...)
if !bytes.Equal(got, want) {
t.Fatalf("upload after the header = %q, want %q", got, want)
}
}
// useTestHomeDir points the ctrld home directory at a temporary directory, so
// the internal log files of a test never reach the real one.
func useTestHomeDir(t *testing.T) {
t.Helper()
origHomedir := homedir
t.Cleanup(func() { homedir = origHomedir })
homedir = t.TempDir()
}
// newSeededJournalWriter writes the journal files of an earlier run and
// returns a new writer over them. Its memory buffer is empty, which is what a
// restart finds, so a rule that reads the buffer loses these lines.
func newSeededJournalWriter(t *testing.T, lines int) *logWriter {
t.Helper()
previous := newTestStreamWriter(t, journalLogFileName)
for seq := 1; seq <= lines; seq++ {
if _, err := previous.Write(rotatingFileTestLine(2, seq)); err != nil {
t.Fatalf("write journal line %d: %v", seq, err)
}
}
previous.closeLogFile()
return newTestStreamWriter(t, journalLogFileName)
}
// mixedStreamJournalLines fill two journal files, because four lines fill one.
const mixedStreamJournalLines = 6
func Test_logReader_mixedStreams(t *testing.T) {
p := setupMemoryLogReaderTest(t)
useTestHomeDir(t)
p.internalJournalWriter = newSeededJournalWriter(t, mixedStreamJournalLines)
debugLine := []byte("debug line\n")
if _, err := p.internalLogWriter.Write(debugLine); err != nil {
t.Fatalf("write the debug buffer: %v", err)
}
journalFiles := p.internalJournalWriter.rotating().paths()
if len(journalFiles) != 2 {
t.Fatalf("journal files = %v, want two", journalFiles)
}
start := time.Now()
got := splitSendHeader(t, readLogReader(t, p, false), start)
var want []byte
want = append(want, debugLine...)
want = append(want, []byte(logWriterLogEndMarker)...)
want = append(want, logFilesContent(t, journalFiles)...)
if !bytes.Equal(got, want) {
t.Fatalf("upload after the header = %d bytes, want the %d bytes of the debug buffer, the marker and %v", len(got), len(want), journalFiles)
}
}
func Test_logReader_emptyStaysEmpty(t *testing.T) {
t.Run("internal mode without a line", func(t *testing.T) {
p := setupMemoryLogReaderTest(t)
lr, err := p.logReader(false)
if !errors.Is(err, errInternalLogEmpty) {
t.Fatalf("logReader error = %v, want %v", err, errInternalLogEmpty)
}
if lr != nil {
t.Fatalf("logReader = %+v, want no reader", lr)
}
})
t.Run("internal mode with empty files", func(t *testing.T) {
p := setupMemoryLogReaderTest(t)
useTestHomeDir(t)
p.internalLogWriter = newTestStreamWriter(t, logFileName)
p.internalJournalWriter = newTestStreamWriter(t, journalLogFileName)
lr, err := p.logReader(false)
if !errors.Is(err, errInternalLogEmpty) {
t.Fatalf("logReader error = %v, want %v", err, errInternalLogEmpty)
}
if lr != nil {
t.Fatalf("logReader = %+v, want no reader", lr)
}
})
t.Run("no log_path to read", func(t *testing.T) {
origSilent, origCdUID := silent, cdUID
t.Cleanup(func() { silent, cdUID = origSilent, origCdUID })
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
stubLogHeaderNetworkRead(t, "en9")
silent, cdUID = false, ""
p := &prog{cfg: &ctrld.Config{}}
lr, err := p.logReader(false)
if err != nil {
t.Fatalf("logReader: %v", err)
}
defer lr.r.Close()
if lr.size != 0 {
t.Fatalf("size = %d, want 0 so the handler answers 301", lr.size)
}
data, err := io.ReadAll(lr.r)
if err != nil {
t.Fatalf("read the upload: %v", err)
}
if len(data) != 0 {
t.Fatalf("upload = %q, want no bytes", data)
}
})
}
// Test_boundedLogFilesFixTheSizeAtSelection appends through the real writer
// between the selection and the reader creation. The upload must hold the
// bytes that the selection counted, so the budget holds under a live writer.
func Test_boundedLogFilesFixTheSizeAtSelection(t *testing.T) {
rf := newTestRotatingFile(t, logBudget{maxSize: 1 << 20, backups: 1})
if _, err := rf.Write([]byte("one\n")); err != nil {
t.Fatal(err)
}
parts := boundedLogFiles(rf.paths(), 4)
if _, err := rf.Write([]byte("two\n")); err != nil {
t.Fatal(err)
}
var upload logParts
upload.addFiles(parts)
t.Cleanup(upload.close)
got, err := io.ReadAll(io.MultiReader(upload.readers...))
if err != nil {
t.Fatal(err)
}
if upload.size != 4 || string(got) != "one\n" {
t.Fatalf("upload = %q (%d bytes), want %q within the budget of 4", got, upload.size, "one\n")
}
}
+80
View File
@@ -0,0 +1,80 @@
package cli
import (
"context"
"io"
"net/http"
"os"
"time"
)
// logTailPollInterval is the time between two reads of a tailed log file.
const logTailPollInterval = 200 * time.Millisecond
// followLogFile streams the lines of path until ctx ends. It first sends the
// last numLines lines, or starts at the end when numLines is 0. A rotation
// moves the file away or truncates it, so the poll reopens the path when the
// file behind it changes or shrinks.
func followLogFile(ctx context.Context, w io.Writer, flusher http.Flusher, path string, numLines int) {
f, err := os.Open(path)
if err != nil {
return
}
defer func() { f.Close() }()
if numLines > 0 {
if tail := tailFileLastLines(f, numLines); len(tail) > 0 {
w.Write(tail)
flusher.Flush()
}
} else {
f.Seek(0, io.SeekEnd)
}
buf := make([]byte, 4096)
ticker := time.NewTicker(logTailPollInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
n, err := f.Read(buf)
if n > 0 {
if _, werr := w.Write(buf[:n]); werr != nil {
return
}
flusher.Flush()
}
if err != nil && err != io.EOF {
return
}
if n == 0 && logFileReplaced(f, path) {
reopened, err := os.Open(path)
if err != nil {
continue
}
f.Close()
f = reopened
}
case <-ctx.Done():
return
}
}
}
// logFileReplaced reports whether path names another file than f, or a file
// shorter than the read position. Both mean that a rotation happened.
func logFileReplaced(f *os.File, path string) bool {
current, err := os.Stat(path)
if err != nil {
return false
}
open, err := f.Stat()
if err != nil {
return true
}
if !os.SameFile(current, open) {
return true
}
position, err := f.Seek(0, io.SeekCurrent)
return err == nil && current.Size() < position
}
+168 -253
View File
@@ -2,18 +2,14 @@ package cli
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/router"
)
const (
@@ -24,8 +20,7 @@ const (
logWriterInitEndMarker = "\n\n=== INIT_END ===\n\n"
logWriterLogEndMarker = "\n\n=== LOG_END ===\n\n"
logFileName = "ctrld.log"
logFileMaxSize = 1024 * 1024 * 5 // 5 MB
logFileName = "ctrld.log"
)
type logViewResponse struct {
@@ -37,29 +32,21 @@ type logSentResponse struct {
Error string `json:"error"`
}
type logReader struct {
r io.ReadCloser
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.
// When a file path is configured via setLogFile, writes are also persisted to
// a rotated file on disk (max logFileMaxSize, 1 backup) so logs survive restarts.
// When a file is configured via setLogFile, writes also go to that file, which
// rotates within its budget, so logs survive restarts.
type logWriter struct {
mu sync.Mutex
buf bytes.Buffer
size int
subscribers []*logSubscriber
// File persistence fields.
logFile *os.File
logFilePath string
logFileSize int64
file *rotatingFile
}
// newLogWriter creates an internal log writer.
@@ -74,82 +61,50 @@ func newSmallLogWriter() *logWriter {
// newLogWriterWithSize creates an internal log writer with a given buffer size.
func newLogWriterWithSize(size int) *logWriter {
lw := &logWriter{size: size}
return lw
return &logWriter{size: size}
}
// setLogFile configures file-backed persistence for the log writer.
// The directory is created if it does not exist. An existing file is
// opened in append mode and its current size is tracked for rotation.
func (lw *logWriter) setLogFile(path string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0750); err != nil {
return fmt.Errorf("creating log directory: %w", err)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0600)
// setLogFile persists the stream to a file that rotates within the budget.
func (lw *logWriter) setLogFile(path string, budget logBudget) error {
rf, err := newRotatingFile(path, budget, nil)
if err != nil {
return fmt.Errorf("opening log file: %w", err)
}
st, err := f.Stat()
if err != nil {
f.Close()
return fmt.Errorf("stat log file: %w", err)
return err
}
rf.onRotate = emitLogRotated
lw.mu.Lock()
defer lw.mu.Unlock()
lw.logFile = f
lw.logFilePath = path
lw.logFileSize = st.Size()
lw.file = rf
return nil
}
// rotateLogFile rotates the current log file to a .1 backup.
// It returns true if lw.logFile is usable after the call, false otherwise.
// Must be called with lw.mu held.
func (lw *logWriter) rotateLogFile() bool {
if lw.logFile == nil {
return false
}
lw.logFile.Close()
backupPath := lw.logFilePath + ".1"
// Best effort: rename current to backup (overwrites old backup).
os.Rename(lw.logFilePath, backupPath)
f, err := os.OpenFile(lw.logFilePath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0600)
if err != nil {
// If we can't reopen, disable file logging.
lw.logFile = nil
lw.logFileSize = 0
return false
}
lw.logFile = f
lw.logFileSize = 0
return true
}
// closeLogFile closes the backing file if open.
func (lw *logWriter) closeLogFile() {
lw.mu.Lock()
defer lw.mu.Unlock()
if lw.logFile != nil {
lw.logFile.Close()
lw.logFile = nil
if lw.file == nil {
return
}
lw.file.close()
lw.file = nil
}
// logFilePaths returns the paths to the current log file and its backup
// (if they exist) for inclusion in log send payloads.
func (lw *logWriter) logFilePaths() (current, backup string) {
// rotating returns the backing file, so a caller works with the file itself
// instead of one pass-through method for each of its calls. A stream that
// keeps its lines in memory only has no file.
func (lw *logWriter) rotating() *rotatingFile {
lw.mu.Lock()
defer lw.mu.Unlock()
if lw.logFilePath == "" {
return "", ""
}
current = lw.logFilePath
bp := lw.logFilePath + ".1"
if _, err := os.Stat(bp); err == nil {
backup = bp
}
return current, backup
return lw.file
}
// bufferedBytes copies the memory buffer, so a reader of the copy holds no
// writer lock while it works.
func (lw *logWriter) bufferedBytes() []byte {
lw.mu.Lock()
defer lw.mu.Unlock()
buffered := make([]byte, lw.buf.Len())
copy(buffered, lw.buf.Bytes())
return buffered
}
// Subscribe returns a channel that receives new log data as it's written,
@@ -200,6 +155,18 @@ func (lw *logWriter) tailLastLines(n int) []byte {
}
func (lw *logWriter) Write(p []byte) (int, error) {
// The file takes the line outside lw.mu: it holds its own lock, and it
// reports its rotation with an event that comes back through this writer.
// A file error must not stop the memory buffer, which is the fallback for
// the log readers.
if rf := lw.rotating(); rf != nil {
_, _ = rf.Write(p)
}
return lw.writeStreams(p)
}
// writeStreams feeds the subscribers and the memory buffer.
func (lw *logWriter) writeStreams(p []byte) (int, error) {
lw.mu.Lock()
defer lw.mu.Unlock()
@@ -216,16 +183,6 @@ func (lw *logWriter) Write(p []byte) (int, error) {
}
}
// Write to backing file if configured.
if lw.logFile != nil {
needsRotation := lw.logFileSize+int64(len(p)) > logFileMaxSize
if !needsRotation || lw.rotateLogFile() {
if n, err := lw.logFile.Write(p); err == nil {
lw.logFileSize += int64(n)
}
}
}
// If writing p causes overflows, discard old data.
if lw.buf.Len()+len(p) > lw.size {
buf := lw.buf.Bytes()
@@ -254,6 +211,29 @@ func (lw *logWriter) Write(p []byte) (int, error) {
return lw.buf.Write(p)
}
// emitLogRotated logs a rotation. The file that rotated guards this call,
// because the event is a line that can rotate that file again. A rotation
// that could not move the file keeps the lines it holds, so it earns a
// warning instead of the normal report.
func emitLogRotated(r logRotation) {
event := journal(mainLog.Load().Info())
message := "Log rotated"
if r.err != nil {
event = journal(mainLog.Load().Warn()).Err(r.err)
message = "Log rotation failed"
}
event = event.Str("file", r.file).Int64("bytes_written", r.bytesWritten)
// A file that holds a header only carries no event, and the year 1 of the
// zero time reads as a wrong date.
if !r.firstWrite.IsZero() {
event = event.Time("first_event_at", r.firstWrite)
}
if !r.lastWrite.IsZero() {
event = event.Time("last_event_at", r.lastWrite)
}
event.Int("backups", r.backups).Msg(message)
}
// initLogging initializes global logging setup.
func (p *prog) initLogging(backup bool) {
zerolog.TimeFieldFormat = time.RFC3339 + ".000"
@@ -261,12 +241,18 @@ func (p *prog) initLogging(backup bool) {
// Initializing internal logging after global logging.
p.initInternalLogging(logWriters)
}
// internalLogFilePath returns the path for persisted internal logs.
// The file lives in the ctrld home directory alongside other runtime state.
func internalLogFilePath() string {
return absHomeDir(logFileName)
if rf := logPathFile.Load(); rf != nil {
p.refreshLogHeader()
if err := rf.writeHeader(); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not write log header")
}
}
// A start-time backup happens before the logger reaches the new file, so
// its event waits here for a logger that can report it.
if record := pendingLogPathRotation.Swap(nil); record != nil {
emitLogRotated(*record)
}
}
// initInternalLogging performs internal logging if there's no log enabled.
@@ -274,46 +260,112 @@ func (p *prog) initInternalLogging(writers []io.Writer) {
if !p.needInternalLogging() {
return
}
headersWritten := false
p.initInternalLogWriterOnce.Do(func() {
mainLog.Load().Notice().Msg("internal logging enabled")
p.internalLogWriter = newLogWriter()
p.internalLogSent = time.Now().Add(-logWriterSentInterval)
p.internalWarnLogWriter = newSmallLogWriter()
// Persist internal logs to disk so they survive restarts.
if path := internalLogFilePath(); path != "" {
if err := p.internalLogWriter.setLogFile(path); err != nil {
mainLog.Load().Warn().Err(err).Msg("could not enable persistent internal logging")
} else {
mainLog.Load().Notice().Msgf("internal log file: %s", path)
}
}
p.internalJournalWriter = newSmallLogWriter()
p.openInternalLogFiles()
// A restart appends to the file it finds, so the header marks the
// point where this run starts.
p.refreshLogHeader()
p.writeLogHeaders()
headersWritten = true
})
p.mu.Lock()
lw := p.internalLogWriter
wlw := p.internalWarnLogWriter
p.mu.Unlock()
lw, jlw := p.internalWriters()
// If ctrld was run without explicit verbose level,
// run the internal logging at debug level, so we could
// have enough information for troubleshooting.
if verbose == 0 {
for i := range writers {
w := &zerolog.FilteredLevelWriter{
Writer: zerolog.LevelWriterAdapter{Writer: writers[i]},
Level: zerolog.NoticeLevel,
}
writers[i] = w
}
wrapConsoleWritersAtNotice(writers)
zerolog.SetGlobalLevel(zerolog.DebugLevel)
}
writers = append(writers, lw)
writers = append(writers, &zerolog.FilteredLevelWriter{
Writer: zerolog.LevelWriterAdapter{Writer: wlw},
Level: zerolog.WarnLevel,
})
writers = append(writers, lw, newJournalLevelWriter(jlw))
multi := zerolog.MultiLevelWriter(writers...)
l := mainLog.Load().Output(multi).With().Logger()
mainLog.Store(&l)
ctrld.ProxyLogger.Store(&l)
if headersWritten {
return
}
// A later call reaches a changed config, so the stored bytes need the new
// values. The files keep the header they already hold.
p.refreshLogHeader()
}
// wrapConsoleWritersAtNotice holds the console at notice level while the files
// take every level. A run without -v needs the detail for troubleshooting, and
// the same detail on the console is noise.
func wrapConsoleWritersAtNotice(writers []io.Writer) {
for i := range writers {
writers[i] = &zerolog.FilteredLevelWriter{
Writer: zerolog.LevelWriterAdapter{Writer: writers[i]},
Level: zerolog.NoticeLevel,
}
}
}
// openInternalLogFiles persists both internal streams, so they survive a
// restart.
func (p *prog) openInternalLogFiles() {
isRouter := router.Name() != ""
_, journalBudget := logBudgets(isRouter)
openInternalLogFile(p.internalLogWriter, absHomeDir(logFileName), debugLogBudget(&p.cfg.Service, isRouter), "internal")
openInternalLogFile(p.internalJournalWriter, absHomeDir(journalLogFileName), journalBudget, "journal")
}
// openInternalLogFile persists one internal stream. A stream whose file cannot
// open keeps its lines in memory. Only these files get a prune: ctrld owns
// their names, while log_path can name a file in a directory that holds the
// files of other programs.
func openInternalLogFile(lw *logWriter, path string, budget logBudget, name string) {
pruneNumberedBackups(path, budget.backups)
if err := lw.setLogFile(path, budget); err != nil {
mainLog.Load().Warn().Err(err).Msgf("could not enable persistent %s logging", name)
return
}
mainLog.Load().Notice().Msgf("%s log file: %s", name, path)
}
// internalWriters returns the debug stream and the journal stream. Both stay
// nil until initInternalLogging opens them, and outside cd mode.
func (p *prog) internalWriters() (debugWriter, journalWriter *logWriter) {
p.mu.Lock()
defer p.mu.Unlock()
return p.internalLogWriter, p.internalJournalWriter
}
// openLogFiles returns the files that this run writes, debug first. A stream
// that keeps its lines in memory only has no file, and log_path belongs to
// this list although no internal writer owns it.
func (p *prog) openLogFiles() []*rotatingFile {
debugWriter, journalWriter := p.internalWriters()
files := make([]*rotatingFile, 0, 3)
for _, writer := range []*logWriter{debugWriter, journalWriter} {
if writer == nil {
continue
}
if rf := writer.rotating(); rf != nil {
files = append(files, rf)
}
}
if rf := logPathFile.Load(); rf != nil {
files = append(files, rf)
}
return files
}
// closeInternalLogs closes both internal files, so a stop leaves no open
// handle behind.
func (p *prog) closeInternalLogs() {
debugWriter, journalWriter := p.internalWriters()
for _, writer := range []*logWriter{debugWriter, journalWriter} {
if writer == nil {
continue
}
writer.closeLogFile()
}
}
// needInternalLogging reports whether prog needs to run internal logging.
@@ -334,140 +386,3 @@ func (p *prog) needInternalLogging() bool {
}
return true
}
func (p *prog) logReader() (*logReader, error) {
if p.needInternalLogging() {
p.mu.Lock()
lw := p.internalLogWriter
wlw := p.internalWarnLogWriter
p.mu.Unlock()
if lw == nil {
return nil, errors.New("nil internal log writer")
}
if wlw == nil {
return nil, errors.New("nil internal warn log writer")
}
// If we have a persisted log file, read from disk (includes data
// from previous runs that the in-memory buffer wouldn't have).
current, backup := lw.logFilePaths()
if current != "" {
return p.logReaderFromFiles(current, backup, wlw)
}
// Fall back to in-memory buffer.
lw.mu.Lock()
lwReader := bytes.NewReader(lw.buf.Bytes())
lwSize := lw.buf.Len()
lw.mu.Unlock()
// Warn log content.
wlw.mu.Lock()
wlwReader := bytes.NewReader(wlw.buf.Bytes())
wlwSize := wlw.buf.Len()
wlw.mu.Unlock()
reader := io.MultiReader(lwReader, bytes.NewReader([]byte(logWriterLogEndMarker)), wlwReader)
lr := &logReader{r: io.NopCloser(reader)}
lr.size = int64(lwSize + wlwSize)
if lr.size == 0 {
return nil, errors.New("internal log is empty")
}
return lr, nil
}
if p.cfg.Service.LogPath == "" {
return &logReader{r: io.NopCloser(strings.NewReader(""))}, nil
}
f, err := os.Open(normalizeLogFilePath(p.cfg.Service.LogPath))
if err != nil {
return nil, err
}
lr := &logReader{r: f}
if st, err := f.Stat(); err == nil {
lr.size = st.Size()
} else {
return nil, fmt.Errorf("f.Stat: %w", err)
}
if lr.size == 0 {
return nil, errors.New("log file is empty")
}
return lr, nil
}
// logReaderFromFiles builds a logReader that concatenates the backup file
// (if it exists), the current log file, and the in-memory warn log buffer.
func (p *prog) logReaderFromFiles(current, backup string, wlw *logWriter) (*logReader, error) {
var rcs []io.ReadCloser
var totalSize int64
closeAll := func() {
for _, rc := range rcs {
rc.Close()
}
}
// Read backup file first (older entries).
if backup != "" {
if bf, err := os.Open(backup); err == nil {
if st, err := bf.Stat(); err == nil {
totalSize += st.Size()
}
rcs = append(rcs, bf)
}
}
// Read current file.
cf, err := os.Open(current)
if err != nil {
closeAll()
return nil, fmt.Errorf("opening current log file: %w", err)
}
if st, err := cf.Stat(); err == nil {
totalSize += st.Size()
}
rcs = append(rcs, cf)
// Append warn log content from memory.
wlw.mu.Lock()
warnData := make([]byte, wlw.buf.Len())
copy(warnData, wlw.buf.Bytes())
wlw.mu.Unlock()
if len(warnData) > 0 {
rcs = append(rcs, io.NopCloser(bytes.NewReader([]byte(logWriterLogEndMarker))))
rcs = append(rcs, io.NopCloser(bytes.NewReader(warnData)))
totalSize += int64(len(logWriterLogEndMarker) + len(warnData))
}
if totalSize == 0 {
closeAll()
return nil, errors.New("internal log is empty")
}
readers := make([]io.Reader, len(rcs))
closers := make([]io.Closer, len(rcs))
for i, rc := range rcs {
readers[i] = rc
closers[i] = rc
}
combined := io.MultiReader(readers...)
lr := &logReader{
r: &multiCloser{Reader: combined, closers: closers},
size: totalSize,
}
return lr, nil
}
// multiCloser wraps an io.Reader and closes multiple underlying closers.
type multiCloser struct {
io.Reader
closers []io.Closer
}
func (mc *multiCloser) Close() error {
var firstErr error
for _, c := range mc.closers {
if err := c.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
+76 -3
View File
@@ -1,10 +1,14 @@
package cli
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
)
@@ -59,8 +63,77 @@ func Test_initInternalLogging_silentCreatesNoFile(t *testing.T) {
p := &prog{cfg: &ctrld.Config{}}
p.initInternalLogging(nil)
logPath := filepath.Join(dir, logFileName)
if _, err := os.Stat(logPath); !os.IsNotExist(err) {
t.Fatalf("silent mode must not create %s (stat err = %v)", logPath, err)
for _, name := range []string{logFileName, journalLogFileName} {
path := filepath.Join(dir, name)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("silent mode must not create %s (stat err = %v)", path, err)
}
}
}
// Test_initInternalLogging_createsJournalFile drives a cd mode run and asserts
// that the journal file holds the retained lines only, while the debug file
// holds every line.
func Test_initInternalLogging_createsJournalFile(t *testing.T) {
origSilent, origCdUID, origHomedir, origVerbose := silent, cdUID, homedir, verbose
origMainLog, origProxyLog := mainLog.Load(), ctrld.ProxyLogger.Load()
origLevel := zerolog.GlobalLevel()
t.Cleanup(func() {
silent, cdUID, homedir, verbose = origSilent, origCdUID, origHomedir, origVerbose
mainLog.Store(origMainLog)
ctrld.ProxyLogger.Store(origProxyLog)
zerolog.SetGlobalLevel(origLevel)
})
if origMainLog == nil {
discard := zerolog.New(io.Discard)
mainLog.Store(&discard)
}
dir := t.TempDir()
homedir = dir
cdUID = "test-uid"
silent = false
verbose = 0
p := &prog{cfg: &ctrld.Config{}}
p.initInternalLogging(nil)
t.Cleanup(func() {
p.internalLogWriter.closeLogFile()
p.internalJournalWriter.closeLogFile()
})
debugPath := filepath.Join(dir, logFileName)
journalPath := filepath.Join(dir, journalLogFileName)
for _, path := range []string{debugPath, journalPath} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("stat %s: %v", path, err)
}
}
const (
warnMsg = "retained warn line"
markedMsg = "retained marked info line"
plainMsg = "dropped plain info line"
)
logger := mainLog.Load()
logger.Warn().Msg(warnMsg)
journal(logger.Info()).Msg(markedMsg)
logger.Info().Msg(plainMsg)
journalText := rotatingFileTestContent(t, journalPath)
for _, msg := range []string{warnMsg, markedMsg} {
if !strings.Contains(journalText, msg) {
t.Fatalf("journal file misses %q: %s", msg, journalText)
}
}
if strings.Contains(journalText, plainMsg) {
t.Fatalf("journal file kept %q: %s", plainMsg, journalText)
}
debugText := rotatingFileTestContent(t, debugPath)
for _, msg := range []string{warnMsg, markedMsg, plainMsg} {
if !strings.Contains(debugText, msg) {
t.Fatalf("debug file misses %q: %s", msg, debugText)
}
}
}
+214 -53
View File
@@ -1,11 +1,17 @@
package cli
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/rs/zerolog"
)
func Test_logWriter_Write(t *testing.T) {
@@ -86,14 +92,23 @@ func Test_logWriter_MarkerInitEnd(t *testing.T) {
}
}
func Test_logWriter_SetLogFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.log")
// testLogWriterBudget keeps the rotation tests small: four lines fill the
// current file.
var testLogWriterBudget = logBudget{maxSize: 4 * rotatingFileTestLineSize, backups: 2}
func newTestFileLogWriter(t *testing.T, budget logBudget) (*logWriter, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "test.log")
lw := newLogWriterWithSize(logWriterSize)
if err := lw.setLogFile(path); err != nil {
if err := lw.setLogFile(path, budget); err != nil {
t.Fatalf("setLogFile: %v", err)
}
defer lw.closeLogFile()
t.Cleanup(lw.closeLogFile)
return lw, path
}
func Test_logWriter_SetLogFile(t *testing.T) {
lw, path := newTestFileLogWriter(t, testLogWriterBudget)
msg := "hello file\n"
lw.Write([]byte(msg))
@@ -113,69 +128,49 @@ func Test_logWriter_SetLogFile(t *testing.T) {
}
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()
lw, path := newTestFileLogWriter(t, testLogWriterBudget)
// Write enough to exceed logFileMaxSize.
chunk := strings.Repeat("X", 1024) + "\n"
written := 0
for written < logFileMaxSize+1024 {
lw.Write([]byte(chunk))
written += len(chunk)
// Three rotations: each file takes four lines.
for seq := 1; seq <= 16; seq++ {
lw.Write(rotatingFileTestLine(1, seq))
}
// Backup file should exist.
backupPath := path + ".1"
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
t.Fatal("expected backup file to exist after rotation")
for _, suffix := range []string{".1", ".2"} {
if _, err := os.Stat(path + suffix); err != nil {
t.Fatalf("stat %s: %v", path+suffix, err)
}
}
if _, err := os.Stat(path + ".3"); !os.IsNotExist(err) {
t.Fatalf("stat %s.3 = %v, want a not exist error", path, err)
}
// 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())
if want := testLogWriterBudget.maxSize + rotatingFileTestLineSize; st.Size() > want {
t.Fatalf("current file holds %d bytes after rotation, want at most %d", st.Size(), want)
}
}
func Test_logWriter_FilePaths(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.log")
func Test_logWriter_RotatingPaths(t *testing.T) {
lw := newLogWriterWithSize(logWriterSize)
// No file configured.
c, b := lw.logFilePaths()
if c != "" || b != "" {
t.Fatalf("expected empty paths, got %q %q", c, b)
if lw.rotating() != nil {
t.Fatal("a writer without a file has a rotating file, want none")
}
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)
lw, path := newTestFileLogWriter(t, testLogWriterBudget)
if got, want := lw.rotating().paths(), []string{path}; !slices.Equal(got, want) {
t.Fatalf("paths = %v, want %v", got, want)
}
// 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")
// Two rotations, so both backups exist.
for seq := 1; seq <= 9; seq++ {
lw.Write(rotatingFileTestLine(1, seq))
}
want := []string{path + ".2", path + ".1", path}
if got := lw.rotating().paths(); !slices.Equal(got, want) {
t.Fatalf("paths = %v, want %v", got, want)
}
}
@@ -185,7 +180,7 @@ func Test_logWriter_FileAppendOnRestart(t *testing.T) {
// Simulate first run.
lw1 := newLogWriterWithSize(logWriterSize)
if err := lw1.setLogFile(path); err != nil {
if err := lw1.setLogFile(path, testLogWriterBudget); err != nil {
t.Fatalf("setLogFile: %v", err)
}
lw1.Write([]byte("run1\n"))
@@ -193,7 +188,7 @@ func Test_logWriter_FileAppendOnRestart(t *testing.T) {
// Simulate second run (restart) — file should be appended.
lw2 := newLogWriterWithSize(logWriterSize)
if err := lw2.setLogFile(path); err != nil {
if err := lw2.setLogFile(path, testLogWriterBudget); err != nil {
t.Fatalf("setLogFile: %v", err)
}
lw2.Write([]byte("run2\n"))
@@ -208,3 +203,169 @@ func Test_logWriter_FileAppendOnRestart(t *testing.T) {
t.Fatalf("file: got %q, want %q", data, want)
}
}
func Test_logWriter_EmitsLogRotated(t *testing.T) {
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
lw, path := newTestFileLogWriter(t, testLogWriterBudget)
// The logger writes back into the writer that rotates, which is the case
// that deadlocks when the writer still holds its lock.
buf := &syncBuffer{}
logger := zerolog.New(zerolog.MultiLevelWriter(lw, buf))
old := mainLog.Load()
mainLog.Store(&logger)
t.Cleanup(func() { mainLog.Store(old) })
done := make(chan struct{})
go func() {
defer close(done)
for seq := 1; seq <= 5; seq++ {
lw.Write(rotatingFileTestLine(1, seq))
}
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("the writer did not return: the rotation event blocked on the writer lock")
}
st, err := os.Stat(path + ".1")
if err != nil {
t.Fatalf("stat %s.1: %v", path, err)
}
var rotatedLines []string
for _, line := range strings.Split(buf.String(), "\n") {
if strings.Contains(line, "Log rotated") {
rotatedLines = append(rotatedLines, line)
}
}
if len(rotatedLines) != 1 {
t.Fatalf("got %d Log rotated events, want 1: %q", len(rotatedLines), buf.String())
}
if !strings.Contains(rotatedLines[0], string(journalMarker)) {
t.Fatalf("the event is not marked for the journal: %s", rotatedLines[0])
}
var event struct {
File string `json:"file"`
BytesWritten int64 `json:"bytes_written"`
}
if err := json.Unmarshal([]byte(rotatedLines[0]), &event); err != nil {
t.Fatalf("unmarshal %s: %v", rotatedLines[0], err)
}
if event.File != path+".1" {
t.Fatalf("file = %q, want %q", event.File, path+".1")
}
if event.BytesWritten != st.Size() {
t.Fatalf("bytes_written = %d, want %d", event.BytesWritten, st.Size())
}
}
func Test_logWriter_NestedRotationStillEmitsEvent(t *testing.T) {
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
debugLog, debugPath := newTestFileLogWriter(t, testLogWriterBudget)
journalLog, journalPath := newTestFileLogWriter(t, logBudget{maxSize: 2 * rotatingFileTestLineSize, backups: 1})
buf := &syncBuffer{}
logger := zerolog.New(zerolog.MultiLevelWriter(debugLog, journalLog, buf))
old := mainLog.Load()
mainLog.Store(&logger)
t.Cleanup(func() { mainLog.Store(old) })
// Fill the journal, so the rotation event of the debug file is the line
// that rotates the journal. That nested rotation needs its own event.
journalLog.Write(rotatingFileTestLine(1, 1))
journalLog.Write(rotatingFileTestLine(1, 2))
for seq := 1; seq <= 5; seq++ {
debugLog.Write(rotatingFileTestLine(1, seq))
}
rotatedFiles := map[string]bool{}
for _, line := range strings.Split(buf.String(), "\n") {
if !strings.Contains(line, "Log rotated") {
continue
}
var event struct {
File string `json:"file"`
}
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("parse %q: %v", line, err)
}
rotatedFiles[event.File] = true
}
for _, want := range []string{debugPath + ".1", journalPath + ".1"} {
if !rotatedFiles[want] {
t.Fatalf("no Log rotated event for %s; events: %v", want, rotatedFiles)
}
}
if len(rotatedFiles) != 2 {
t.Fatalf("got %d rotated files, want 2: %v", len(rotatedFiles), rotatedFiles)
}
}
// rotationEventFields parses one log line into its raw fields, so a test can
// ask whether a field is there at all.
func rotationEventFields(t *testing.T, line string) map[string]json.RawMessage {
t.Helper()
fields := map[string]json.RawMessage{}
if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &fields); err != nil {
t.Fatalf("unmarshal %q: %v", line, err)
}
return fields
}
func Test_emitLogRotated_eventTimes(t *testing.T) {
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
t.Run("a file with no event names no time", func(t *testing.T) {
buf := captureMainLog(t)
emitLogRotated(logRotation{file: "/tmp/ctrld.log.1", backups: 1})
fields := rotationEventFields(t, buf.String())
for _, name := range []string{"first_event_at", "last_event_at"} {
if raw, ok := fields[name]; ok {
t.Errorf("%s = %s, want no field for a file with no event", name, raw)
}
}
})
t.Run("a file with events names both times", func(t *testing.T) {
buf := captureMainLog(t)
now := time.Now()
emitLogRotated(logRotation{file: "/tmp/ctrld.log.1", backups: 1, firstWrite: now, lastWrite: now})
fields := rotationEventFields(t, buf.String())
for _, name := range []string{"first_event_at", "last_event_at"} {
if _, ok := fields[name]; !ok {
t.Errorf("%s is missing: %v", name, fields)
}
}
})
}
func Test_emitLogRotated_reportsAFailedRotation(t *testing.T) {
setLogHeaderTestGlobalLevel(t, zerolog.DebugLevel)
buf := captureMainLog(t)
emitLogRotated(logRotation{file: "/tmp/ctrld.log.1", err: errors.New("rename refused")})
var event struct {
Level string `json:"level"`
Message string `json:"message"`
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &event); err != nil {
t.Fatalf("unmarshal %q: %v", buf.String(), err)
}
if event.Message != "Log rotation failed" {
t.Errorf("message = %q, want %q", event.Message, "Log rotation failed")
}
if event.Level != "warn" {
t.Errorf("level = %q, want %q", event.Level, "warn")
}
if event.Error != "rename refused" {
t.Errorf("error = %q, want %q", event.Error, "rename refused")
}
}
+2 -24
View File
@@ -143,30 +143,8 @@ func initInteractiveLogging() {
// wrapper instead of calling this function directly.
func initLoggingWithBackup(doBackup bool) []io.Writer {
var writers []io.Writer
if logFilePath := normalizeLogFilePath(cfg.Service.LogPath); logFilePath != "" {
// Create parent directory if necessary.
if err := os.MkdirAll(filepath.Dir(logFilePath), 0750); err != nil {
mainLog.Load().Error().Msgf("failed to create log path: %v", err)
os.Exit(1)
}
// Default open log file in append mode.
flags := os.O_CREATE | os.O_RDWR | os.O_APPEND
if doBackup {
// Backup old log file with .1 suffix.
if err := os.Rename(logFilePath, logFilePath+oldLogSuffix); err != nil && !os.IsNotExist(err) {
mainLog.Load().Error().Msgf("could not backup old log file: %v", err)
} else {
// Backup was created, set flags for truncating old log file.
flags = os.O_CREATE | os.O_RDWR
}
}
logFile, err := openLogFile(logFilePath, flags)
if err != nil {
mainLog.Load().Error().Msgf("failed to create log file: %v", err)
os.Exit(1)
}
writers = append(writers, logFile)
if rf := openLogPathWriter(doBackup); rf != nil {
writers = append(writers, rf)
}
writers = append(writers, consoleWriter)
multi := zerolog.MultiLevelWriter(writers...)
+12 -1
View File
@@ -250,11 +250,14 @@ func TestRecoveryDebouncePreservesTransitionID(t *testing.T) {
func TestTransitionWarningsSurviveDebugRotation(t *testing.T) {
sourceTestGlobals(t)
level := zerolog.GlobalLevel()
zerolog.SetGlobalLevel(zerolog.DebugLevel)
t.Cleanup(func() { zerolog.SetGlobalLevel(level) })
old := mainLog.Load()
debug, warnings := newLogWriterWithSize(2048), newSmallLogWriter()
// Runtime summaries occur after initialization, outside the preserved prefix.
debug.Write([]byte(logWriterInitEndMarker))
logger := zerolog.New(zerolog.MultiLevelWriter(debug, &zerolog.FilteredLevelWriter{Writer: zerolog.LevelWriterAdapter{Writer: warnings}, Level: zerolog.WarnLevel}))
logger := zerolog.New(zerolog.MultiLevelWriter(debug, newJournalLevelWriter(warnings)))
mainLog.Store(&logger)
t.Cleanup(func() { mainLog.Store(old) })
ctrld.SetDefaultLocalIPv4(net.ParseIP("192.0.2.10"))
@@ -272,6 +275,8 @@ func TestTransitionWarningsSurviveDebugRotation(t *testing.T) {
wg.Wait()
d.end("completed")
(&recoveryDiagnostic{transitionID: 8, generation: 10, started: time.Now()}).end("completed")
journal(logger.Info()).Str("interface", "en0").Msg("Network snapshot")
logger.Info().Msg("plain info line")
for range 100 {
logger.Debug().Msg(strings.Repeat("noise", 100))
}
@@ -290,6 +295,12 @@ func TestTransitionWarningsSurviveDebugRotation(t *testing.T) {
if strings.Contains(text, "private.customer") || strings.Contains(text, "secret payload") {
t.Fatalf("raw payload or normal debug elevated: %s", text)
}
if strings.Count(text, "Network snapshot") != 1 {
t.Fatalf("expected one retained marked info line: %s", text)
}
if strings.Contains(text, "plain info line") {
t.Fatalf("unmarked info line must not be retained: %s", text)
}
}
func TestRecoveryWaitLogsFirstFailure(t *testing.T) {
+15 -1
View File
@@ -137,7 +137,9 @@ type prog struct {
queryFromSelfMap sync.Map
initInternalLogWriterOnce sync.Once
internalLogWriter *logWriter
internalWarnLogWriter *logWriter
internalJournalWriter *logWriter
querySampler errorSampler
lastNetworkState atomic.Pointer[netmon.State]
internalLogSent time.Time
runningIface string
requiredMultiNICsConfig bool
@@ -427,6 +429,10 @@ func (p *prog) runWait() {
}
p.mu.Unlock()
// The header names the mode, the listeners, and the upstreams, so the
// open files take the values of the config that now runs.
p.refreshLogHeader()
logger.Notice().Msg("reloading config successfully")
select {
@@ -764,6 +770,12 @@ func (p *prog) run(reload bool, reloadCh chan struct{}) {
}
}
// The new monitor starts with every upstream up, so the old one closes its
// open outages first. Without this the journal holds a down event that no
// up event ever follows.
if p.um != nil {
p.um.retire()
}
p.um = newUpstreamMonitor(p.cfg)
if !reload {
@@ -908,6 +920,8 @@ func (p *prog) Stop(s service.Service) error {
mainLog.Load().Debug().Msg("finish running onStopped functions")
defer func() {
mainLog.Load().Info().Msg("Service stopped")
// The files take the last line of this run before they close.
p.closeInternalLogs()
}()
if err := p.deAllocateIP(); err != nil {
mainLog.Load().Error().Err(err).Msg("de-allocate ip failed")
+166
View File
@@ -0,0 +1,166 @@
package cli
import (
"sync"
"time"
"github.com/rs/zerolog"
)
// Classes of per-query errors. Each class keeps its own budget per upstream.
const (
sampleClassResolveFailed = "resolve_failed"
sampleClassAllEndpointsFailed = "all_endpoints_failed"
sampleClassSendResponseFailed = "send_response_failed"
)
const (
// querySampleLimit is the number of lines that stay at error level in one window.
querySampleLimit = 5
// querySampleWindow is the length of a window. It starts at the first error of the window.
querySampleWindow = 60 * time.Second
)
// sampleKey identifies one budget.
type sampleKey struct {
class string
upstream string
}
// sampleWindow counts the events of one budget between its start and its end.
type sampleWindow struct {
start time.Time
count int
suppressed int
timer *time.Timer
}
// ended reports whether the window closed at or before now.
func (w *sampleWindow) ended(now time.Time) bool {
return !now.Before(w.start.Add(querySampleWindow))
}
// sampleSummary holds what one closed window reports.
type sampleSummary struct {
key sampleKey
count int
suppressed int
}
// errorSampler bounds the noise of a repeated per-query error. It lowers the
// level of a line, never its presence, so the debug stream stays complete. A
// run with -v holds every stream at info, and the lowered lines fall below it.
// The zero value is ready to use.
type errorSampler struct {
mu sync.Mutex
windows map[sampleKey]*sampleWindow
now func() time.Time
after func(time.Duration, func()) *time.Timer
}
// event returns the event a call site logs with. The first querySampleLimit
// events of a window come back at error level, the later ones at debug level.
// The caller adds its own fields and the message.
func (s *errorSampler) event(class, upstream string) *zerolog.Event {
key := sampleKey{class: class, upstream: upstream}
s.mu.Lock()
now := s.clock()
var summaries []sampleSummary
window := s.windows[key]
if window != nil && window.ended(now) {
summaries = appendWindowSummary(summaries, key, window)
s.dropWindow(key, window)
window = nil
}
if window == nil {
window = s.startWindow(key, now)
}
window.count++
suppressed := window.count > querySampleLimit
if suppressed {
window.suppressed++
}
s.mu.Unlock()
s.logSummaries(summaries)
if suppressed {
return mainLog.Load().Debug()
}
return mainLog.Load().Error()
}
// closeExpired closes every window that ended at or before now. The window
// timer calls it, so an idle window reports without a new event.
func (s *errorSampler) closeExpired(now time.Time) {
s.mu.Lock()
var summaries []sampleSummary
for key, window := range s.windows {
if !window.ended(now) {
continue
}
summaries = appendWindowSummary(summaries, key, window)
s.dropWindow(key, window)
}
s.mu.Unlock()
s.logSummaries(summaries)
}
// startWindow opens a window and arms the timer that closes it. The caller holds the lock.
func (s *errorSampler) startWindow(key sampleKey, start time.Time) *sampleWindow {
if s.windows == nil {
s.windows = make(map[sampleKey]*sampleWindow)
}
window := &sampleWindow{start: start}
s.windows[key] = window
window.timer = s.afterFunc()(querySampleWindow, func() { s.closeExpired(s.clock()) })
return window
}
// dropWindow forgets a window and stops its timer. The caller holds the lock.
func (s *errorSampler) dropWindow(key sampleKey, window *sampleWindow) {
if window.timer != nil {
window.timer.Stop()
}
delete(s.windows, key)
}
// logSummaries reports the closed windows. The caller released the lock first,
// because the logger writes back through code that can take it again.
func (s *errorSampler) logSummaries(summaries []sampleSummary) {
for _, summary := range summaries {
mainLog.Load().Error().
Str("class", summary.key.class).
Str("upstream", summary.key.upstream).
Int("count", summary.count).
Int("suppressed", summary.suppressed).
Int("window_s", int(querySampleWindow/time.Second)).
Msg("Per-query errors sampled")
}
}
// clock reads the time of the sampler. A test sets its own clock, so a window
// closes without a wait.
func (s *errorSampler) clock() time.Time {
if s.now != nil {
return s.now()
}
return time.Now()
}
func (s *errorSampler) afterFunc() func(time.Duration, func()) *time.Timer {
if s.after != nil {
return s.after
}
return time.AfterFunc
}
// appendWindowSummary keeps the windows that suppressed at least one line.
func appendWindowSummary(summaries []sampleSummary, key sampleKey, window *sampleWindow) []sampleSummary {
if window.suppressed == 0 {
return summaries
}
return append(summaries, sampleSummary{key: key, count: window.count, suppressed: window.suppressed})
}
+260
View File
@@ -0,0 +1,260 @@
package cli
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/rs/zerolog"
)
// samplerTestMessage is the message the fake call site writes on every sampled event.
const samplerTestMessage = "failed to resolve query"
// samplerSummaryMessage is the message of the window summary.
const samplerSummaryMessage = "Per-query errors sampled"
// captureDebugMainLog sends mainLog into a buffer and lowers the global level,
// so the test also sees the lines that the code under test drops to debug. It
// swaps two globals, so no test of this package runs in parallel.
func captureDebugMainLog(t *testing.T) *syncBuffer {
t.Helper()
level := zerolog.GlobalLevel()
zerolog.SetGlobalLevel(zerolog.DebugLevel)
t.Cleanup(func() { zerolog.SetGlobalLevel(level) })
return captureMainLog(t)
}
// jsonLogEvents parses the captured lines as JSON events, in order. An empty
// message takes every line.
func jsonLogEvents(t *testing.T, logs *syncBuffer, message string) []map[string]any {
t.Helper()
var events []map[string]any
for _, line := range strings.Split(logs.String(), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
event := map[string]any{}
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("log line is not JSON: %q: %v", line, err)
}
if message != "" && event["message"] != message {
continue
}
events = append(events, event)
}
return events
}
// samplerHarness drives an errorSampler on a clock and a timer queue the test owns.
type samplerHarness struct {
sampler *errorSampler
logs *syncBuffer
now time.Time
pending []func()
}
func newSamplerHarness(t *testing.T) *samplerHarness {
t.Helper()
h := &samplerHarness{
logs: captureDebugMainLog(t),
now: time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC),
}
h.sampler = &errorSampler{
now: func() time.Time { return h.now },
after: func(_ time.Duration, fn func()) *time.Timer {
h.pending = append(h.pending, fn)
return nil
},
}
return h
}
// emit plays a call site: it takes an event from the sampler and adds its own fields.
func (h *samplerHarness) emit(class, upstream string) {
h.sampler.event(class, upstream).Str("class", class).Str("upstream", upstream).Msg(samplerTestMessage)
}
func (h *samplerHarness) advance(d time.Duration) {
h.now = h.now.Add(d)
}
func (h *samplerHarness) events(t *testing.T) []map[string]any {
t.Helper()
return jsonLogEvents(t, h.logs, "")
}
func countSampled(events []map[string]any, level, upstream string) int {
count := 0
for _, event := range events {
if event["message"] != samplerTestMessage {
continue
}
if event["level"] != level || event["upstream"] != upstream {
continue
}
count++
}
return count
}
func samplerSummaries(events []map[string]any) []map[string]any {
var summaries []map[string]any
for _, event := range events {
if event["message"] == samplerSummaryMessage {
summaries = append(summaries, event)
}
}
return summaries
}
func wantField(t *testing.T, event map[string]any, field string, want any) {
t.Helper()
if got := event[field]; got != want {
t.Fatalf("field %q: got %v, want %v", field, got, want)
}
}
func Test_errorSamplerLimitsWindowAndSummarizes(t *testing.T) {
h := newSamplerHarness(t)
for i := 0; i < querySampleLimit+2; i++ {
h.emit(sampleClassResolveFailed, "upstream.0")
}
events := h.events(t)
if got := countSampled(events, "error", "upstream.0"); got != querySampleLimit {
t.Fatalf("error lines: got %d, want %d", got, querySampleLimit)
}
if got := countSampled(events, "debug", "upstream.0"); got != 2 {
t.Fatalf("debug lines: got %d, want 2", got)
}
if got := len(samplerSummaries(events)); got != 0 {
t.Fatalf("summaries before the window closed: got %d, want 0", got)
}
h.advance(querySampleWindow)
h.emit(sampleClassResolveFailed, "upstream.0")
events = h.events(t)
summaries := samplerSummaries(events)
if len(summaries) != 1 {
t.Fatalf("summaries after the window closed: got %d, want 1", len(summaries))
}
summary := summaries[0]
wantField(t, summary, "level", "error")
wantField(t, summary, "class", sampleClassResolveFailed)
wantField(t, summary, "upstream", "upstream.0")
wantField(t, summary, "count", float64(7))
wantField(t, summary, "suppressed", float64(2))
wantField(t, summary, "window_s", float64(60))
last := events[len(events)-1]
wantField(t, last, "message", samplerTestMessage)
wantField(t, last, "level", "error")
}
func Test_errorSamplerKeepsBudgetPerUpstream(t *testing.T) {
h := newSamplerHarness(t)
for i := 0; i < querySampleLimit+1; i++ {
h.emit(sampleClassResolveFailed, "upstream.0")
}
for i := 0; i < querySampleLimit; i++ {
h.emit(sampleClassResolveFailed, "upstream.1")
}
events := h.events(t)
if got := countSampled(events, "error", "upstream.1"); got != querySampleLimit {
t.Fatalf("error lines for the second upstream: got %d, want %d", got, querySampleLimit)
}
if got := countSampled(events, "debug", "upstream.1"); got != 0 {
t.Fatalf("debug lines for the second upstream: got %d, want 0", got)
}
if got := countSampled(events, "debug", "upstream.0"); got != 1 {
t.Fatalf("debug lines for the first upstream: got %d, want 1", got)
}
}
func Test_errorSamplerKeepsQuietWindowSilent(t *testing.T) {
h := newSamplerHarness(t)
for i := 0; i < 3; i++ {
h.emit(sampleClassAllEndpointsFailed, "")
}
h.advance(querySampleWindow)
h.sampler.closeExpired(h.now)
if got := len(samplerSummaries(h.events(t))); got != 0 {
t.Fatalf("summaries for a window without suppressed lines: got %d, want 0", got)
}
}
func Test_errorSamplerCloseExpiredSummarizesOnce(t *testing.T) {
h := newSamplerHarness(t)
for i := 0; i < querySampleLimit+2; i++ {
h.emit(sampleClassSendResponseFailed, "")
}
sampled := len(h.events(t))
h.advance(querySampleWindow)
h.sampler.closeExpired(h.now)
events := h.events(t)
if len(events) != sampled+1 {
t.Fatalf("lines after closeExpired: got %d, want %d", len(events), sampled+1)
}
summaries := samplerSummaries(events)
if len(summaries) != 1 {
t.Fatalf("summaries after closeExpired: got %d, want 1", len(summaries))
}
wantField(t, summaries[0], "class", sampleClassSendResponseFailed)
wantField(t, summaries[0], "suppressed", float64(2))
h.sampler.closeExpired(h.now.Add(querySampleWindow))
if got := len(h.events(t)); got != sampled+1 {
t.Fatalf("lines after the second closeExpired: got %d, want %d", got, sampled+1)
}
}
func Test_errorSamplerTimerClosesIdleWindow(t *testing.T) {
h := newSamplerHarness(t)
for i := 0; i < querySampleLimit+1; i++ {
h.emit(sampleClassResolveFailed, "upstream.0")
}
if len(h.pending) != 1 {
t.Fatalf("window timers: got %d, want 1", len(h.pending))
}
h.advance(querySampleWindow)
h.pending[0]()
summaries := samplerSummaries(h.events(t))
if len(summaries) != 1 {
t.Fatalf("summaries after the timer fired: got %d, want 1", len(summaries))
}
wantField(t, summaries[0], "suppressed", float64(1))
wantField(t, summaries[0], "count", float64(6))
}
func Test_errorSamplerZeroValueWorks(t *testing.T) {
buf := captureDebugMainLog(t)
var sampler errorSampler
// The zero value arms a real timer. Close its window after the assertions,
// so no late summary line reaches another test.
t.Cleanup(func() { sampler.closeExpired(time.Now().Add(querySampleWindow)) })
sampler.event(sampleClassResolveFailed, "upstream.0").Msg(samplerTestMessage)
if got := strings.Count(buf.String(), samplerTestMessage); got != 1 {
t.Fatalf("lines from the zero value sampler: got %d, want 1", got)
}
if !strings.Contains(buf.String(), `"level":"error"`) {
t.Fatalf("first line of a window is not at error level: %s", buf.String())
}
}
+4 -1
View File
@@ -4,14 +4,17 @@ package cli
import (
"os"
"syscall"
)
func hasElevatedPrivilege() (bool, error) {
return os.Geteuid() == 0, nil
}
// openLogFile opens a log file. It refuses a symlink, because ctrld often
// writes its logs as root into a directory that other users can write.
func openLogFile(path string, flags int) (*os.File, error) {
return os.OpenFile(path, flags, os.FileMode(0o600))
return os.OpenFile(path, flags|syscall.O_NOFOLLOW, os.FileMode(0o600))
}
// hasLocalDnsServerRunning reports whether we are on Windows and having Dns server running.
+155 -29
View File
@@ -1,6 +1,7 @@
package cli
import (
"strings"
"sync"
"time"
@@ -17,6 +18,12 @@ const (
// the backoff so an unroutable endpoint is still re-probed periodically and
// recovers once the route returns.
checkUpstreamUnreachableBackoffMax = 60 * time.Second
// upstreamDownDelay is the time that the failures of an upstream must last
// before the monitor marks it down.
upstreamDownDelay = 10 * time.Second
// upstreamRecoveredDelay is the time that a reset holds back the failure
// counts of an upstream.
upstreamRecoveredDelay = 1 * time.Second
)
// unreachableRecoveryBackoff returns the retry interval for the given streak of
@@ -33,28 +40,55 @@ func unreachableRecoveryBackoff(streak int) time.Duration {
return d
}
// upstreamDownState remembers when an upstream went down and how many
// failures put it there, so the up event can report the outage.
type upstreamDownState struct {
since time.Time
failures uint64
}
// upstreamMonitor performs monitoring upstreams health.
type upstreamMonitor struct {
cfg *ctrld.Config
mu sync.RWMutex
mu sync.RWMutex
// down holds one entry for each upstream that is down now.
down map[string]upstreamDownState
checking map[string]bool
down map[string]bool
failureReq map[string]uint64
recovered map[string]bool
// failureTimerActive tracks if a timer is already running for a given upstream.
failureTimerActive map[string]bool
// retired is set when a reload replaced this monitor. A timer that was
// armed before the reload must not open an outage that nothing closes.
retired bool
// now is a seam, so a test can set the reported down time.
now func() time.Time
// after arms a one-shot timer, so a test fires the check itself instead of
// waiting for it. nil means time.AfterFunc.
after func(time.Duration, func())
}
// afterFunc returns the timer seam. A real timer drops its handle, because
// nothing stops these checks.
func (um *upstreamMonitor) afterFunc() func(time.Duration, func()) {
if um.after != nil {
return um.after
}
return func(d time.Duration, fn func()) { time.AfterFunc(d, fn) }
}
func newUpstreamMonitor(cfg *ctrld.Config) *upstreamMonitor {
um := &upstreamMonitor{
cfg: cfg,
checking: make(map[string]bool),
down: make(map[string]bool),
down: make(map[string]upstreamDownState),
failureReq: make(map[string]uint64),
recovered: make(map[string]bool),
failureTimerActive: make(map[string]bool),
now: time.Now,
}
for n := range cfg.Upstream {
upstream := upstreamPrefix + n
@@ -71,6 +105,9 @@ func (um *upstreamMonitor) increaseFailureCount(upstream string) {
um.mu.Lock()
defer um.mu.Unlock()
if um.retired {
return
}
if um.recovered[upstream] {
mainLog.Load().Debug().Msgf("upstream %q is recovered, skipping failure count increase", upstream)
return
@@ -82,53 +119,142 @@ func (um *upstreamMonitor) increaseFailureCount(upstream string) {
// Log the updated failure count.
mainLog.Load().Debug().Msgf("upstream %q failure count updated to %d", upstream, failedCount)
// If this is the first failure and no timer is running, start a 10-second timer.
// If this is the first failure and no timer is running, start the timer.
if failedCount == 1 && !um.failureTimerActive[upstream] {
um.failureTimerActive[upstream] = true
go func(upstream string) {
time.Sleep(10 * time.Second)
um.mu.Lock()
defer um.mu.Unlock()
// If no success occurred during the 10-second window (i.e. counter remains > 0)
// and the upstream is not in a recovered state, mark it as down.
if um.failureReq[upstream] > 0 && !um.recovered[upstream] {
um.down[upstream] = true
mainLog.Load().Warn().Msgf("upstream %q marked as down after 10 seconds (failure count: %d)", upstream, um.failureReq[upstream])
}
// Reset the timer flag so that a new timer can be spawned if needed.
um.failureTimerActive[upstream] = false
}(upstream)
um.afterFunc()(upstreamDownDelay, func() { um.markDownAfterDelay(upstream) })
}
// If the failure count quickly reaches the threshold, mark the upstream as down immediately.
if failedCount >= maxFailureRequest {
um.down[upstream] = true
mainLog.Load().Warn().Msgf("upstream %q marked as down immediately (failure count: %d)", upstream, failedCount)
um.markDown(upstream, failedCount, "immediate")
}
}
// markDownAfterDelay marks an upstream down when its failures did not clear
// while the timer ran. A success during that time leaves the count at zero.
func (um *upstreamMonitor) markDownAfterDelay(upstream string) {
um.mu.Lock()
defer um.mu.Unlock()
if um.retired {
return
}
if um.failureReq[upstream] > 0 && !um.recovered[upstream] {
um.markDown(upstream, um.failureReq[upstream], "timer")
}
// The next failure of this upstream can arm a new timer.
um.failureTimerActive[upstream] = false
}
// markDown marks an upstream as down and logs the transition once. The caller holds the lock.
func (um *upstreamMonitor) markDown(upstream string, failures uint64, reason string) {
if um.retired {
return
}
if _, down := um.down[upstream]; down {
return
}
um.down[upstream] = upstreamDownState{since: um.now(), failures: failures}
journal(mainLog.Load().Warn()).
Str("upstream", journalUpstreamName(upstream)).
Str("state", "down").
Uint64("failure_count", failures).
Str("reason", reason).
Msg("Upstream state changed")
}
// markUp marks an upstream as up and logs the transition once. The caller holds the lock.
func (um *upstreamMonitor) markUp(upstream, reason string) {
state, down := um.down[upstream]
if !down {
return
}
delete(um.down, upstream)
journal(mainLog.Load().Info()).
Str("upstream", journalUpstreamName(upstream)).
Str("state", "up").
Str("reason", reason).
Int64("down_for_ms", um.now().Sub(state.since).Milliseconds()).
Uint64("failure_count", state.failures).
Msg("Upstream state changed")
}
// journalUpstreamName bounds the upstream name that a journal event carries.
// ctrld generates upstream.os, upstream.<n>, and upstream.internal_<n>; every
// other key is operator text that can hold a token, so it reads as
// upstream.custom.
func journalUpstreamName(upstream string) string {
if upstream == upstreamOS || generatedUpstreamKey(strings.TrimPrefix(upstream, upstreamPrefix)) {
return upstream
}
return upstreamPrefix + "custom"
}
// generatedUpstreamKey reports whether key is a number, or internal_ and a
// number, the two shapes that ctrld writes itself.
func generatedUpstreamKey(key string) bool {
digits := strings.TrimPrefix(key, internalDomainUpstreamPrefix)
if digits == "" {
return false
}
for _, r := range digits {
if r < '0' || r > '9' {
return false
}
}
return true
}
// retire closes the outage of every upstream that is still down and stops
// every later state change. A reload builds a new monitor, so the journal ends
// the outages of the old one, and the old timers must not open new ones.
func (um *upstreamMonitor) retire() {
um.mu.Lock()
defer um.mu.Unlock()
for upstream := range um.down {
um.markUp(upstream, "reload")
}
um.retired = true
clear(um.failureReq)
clear(um.failureTimerActive)
}
// noteSuccess records an answer from an upstream. It does not set the recovered
// flag, because that flag stops failure counts for one second and an answer
// must not hide the failures that follow it.
func (um *upstreamMonitor) noteSuccess(upstream string) {
um.mu.Lock()
defer um.mu.Unlock()
um.failureReq[upstream] = 0
um.markUp(upstream, "recovered")
}
// isDown reports whether the given upstream is being marked as down.
func (um *upstreamMonitor) isDown(upstream string) bool {
um.mu.Lock()
defer um.mu.Unlock()
return um.down[upstream]
_, down := um.down[upstream]
return down
}
// reset marks an upstream as up and set failed queries counter to zero.
func (um *upstreamMonitor) reset(upstream string) {
um.mu.Lock()
um.failureReq[upstream] = 0
um.down[upstream] = false
um.markUp(upstream, "recovered")
um.recovered[upstream] = true
um.mu.Unlock()
go func() {
// debounce the recovery to avoid incrementing failure counts already in flight
time.Sleep(1 * time.Second)
um.mu.Lock()
um.recovered[upstream] = false
um.mu.Unlock()
}()
// The flag holds back the failure counts that are already in flight.
um.afterFunc()(upstreamRecoveredDelay, func() { um.clearRecovered(upstream) })
}
// clearRecovered lets the failures of an upstream count again.
func (um *upstreamMonitor) clearRecovered(upstream string) {
um.mu.Lock()
defer um.mu.Unlock()
um.recovered[upstream] = false
}
// countHealthy returns the number of upstreams in the provided map that are considered healthy.
@@ -136,7 +262,7 @@ func (um *upstreamMonitor) countHealthy(upstreams []string) int {
var count int
um.mu.RLock()
for _, upstream := range upstreams {
if !um.down[upstream] {
if _, down := um.down[upstream]; !down {
count++
}
}
+231
View File
@@ -0,0 +1,231 @@
package cli
import (
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
)
// upstreamStateMessage is the message of every upstream transition event.
const upstreamStateMessage = "Upstream state changed"
// downMonitor returns a monitor on a clock the test owns, with the upstream down.
func downMonitor(t *testing.T, upstream string) (*upstreamMonitor, *syncBuffer, *time.Time) {
t.Helper()
logs := captureDebugMainLog(t)
um := newUpstreamMonitor(&ctrld.Config{})
now := time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC)
um.now = func() time.Time { return now }
for i := 0; i < maxFailureRequest; i++ {
um.increaseFailureCount(upstream)
}
if got := len(jsonLogEvents(t, logs, upstreamStateMessage)); got != 1 {
t.Fatalf("state events after the failures: got %d, want 1", got)
}
return um, logs, &now
}
func Test_upstreamMonitorLogsOneDownEvent(t *testing.T) {
const upstream = upstreamPrefix + "0"
logs := captureDebugMainLog(t)
um := newUpstreamMonitor(&ctrld.Config{})
for i := 0; i < 633; i++ {
um.increaseFailureCount(upstream)
}
events := jsonLogEvents(t, logs, upstreamStateMessage)
if len(events) != 1 {
t.Fatalf("state events: got %d, want 1", len(events))
}
wantField(t, events[0], "level", "warn")
wantField(t, events[0], "journal", true)
wantField(t, events[0], "upstream", upstream)
wantField(t, events[0], "state", "down")
wantField(t, events[0], "reason", "immediate")
wantField(t, events[0], "failure_count", float64(maxFailureRequest))
}
func Test_upstreamMonitorTimerLogsDownEvent(t *testing.T) {
const upstream = upstreamPrefix + "0"
logs := captureDebugMainLog(t)
um := newUpstreamMonitor(&ctrld.Config{})
var armed []func()
um.after = func(_ time.Duration, fn func()) { armed = append(armed, fn) }
um.increaseFailureCount(upstream)
if len(armed) != 1 {
t.Fatalf("armed timers: got %d, want 1", len(armed))
}
armed[0]()
events := jsonLogEvents(t, logs, upstreamStateMessage)
if len(events) != 1 {
t.Fatalf("state events: got %d, want 1", len(events))
}
wantField(t, events[0], "state", "down")
wantField(t, events[0], "reason", "timer")
wantField(t, events[0], "failure_count", float64(1))
}
func Test_upstreamMonitorResetLogsOneUpEvent(t *testing.T) {
const upstream = upstreamPrefix + "0"
um, logs, now := downMonitor(t, upstream)
*now = now.Add(1500 * time.Millisecond)
um.reset(upstream)
events := jsonLogEvents(t, logs, upstreamStateMessage)
if len(events) != 2 {
t.Fatalf("state events: got %d, want 2", len(events))
}
up := events[1]
wantField(t, up, "level", "info")
wantField(t, up, "journal", true)
wantField(t, up, "upstream", upstream)
wantField(t, up, "state", "up")
wantField(t, up, "reason", "recovered")
wantField(t, up, "down_for_ms", float64(1500))
wantField(t, up, "failure_count", float64(maxFailureRequest))
}
func Test_upstreamMonitorRetireLogsTheOpenOutage(t *testing.T) {
const upstream = upstreamPrefix + "0"
um, logs, now := downMonitor(t, upstream)
*now = now.Add(3 * time.Second)
um.retire()
events := jsonLogEvents(t, logs, upstreamStateMessage)
if len(events) != 2 {
t.Fatalf("state events: got %d, want 2", len(events))
}
up := events[1]
wantField(t, up, "upstream", upstream)
wantField(t, up, "state", "up")
wantField(t, up, "reason", "reload")
wantField(t, up, "down_for_ms", float64(3000))
um.retire()
if got := len(jsonLogEvents(t, logs, upstreamStateMessage)); got != 2 {
t.Fatalf("state events after the second retire: got %d, want 2", got)
}
}
func Test_upstreamMonitorNoteSuccessLogsOneUpEvent(t *testing.T) {
const upstream = upstreamPrefix + "0"
um, logs, now := downMonitor(t, upstream)
*now = now.Add(2 * time.Second)
um.noteSuccess(upstream)
events := jsonLogEvents(t, logs, upstreamStateMessage)
if len(events) != 2 {
t.Fatalf("state events: got %d, want 2", len(events))
}
up := events[1]
wantField(t, up, "state", "up")
wantField(t, up, "reason", "recovered")
wantField(t, up, "down_for_ms", float64(2000))
wantField(t, up, "failure_count", float64(maxFailureRequest))
if got := um.failureReq[upstream]; got != 0 {
t.Fatalf("failure count after the answer: got %d, want 0", got)
}
}
func Test_upstreamMonitorNoteSuccessOnUpUpstreamIsQuiet(t *testing.T) {
const upstream = upstreamPrefix + "0"
logs := captureDebugMainLog(t)
um := newUpstreamMonitor(&ctrld.Config{})
um.noteSuccess(upstream)
um.noteSuccess(upstream)
if got := len(jsonLogEvents(t, logs, upstreamStateMessage)); got != 0 {
t.Fatalf("state events: got %d, want 0", got)
}
}
// Test_upstreamMonitorRetiredTimerStaysSilent fires the down timer that a
// failure armed before the reload retired the monitor. A retired monitor
// owns no outage, so the timer must add no event.
func Test_upstreamMonitorRetiredTimerStaysSilent(t *testing.T) {
const upstream = upstreamPrefix + "0"
logs := captureDebugMainLog(t)
um := newUpstreamMonitor(&ctrld.Config{})
var armed []func()
um.after = func(_ time.Duration, fn func()) { armed = append(armed, fn) }
um.increaseFailureCount(upstream)
um.retire()
for _, fire := range armed {
fire()
}
if got := len(jsonLogEvents(t, logs, upstreamStateMessage)); got != 0 {
t.Fatalf("state events from the retired monitor: got %d, want 0: %s", got, logs.String())
}
if um.isDown(upstream) {
t.Fatal("the retired monitor marked the upstream down")
}
}
// Test_upstreamMonitorRetiredAfterImmediateDownStaysSilent retires a monitor
// that already reported an outage. The armed timer and a late failure must
// add no event after the closing up event.
func Test_upstreamMonitorRetiredAfterImmediateDownStaysSilent(t *testing.T) {
const upstream = upstreamPrefix + "0"
logs := captureDebugMainLog(t)
um := newUpstreamMonitor(&ctrld.Config{})
var armed []func()
um.after = func(_ time.Duration, fn func()) { armed = append(armed, fn) }
for i := 0; i < maxFailureRequest; i++ {
um.increaseFailureCount(upstream)
}
um.retire()
before := jsonLogEvents(t, logs, upstreamStateMessage)
if len(before) != 2 {
t.Fatalf("state events after the retire: got %d, want down and up", len(before))
}
for _, fire := range armed {
fire()
}
um.increaseFailureCount(upstream)
if got := len(jsonLogEvents(t, logs, upstreamStateMessage)); got != 2 {
t.Fatalf("state events after the retired timer fired: got %d, want 2: %s", got, logs.String())
}
if um.isDown(upstream) {
t.Fatal("the retired monitor marked the upstream down again")
}
}
// Test_upstreamMonitorKeepsACustomNameOutOfTheJournal marks an upstream with
// an operator-chosen key down. The key can hold a token, so the event names
// the upstream by a bounded name.
func Test_upstreamMonitorKeepsACustomNameOutOfTheJournal(t *testing.T) {
const upstream = upstreamPrefix + "dns.controld.com/org-v1-SECRET0123"
logs := captureDebugMainLog(t)
um := newUpstreamMonitor(&ctrld.Config{})
for i := 0; i < maxFailureRequest; i++ {
um.increaseFailureCount(upstream)
}
um.noteSuccess(upstream)
events := jsonLogEvents(t, logs, upstreamStateMessage)
if len(events) != 2 {
t.Fatalf("state events: got %d, want down and up", len(events))
}
for _, event := range events {
wantField(t, event, "upstream", upstreamPrefix+"custom")
}
for _, generated := range []string{upstreamOS, upstreamPrefix + "0", upstreamPrefix + "internal_3"} {
if got := journalUpstreamName(generated); got != generated {
t.Fatalf("journalUpstreamName(%q) = %q, want the name itself", generated, got)
}
}
}
+2
View File
@@ -218,6 +218,8 @@ func (c *Config) FirstUpstream() *UpstreamConfig {
type ServiceConfig struct {
LogLevel string `mapstructure:"log_level" toml:"log_level,omitempty"`
LogPath string `mapstructure:"log_path" toml:"log_path,omitempty"`
LogMaxSizeMB int `mapstructure:"log_max_size_mb" toml:"log_max_size_mb,omitempty" validate:"omitempty,gte=1,lte=1024"`
LogMaxBackups *int `mapstructure:"log_max_backups" toml:"log_max_backups,omitempty" validate:"omitempty,gte=0,lte=64"`
CacheEnable bool `mapstructure:"cache_enable" toml:"cache_enable,omitempty"`
CacheSize int `mapstructure:"cache_size" toml:"cache_size,omitempty"`
CacheTTLOverride int `mapstructure:"cache_ttl_override" toml:"cache_ttl_override,omitempty"`
+66
View File
@@ -169,6 +169,72 @@ discover_ptr = false
require.False(t, *cfg.Service.DiscoverPtr)
}
func TestLogMaxKeys(t *testing.T) {
tests := []struct {
name string
serviceKeys string
wantSize int
wantBackups *int
}{
{"size and backups set", "log_max_size_mb = 20\nlog_max_backups = 1", 20, intPtr(1)},
{"size zero, backups absent", "log_max_size_mb = 0", 0, nil},
{"backups zero", "log_max_backups = 0", 0, intPtr(0)},
{"the largest values the keys take", "log_max_size_mb = 1024\nlog_max_backups = 64", 1024, intPtr(64)},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := configWithServiceKeys(t, tc.serviceKeys)
validate := validator.New()
require.NoError(t, ctrld.ValidateConfig(validate, cfg))
require.Equal(t, tc.wantSize, cfg.Service.LogMaxSizeMB)
if tc.wantBackups == nil {
require.Nil(t, cfg.Service.LogMaxBackups)
return
}
require.NotNil(t, cfg.Service.LogMaxBackups)
require.Equal(t, *tc.wantBackups, *cfg.Service.LogMaxBackups)
})
}
}
func TestLogMaxKeysOutOfRange(t *testing.T) {
tests := []struct {
name string
serviceKeys string
wantField string
}{
{"negative size", "log_max_size_mb = -1", "LogMaxSizeMB"},
{"negative backups", "log_max_backups = -1", "LogMaxBackups"},
{"size above the limit", "log_max_size_mb = 1025", "LogMaxSizeMB"},
{"backups above the limit", "log_max_backups = 65", "LogMaxBackups"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := configWithServiceKeys(t, tc.serviceKeys)
validate := validator.New()
err := ctrld.ValidateConfig(validate, cfg)
require.ErrorContains(t, err, tc.wantField)
})
}
}
func configWithServiceKeys(t *testing.T, serviceKeys string) *ctrld.Config {
t.Helper()
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
ctrld.InitConfig(v, "test_log_max_keys")
v.SetConfigType("toml")
require.NoError(t, v.ReadConfig(strings.NewReader("[service]\n"+serviceKeys+"\n")))
var cfg ctrld.Config
require.NoError(t, v.Unmarshal(&cfg))
return &cfg
}
func intPtr(n int) *int {
return &n
}
func defaultConfig(t *testing.T) *ctrld.Config {
v := viper.New()
ctrld.InitConfig(v, "test_load_default_config")
+18
View File
@@ -121,10 +121,28 @@ Logging level you wish to enable.
### log_path
Relative or absolute path of the log file.
Point `log_path` at a file that no other program rotates. ctrld renames `<log_path>.1` to `<log_path>.N` and uploads these files. A lower `log_max_backups` leaves the older numbered `log_path` files in place, but ctrld removes the extra numbered files of its internal logs.
- Type: string
- Required: no
- Default: ""
### log_max_size_mb
Maximum size of the debug log file in MB. When the file reaches this size, ctrld rotates it. This limit also applies to the `log_path` file.
- Type: integer
- Required: no
- Valid values: 1 to 1024
- Default: 10 on desktop platforms, 5 on routers
### log_max_backups
Number of rotated debug log files that ctrld keeps. If the value is 0, ctrld keeps no rotated file. This number also applies to the `log_path` file.
- Type: integer
- Required: no
- Valid values: 0 to 64
- Default: 4 on desktop platforms, 1 on routers
### cache_enable
When `cache_enable = true`, all resolved DNS query responses will be cached for duration of the upstream record TTLs.
+4 -1
View File
@@ -153,7 +153,10 @@ removed domain leaves no rule and no `internal_*` upstream behind.
Generated `internal_*` upstreams carry the organization's private resolver
addresses, so anything naming one stays at debug, and the level above it carries
only a classification (`timeout`, `refused`, `unreachable`, `source_unavailable`,
`error`). That covers every consumer, not just query handling:
`error`). The per-query error sampler applies to the classification line as
well. After 5 lines in one minute for the same upstream and class, the next
classification lines go to debug, and one `Per-query errors sampled` line gives
the count. That covers every consumer, not just query handling:
- the bootstrap-IP line in `setupUpstream`;
- the transport error from a failed query, which names the endpoint;
+11 -8
View File
@@ -1,9 +1,12 @@
# Network-recovery diagnostics
Use continuous `ctrld log tail` capture during a reproduction. Internal logs rotate by size.
A later `log send` can contain recent debug events and older warnings.
Match timestamps and IDs. The last retained error does not necessarily describe the current network.
Issue #604 tracks upload-time device and version headers separately.
The retained stream is the journal, the file `ctrld-journal.log` in the ctrld home directory.
It keeps every warning and every error, plus every event marked `journal=true`.
The journal is on disk, so it keeps these events across a restart and a self-upgrade.
Its budget is 2 MB with 2 backup files on a desktop, and 1 MB with 1 backup file on a router.
Every `ctrld log send` upload holds the whole journal, so a reproduction does not need a continuous `ctrld log tail` capture.
Match timestamps and IDs. The last retained error does not describe the current network in every case.
Each upload starts with a header line that names the build, the intercept mode, and the network at send time.
## Transitions and recovery
@@ -16,10 +19,10 @@ For minor callbacks and late major callbacks in that epoch, ctrld reads current
This read does not change host DNS. A failed read defers source writes, but reconciliation continues.
`Removed stale resolver source` is a warning only when ctrld removes an invalid address.
The warning gives the reason and survives debug rotation.
The warning gives the reason. Every warning enters the journal, so it stays on disk after a debug rotation and after a restart.
`Recovery begin` and `Recovery end` connect the transition to a `recovery_generation`, reason, outcome, and elapsed time.
Only the first upstream failure in each recovery produces a bounded summary.
If a recovery encounters a failure, its final outcome also enters the warning stream.
If a recovery encounters a failure, its final outcome also enters the journal.
Normal successful recovery stays at debug level.
These summaries exclude upstream URLs and queried names.
@@ -43,9 +46,9 @@ The cancellation event alone does not prove that interface DNS remains removed.
A changed unsuccessful condition produces a warning. The condition consists of the stage, error code, outcome, and target.
Equal results stay at debug level. The next changed condition includes the preceding repeat count.
The first restored receipt also produces a warning-stream event. This event prevents an older failure from appearing current.
The first restored receipt also produces a warning, which enters the journal. This event prevents an older failure from appearing current.
Normal receipts and shutdown cancellation stay at debug level.
The warning buffer is finite. These events do not guarantee indefinite retention.
The journal budget is finite and the journal rotates by size. It does not keep these events without a limit.
The outcomes have these meanings:
+76 -28
View File
@@ -1,46 +1,94 @@
# Runtime Internal Logging
When no logging is configured (i.e., `log_path` is not set), ctrld automatically enables an internal logging system. This system stores logs in memory to provide troubleshooting information when problems occur.
# Runtime internal logging
## Purpose
The runtime internal logging system is designed primarily for **ctrld developers**, not end users. It captures detailed diagnostic information that can be useful for troubleshooting issues when they arise, especially in production environments where explicit logging may not be configured.
ctrld writes its own log when the configuration sets no log file. The log gives Control D support and ctrld developers the evidence they need after a problem. Two of the three streams are files on disk. They keep their content across a restart and a self-upgrade.
## When It's Enabled
## When it is on
Internal logging is automatically enabled when:
Internal logging is on when these three conditions are true at the same time:
- ctrld is running in Control D mode (i.e., `--cd` flag is provided)
- No log file is configured (i.e., `log_path` is empty or not set)
- ctrld runs in Control D mode, with the `--cd` flag
- The configuration sets no `log_path`
- Silent mode is off
If a log file is explicitly configured via `log_path`, internal logging will **not** be enabled, as the configured log file serves the logging purpose.
When `log_path` is set, ctrld writes that file and starts no internal stream. The journal needs internal logging, so `log_path` mode has no journal.
## How It Works
## Streams and files
The internal logging system:
ctrld writes three streams. The debug file and the journal file live in the ctrld home directory, next to each other.
- Stores logs in **in-memory buffers** (not written to disk)
- Captures logs at **debug level** for normal operations and **warn level** for warnings
- Maintains separate buffers for normal logs and warning logs
- Automatically manages buffer size to prevent unbounded memory growth
- Preserves initialization logs even when buffers overflow
| Stream | Level filter | File | Desktop budget | Router budget | Survives a restart |
|---|---|---|---|---|---|
| Console | Notice and above. The `-v` flag adds the lower levels | none | none | none | No |
| Debug | Every level | `ctrld.log` | 10 MB, 4 backup files | 5 MB, 1 backup file | Yes |
| Journal | Warning and above, plus every event with `journal=true` | `ctrld-journal.log` | 2 MB, 2 backup files | 1 MB, 1 backup file | Yes |
## Configuration
The `-v` flag changes the level of every stream, the files included. Without the flag, ctrld sets the level to debug and holds the console at notice. With `-v` the level is info, so no debug line reaches the debug file. With `-vv` the level is debug again, and the console shows every line.
**Important**: The `log_level` configuration option does **not** affect the internal logging system. Internal logging always operates at debug level for normal logs and warn level for warnings, regardless of the `log_level` setting in the configuration file.
A backup file carries a number suffix. On a desktop the debug backup files are `ctrld.log.1` to `ctrld.log.4`, and `ctrld.log.1` holds the newest rotated content. The journal backup files are `ctrld-journal.log.1` and `ctrld-journal.log.2`.
The `log_level` setting only affects:
- Console output (when running interactively)
- File-based logging (when `log_path` is configured)
Rotation is by size. A write that crosses the size limit rotates the file first. ctrld deletes the file beyond the backup count.
## Accessing Internal Logs
Each stream also keeps a memory buffer: 5 MB for the debug stream and 1 MB for the journal. These buffers serve `ctrld log tail` and the fallback path when a file cannot open. The files hold the history.
Internal logs can be accessed through the control server API endpoints. This functionality is intended for developers and support personnel who need to diagnose issues.
A restart deletes the memory buffers and keeps the files. The new process appends a header line, which marks the restart point inside each file.
## Header and rotation events
Every debug file and every journal file starts with one JSON line, the header. ctrld writes the header when it opens a file at start and after every rotation. On an existing file at start, ctrld appends the header.
The message of the line is `Log header`. The line carries these fields:
- Build: `version` and `commit`
- Host: `os`, `arch`, `pid`, and `start_time`
- Mode: `intercept_mode`, `listeners`, `upstream_count`, and `upstream_types`
- Identity: `resolver_uid`
- Files: `log_files`
- Network: `network` with `default_route_interface`, `have_v4`, `have_v6`, and `interfaces`
The header holds no provisioning token, no upstream endpoint URL, and no query name. `upstream_types` holds the type words, for example `doh` or `os`. `listeners` holds the listen addresses.
After each rotation ctrld logs one `Log rotated` event at info level, with the fields `file`, `bytes_written`, `first_event_at`, `last_event_at`, and `backups`. This event is a journal event, so it lands in the debug stream and in the journal.
## Sampling of per-query errors
A broken network produces one error line per query. ctrld bounds this flood for each error class and each upstream. The first 5 lines in a 60 s window stay at error level. Later lines in the same window drop to debug level, so the debug stream stays complete. A run with `-v` holds every stream at info, and the debug stream then loses these lines.
When a window closes, ctrld logs one `Per-query errors sampled` summary at error level. The summary carries `class`, `upstream`, `count`, `suppressed`, and `window_s`. ctrld logs the summary only when the window suppressed at least one line.
An upstream logs one `Upstream state changed` event per transition. The down event is at warn level and carries `state` `down`, `failure_count`, and `reason`. The up event is at info level and carries `state` `up`, `down_for_ms`, and `failure_count`. Both are journal events.
## Configuration keys
The `log_level` key does not change the internal streams. The debug file keeps every level, and the journal keeps the warnings, the errors, and the journal events. The `log_level` key changes the console output and the `log_path` file. The `-v` flag changes every stream, as the section "Streams and files" explains.
Two keys in the `[service]` section size the debug stream:
- The key `log_max_size_mb` sets the size limit of `ctrld.log` in MB. The default is 10 on a desktop and 5 on a router.
- The key `log_max_backups` sets the number of backup files. The default is 4 on a desktop and 1 on a router. The value 0 keeps no backup file.
Both keys also apply to the `log_path` file, which rotates the same way. The journal budget is fixed per platform.
## Uploads
`ctrld log send` uploads the log to Control D. The body holds these parts, in this order:
- One header line rendered at send time, with the field `trigger` set to `send`
- The newest 10 MB of the debug files, oldest file first, cut at a line boundary
- The marker `=== LOG_END ===`
- Every journal file, oldest first
The `network` object of the send-time header comes from a fresh read of the interfaces and the default route. `ctrld log send --full` sends every debug file in place of the newest 10 MB. The reported size equals the number of bytes uploaded.
`ctrld log view` prints the same composition on the terminal and takes the same `--full` flag. ctrld accepts one upload per minute. The body is plain text without gzip.
In `log_path` mode the body holds the send-time header, then the `log_path` file and its backup files, oldest first. This body has no marker and no journal.
`ctrld log tail` follows the live debug stream and prints each new line, journal events included.
## Notes
- Internal logging is **not** a replacement for proper log file configuration in production environments
- For production deployments, it is recommended to configure `log_path` to enable persistent file-based logging
- Internal logs are stored in memory and will be lost if the process terminates unexpectedly
- The internal logging system is automatically disabled when explicit logging is configured
- Internal logging is not a replacement for a configured `log_path` file in production.
- The journal is small on purpose. It keeps the events that describe a problem, not the events of each query.
- Support reads the upload without access to the host. The header line names the build, the mode, and the network, so support does not have to ask for them.