diff --git a/cmd/hardlink/main.go b/cmd/hardlink/main.go index 131253e..eec2c0e 100644 --- a/cmd/hardlink/main.go +++ b/cmd/hardlink/main.go @@ -33,7 +33,7 @@ import ( ) const ( - buildVersion = "v2.1.2" + buildVersion = "v2.1.3" serviceName = "hardlink" pollingFrequency = 8 * time.Second ) diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 3cc9e97..b098204 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -2,17 +2,22 @@ package logging import ( "fmt" + "io" "os" + "path/filepath" "time" log "github.com/sirupsen/logrus" ) -// setupLogging ensures log directory, opens log file, and configures logrus. -// Returns the *os.File so caller can defer its Close(). -func SetupLogging(logDir, serviceName, buildVersion string) (*os.File, error) { - fileName := logDir + serviceName + ".log" - f, err := os.OpenFile(fileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) +// SetupLogging ensures the log directory, opens the rotating log writer, and configures logrus. +func SetupLogging(logDir, serviceName, buildVersion string) (io.WriteCloser, error) { + if err := os.MkdirAll(logDir, 0o755); err != nil { + return nil, fmt.Errorf("create log directory: %w", err) + } + + fileName := filepath.Join(logDir, serviceName+".log") + f, err := newWeeklyLogWriter(fileName, time.Local, defaultWeeklyLogRuntime()) if err != nil { return nil, fmt.Errorf("open log file: %w", err) } diff --git a/internal/logging/weekly_writer.go b/internal/logging/weekly_writer.go new file mode 100644 index 0000000..0c0ce00 --- /dev/null +++ b/internal/logging/weekly_writer.go @@ -0,0 +1,319 @@ +package logging + +import ( + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + _ "time/tzdata" +) + +const weeklyLogRetention = 13 + +type logRotationTimer interface { + C() <-chan time.Time + Stop() bool +} + +type realLogRotationTimer struct { + *time.Timer +} + +func (t realLogRotationTimer) C() <-chan time.Time { + return t.Timer.C +} + +type weeklyLogRuntime struct { + now func() time.Time + newTimer func(time.Duration) logRotationTimer + rename func(string, string) error + diagnostic io.Writer +} + +func defaultWeeklyLogRuntime() weeklyLogRuntime { + return weeklyLogRuntime{ + now: time.Now, + newTimer: func(duration time.Duration) logRotationTimer { + return realLogRotationTimer{Timer: time.NewTimer(duration)} + }, + rename: os.Rename, + diagnostic: os.Stderr, + } +} + +type weeklyLogWriter struct { + path string + location *time.Location + now func() time.Time + newTimer func(time.Duration) logRotationTimer + rename func(string, string) error + diagnostic io.Writer + + mu sync.Mutex + file *os.File + weekStart time.Time + closed bool + stop chan struct{} + done chan struct{} + closeOnce sync.Once + closeErr error +} + +func newWeeklyLogWriter(path string, location *time.Location, runtime weeklyLogRuntime) (*weeklyLogWriter, error) { + if location == nil { + location = time.Local + } + if runtime.now == nil { + runtime.now = time.Now + } + if runtime.newTimer == nil { + runtime.newTimer = defaultWeeklyLogRuntime().newTimer + } + if runtime.rename == nil { + runtime.rename = os.Rename + } + if runtime.diagnostic == nil { + runtime.diagnostic = os.Stderr + } + + now := runtime.now().In(location) + weekStart := logWeekStart(now, location) + if info, err := os.Stat(path); err == nil { + weekStart = logWeekStart(info.ModTime(), location) + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("inspect active log: %w", err) + } + + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o666) + if err != nil { + return nil, err + } + + writer := &weeklyLogWriter{ + path: path, + location: location, + now: runtime.now, + newTimer: runtime.newTimer, + rename: runtime.rename, + diagnostic: runtime.diagnostic, + file: file, + weekStart: weekStart, + stop: make(chan struct{}), + done: make(chan struct{}), + } + writer.rotateIfNeeded(now) + go writer.run() + return writer, nil +} + +func (w *weeklyLogWriter) Write(data []byte) (int, error) { + now := w.now().In(w.location) + w.mu.Lock() + rotationErr := w.rotateIfNeededLocked(now) + if w.closed || w.file == nil { + w.mu.Unlock() + w.reportRotationError(rotationErr) + return 0, os.ErrClosed + } + n, writeErr := w.file.Write(data) + w.mu.Unlock() + w.reportRotationError(rotationErr) + return n, writeErr +} + +func (w *weeklyLogWriter) Close() error { + w.closeOnce.Do(func() { + close(w.stop) + <-w.done + + w.mu.Lock() + w.closed = true + if w.file != nil { + w.closeErr = w.file.Close() + w.file = nil + } + w.mu.Unlock() + }) + return w.closeErr +} + +func (w *weeklyLogWriter) run() { + defer close(w.done) + for { + now := w.now().In(w.location) + next := nextLogWeekStart(now, w.location) + duration := next.Sub(now) + if duration <= 0 { + duration = time.Nanosecond + } + timer := w.newTimer(duration) + select { + case <-timer.C(): + w.rotateIfNeeded(w.now().In(w.location)) + case <-w.stop: + timer.Stop() + return + } + } +} + +func (w *weeklyLogWriter) rotateIfNeeded(now time.Time) { + w.mu.Lock() + err := w.rotateIfNeededLocked(now) + w.mu.Unlock() + w.reportRotationError(err) +} + +func (w *weeklyLogWriter) rotateIfNeededLocked(now time.Time) error { + if w.closed { + return nil + } + targetWeek := logWeekStart(now, w.location) + if !targetWeek.After(w.weekStart) { + return nil + } + + // Record the attempted week even if rotation fails so every write in a + // broken environment does not retry and emit another diagnostic. + w.weekStart = targetWeek + return w.rotateLocked() +} + +func (w *weeklyLogWriter) rotateLocked() error { + if w.file == nil { + return fmt.Errorf("active log file is unavailable") + } + if err := w.file.Close(); err != nil { + w.file = nil + return errors.Join(fmt.Errorf("close active log: %w", err), w.reopenActiveLocked()) + } + w.file = nil + + if err := w.cleanupOlderGenerationsLocked(); err != nil { + return errors.Join(err, w.reopenActiveLocked()) + } + if err := removeIfExists(w.generationPath(weeklyLogRetention)); err != nil { + return errors.Join(fmt.Errorf("remove oldest weekly log: %w", err), w.reopenActiveLocked()) + } + for generation := weeklyLogRetention - 1; generation >= 1; generation-- { + source := w.generationPath(generation) + if _, err := os.Stat(source); err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return errors.Join(fmt.Errorf("inspect weekly log generation %d: %w", generation, err), w.reopenActiveLocked()) + } + if err := w.rename(source, w.generationPath(generation+1)); err != nil { + return errors.Join(fmt.Errorf("shift weekly log generation %d: %w", generation, err), w.reopenActiveLocked()) + } + } + + activeRenamed := false + if _, err := os.Stat(w.path); err == nil { + if err := w.rename(w.path, w.generationPath(1)); err != nil { + return errors.Join(fmt.Errorf("archive active log: %w", err), w.reopenActiveLocked()) + } + activeRenamed = true + } else if !errors.Is(err, os.ErrNotExist) { + return errors.Join(fmt.Errorf("inspect active log: %w", err), w.reopenActiveLocked()) + } + + file, err := os.OpenFile(w.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o666) + if err == nil { + w.file = file + return nil + } + + rotationErr := fmt.Errorf("open new active log: %w", err) + if activeRenamed { + if rollbackErr := w.rename(w.generationPath(1), w.path); rollbackErr != nil { + rotationErr = errors.Join(rotationErr, fmt.Errorf("restore archived active log: %w", rollbackErr)) + } + } + return errors.Join(rotationErr, w.reopenActiveLocked()) +} + +func (w *weeklyLogWriter) cleanupOlderGenerationsLocked() error { + directory := filepath.Dir(w.path) + entries, err := os.ReadDir(directory) + if err != nil { + return fmt.Errorf("list weekly log directory: %w", err) + } + base := filepath.Base(w.path) + extension := filepath.Ext(base) + stem := strings.TrimSuffix(base, extension) + prefix := stem + "." + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, extension) { + continue + } + generationText := strings.TrimSuffix(strings.TrimPrefix(name, prefix), extension) + generation, err := strconv.Atoi(generationText) + if err != nil || generation <= weeklyLogRetention { + continue + } + if err := os.Remove(filepath.Join(directory, name)); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove weekly log generation %d: %w", generation, err) + } + } + return nil +} + +func (w *weeklyLogWriter) reopenActiveLocked() error { + file, err := os.OpenFile(w.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o666) + if err != nil { + return fmt.Errorf("reopen active log: %w", err) + } + w.file = file + return nil +} + +func (w *weeklyLogWriter) generationPath(generation int) string { + extension := filepath.Ext(w.path) + stem := strings.TrimSuffix(w.path, extension) + return fmt.Sprintf("%s.%d%s", stem, generation, extension) +} + +func (w *weeklyLogWriter) reportRotationError(err error) { + if err == nil { + return + } + slog.New(slog.NewJSONHandler(w.diagnostic, nil)).Warn( + "weekly log rotation failed", + "module", "logging", + "action", "weekly_log_rotation_failed", + "path", w.path, + "error.message", err.Error(), + ) +} + +func logWeekStart(value time.Time, location *time.Location) time.Time { + local := value.In(location) + daysSinceMonday := (int(local.Weekday()) + 6) % 7 + monday := local.AddDate(0, 0, -daysSinceMonday) + return time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, location) +} + +func nextLogWeekStart(value time.Time, location *time.Location) time.Time { + return logWeekStart(value, location).AddDate(0, 0, 7) +} + +func removeIfExists(path string) error { + err := os.Remove(path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} diff --git a/internal/logging/weekly_writer_test.go b/internal/logging/weekly_writer_test.go new file mode 100644 index 0000000..af764d9 --- /dev/null +++ b/internal/logging/weekly_writer_test.go @@ -0,0 +1,387 @@ +package logging + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + log "github.com/sirupsen/logrus" +) + +func TestWeeklyLogRotationShiftsAndRetainsExactGenerations(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "hardlink.log") + writeLogTestFile(t, path, "active") + for generation := 1; generation <= 14; generation++ { + writeLogTestFile(t, weeklyLogTestGeneration(path, generation), fmt.Sprintf("generation-%d", generation)) + } + writeLogTestFile(t, weeklyLogTestGeneration(path, 19), "generation-19") + for name, contents := range map[string]string{ + "hardlink.backup.14.log": "related-looking backup", + "another.14.log": "unrelated log", + "hardlink.14.log.bak": "different extension", + } { + writeLogTestFile(t, filepath.Join(directory, name), contents) + } + + sunday := time.Date(2026, time.August, 9, 12, 0, 0, 0, time.UTC) + setLogTestModTime(t, path, sunday) + writer := newLogTestWriter(t, path, sunday, weeklyLogRuntime{}) + writer.rotateIfNeeded(time.Date(2026, time.August, 10, 0, 0, 0, 0, time.UTC)) + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + requireLogTestContents(t, path, "") + requireLogTestContents(t, weeklyLogTestGeneration(path, 1), "active") + requireLogTestContents(t, weeklyLogTestGeneration(path, 2), "generation-1") + requireLogTestContents(t, weeklyLogTestGeneration(path, 13), "generation-12") + for _, generation := range []int{14, 19} { + if _, err := os.Stat(weeklyLogTestGeneration(path, generation)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("generation %d exists after rotation, want removed: %v", generation, err) + } + } + for name, contents := range map[string]string{ + "hardlink.backup.14.log": "related-looking backup", + "another.14.log": "unrelated log", + "hardlink.14.log.bak": "different extension", + } { + requireLogTestContents(t, filepath.Join(directory, name), contents) + } +} + +func TestWeeklyLogRotationOccursOncePerWeek(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "hardlink.log") + writeLogTestFile(t, path, "week-zero\n") + sunday := time.Date(2026, time.January, 4, 18, 0, 0, 0, time.UTC) + setLogTestModTime(t, path, sunday) + writer := newLogTestWriter(t, path, sunday, weeklyLogRuntime{}) + + writer.rotateIfNeeded(time.Date(2026, time.January, 4, 23, 59, 59, 0, time.UTC)) + if _, err := os.Stat(weeklyLogTestGeneration(path, 1)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rotation before Monday: %v, want no generation", err) + } + writer.rotateIfNeeded(time.Date(2026, time.January, 5, 0, 0, 0, 0, time.UTC)) + if _, err := writer.Write([]byte("week-one\n")); err != nil { + t.Fatal(err) + } + writer.rotateIfNeeded(time.Date(2026, time.January, 6, 12, 0, 0, 0, time.UTC)) + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + requireLogTestContents(t, weeklyLogTestGeneration(path, 1), "week-zero\n") + requireLogTestContents(t, path, "week-one\n") + if _, err := os.Stat(weeklyLogTestGeneration(path, 2)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("same-week rotation created generation 2: %v", err) + } +} + +func TestWeeklyLogStartupRotatesOnceAfterSeveralMissedWeeks(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "hardlink.log") + writeLogTestFile(t, path, "old active") + writeLogTestFile(t, weeklyLogTestGeneration(path, 1), "older history") + setLogTestModTime(t, path, time.Date(2026, time.January, 5, 12, 0, 0, 0, time.UTC)) + + now := time.Date(2026, time.February, 23, 9, 0, 0, 0, time.UTC) + writer := newLogTestWriter(t, path, now, weeklyLogRuntime{}) + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + requireLogTestContents(t, weeklyLogTestGeneration(path, 1), "old active") + requireLogTestContents(t, weeklyLogTestGeneration(path, 2), "older history") + if _, err := os.Stat(weeklyLogTestGeneration(path, 3)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("missed weeks created generation 3: %v", err) + } +} + +func TestWeeklyLogSchedulerUsesLocalMondayBoundary(t *testing.T) { + location, err := time.LoadLocation("Europe/London") + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + path := filepath.Join(directory, "hardlink.log") + writeLogTestFile(t, path, "sunday") + sunday := time.Date(2026, time.March, 29, 0, 0, 0, 0, location) + setLogTestModTime(t, path, sunday) + + clock := &atomic.Pointer[time.Time]{} + clock.Store(&sunday) + created := make(chan *logTestTimer, 2) + rotated := make(chan struct{}) + runtime := weeklyLogRuntime{ + now: func() time.Time { return *clock.Load() }, + newTimer: func(duration time.Duration) logRotationTimer { + timer := &logTestTimer{duration: duration, ch: make(chan time.Time, 1)} + created <- timer + return timer + }, + rename: func(oldPath, newPath string) error { + err := os.Rename(oldPath, newPath) + if oldPath == path && err == nil { + close(rotated) + } + return err + }, + } + writer, err := newWeeklyLogWriter(path, location, runtime) + if err != nil { + t.Fatal(err) + } + timer := <-created + if timer.duration != 23*time.Hour { + t.Fatalf("timer duration = %v, want 23h across DST boundary", timer.duration) + } + monday := time.Date(2026, time.March, 30, 0, 0, 0, 0, location) + clock.Store(&monday) + timer.ch <- monday + <-rotated + if err := writer.Close(); err != nil { + t.Fatal(err) + } + requireLogTestContents(t, weeklyLogTestGeneration(path, 1), "sunday") +} + +func TestWeeklyLogConcurrentWritesRemainCompleteAcrossRotation(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "hardlink.log") + sunday := time.Date(2026, time.August, 9, 12, 0, 0, 0, time.UTC) + writer := newLogTestWriter(t, path, sunday, weeklyLogRuntime{}) + + const goroutines = 8 + const recordsPerGoroutine = 100 + writeErrors := make(chan error, goroutines) + var writers sync.WaitGroup + writers.Add(goroutines) + for worker := 0; worker < goroutines; worker++ { + go func() { + defer writers.Done() + for record := 0; record < recordsPerGoroutine; record++ { + if _, err := writer.Write([]byte(fmt.Sprintf("%d:%d\n", worker, record))); err != nil { + writeErrors <- err + return + } + } + }() + } + writer.rotateIfNeeded(time.Date(2026, time.August, 10, 0, 0, 0, 0, time.UTC)) + writers.Wait() + close(writeErrors) + for err := range writeErrors { + t.Errorf("weeklyLogWriter.Write() error = %v, want nil", err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + contents := readLogTestFile(t, weeklyLogTestGeneration(path, 1)) + readLogTestFile(t, path) + lines := strings.Split(strings.TrimSpace(contents), "\n") + if len(lines) != goroutines*recordsPerGoroutine { + t.Fatalf("record count = %d, want %d", len(lines), goroutines*recordsPerGoroutine) + } + seen := make(map[string]bool, len(lines)) + for _, line := range lines { + if _, _, ok := strings.Cut(line, ":"); !ok { + t.Fatalf("record %q has no separator", line) + } + seen[line] = true + } + if len(seen) != goroutines*recordsPerGoroutine { + t.Errorf("unique record count = %d, want %d", len(seen), goroutines*recordsPerGoroutine) + } +} + +func TestWeeklyLogCloseWaitsForRotationAndIsIdempotent(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "hardlink.log") + writeLogTestFile(t, path, "active") + sunday := time.Date(2026, time.August, 9, 12, 0, 0, 0, time.UTC) + setLogTestModTime(t, path, sunday) + started := make(chan struct{}) + release := make(chan struct{}) + runtime := weeklyLogRuntime{rename: func(oldPath, newPath string) error { + if oldPath == path { + close(started) + <-release + } + return os.Rename(oldPath, newPath) + }} + writer := newLogTestWriter(t, path, sunday, runtime) + rotationDone := make(chan struct{}) + go func() { + writer.rotateIfNeeded(time.Date(2026, time.August, 10, 0, 0, 0, 0, time.UTC)) + close(rotationDone) + }() + <-started + + closeDone := make(chan error, 1) + go func() { closeDone <- writer.Close() }() + select { + case err := <-closeDone: + t.Fatalf("weeklyLogWriter.Close() during rotation returned %v, want blocked", err) + case <-time.After(20 * time.Millisecond): + } + close(release) + <-rotationDone + if err := <-closeDone; err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Errorf("second weeklyLogWriter.Close() error = %v, want nil", err) + } + if _, err := writer.Write([]byte("after close")); !errors.Is(err, os.ErrClosed) { + t.Errorf("weeklyLogWriter.Write() after close error = %v, want os.ErrClosed", err) + } +} + +func TestWeeklyLogRotationFailureIsNonRecursiveAndPreservesWrites(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "hardlink.log") + writeLogTestFile(t, path, "before\n") + sunday := time.Date(2026, time.August, 9, 12, 0, 0, 0, time.UTC) + setLogTestModTime(t, path, sunday) + var diagnostics bytes.Buffer + runtime := weeklyLogRuntime{ + rename: func(oldPath, newPath string) error { + if oldPath == path { + return errors.New("injected rename failure") + } + return os.Rename(oldPath, newPath) + }, + diagnostic: &diagnostics, + } + writer := newLogTestWriter(t, path, sunday, runtime) + writer.rotateIfNeeded(time.Date(2026, time.August, 10, 0, 0, 0, 0, time.UTC)) + if _, err := writer.Write([]byte("after\n")); err != nil { + t.Fatalf("weeklyLogWriter.Write() after failed rotation error = %v, want nil", err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + requireLogTestContents(t, path, "before\nafter\n") + if !strings.Contains(diagnostics.String(), "weekly_log_rotation_failed") || !strings.Contains(diagnostics.String(), "injected rename failure") { + t.Errorf("rotation diagnostic = %q, want action and injected error", diagnostics.String()) + } + if strings.Contains(readLogTestFile(t, path), "weekly log rotation failed") { + t.Error("rotation diagnostic recursively entered active log") + } +} + +func TestSetupLoggingKeepsFilenameAndJSONFormat(t *testing.T) { + logger := log.StandardLogger() + previousOutput := logger.Out + previousFormatter := logger.Formatter + previousLevel := logger.Level + t.Cleanup(func() { + logger.SetOutput(previousOutput) + logger.SetFormatter(previousFormatter) + logger.SetLevel(previousLevel) + }) + + directory := filepath.Join(t.TempDir(), "nested", "logs") + writer, err := SetupLogging(directory, "hardlink", "v-test") + if err != nil { + t.Fatal(err) + } + log.WithField("probe", "value").Info("test record") + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + contents := readLogTestFile(t, filepath.Join(directory, "hardlink.log")) + for _, want := range []string{`"buildVersion":"v-test"`, `"msg":"Logging initialized"`, `"probe":"value"`, `"msg":"test record"`} { + if !strings.Contains(contents, want) { + t.Errorf("hardlink.log contents missing %q: %s", want, contents) + } + } + if _, err := os.Stat(filepath.Join(directory, "hardlink.1.log")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("SetupLogging() created archive during current week: %v", err) + } +} + +type logTestTimer struct { + duration time.Duration + ch chan time.Time + stopped atomic.Bool +} + +func (t *logTestTimer) C() <-chan time.Time { + return t.ch +} + +func (t *logTestTimer) Stop() bool { + return !t.stopped.Swap(true) +} + +func newLogTestWriter(t *testing.T, path string, now time.Time, overrides weeklyLogRuntime) *weeklyLogWriter { + t.Helper() + runtime := defaultWeeklyLogRuntime() + runtime.now = func() time.Time { return now } + if overrides.now != nil { + runtime.now = overrides.now + } + if overrides.newTimer != nil { + runtime.newTimer = overrides.newTimer + } + if overrides.rename != nil { + runtime.rename = overrides.rename + } + if overrides.diagnostic != nil { + runtime.diagnostic = overrides.diagnostic + } + writer, err := newWeeklyLogWriter(path, now.Location(), runtime) + if err != nil { + t.Fatal(err) + } + return writer +} + +func weeklyLogTestGeneration(path string, generation int) string { + extension := filepath.Ext(path) + return fmt.Sprintf("%s.%d%s", strings.TrimSuffix(path, extension), generation, extension) +} + +func writeLogTestFile(t *testing.T, path, contents string) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } +} + +func setLogTestModTime(t *testing.T, path string, value time.Time) { + t.Helper() + if err := os.Chtimes(path, value, value); err != nil { + t.Fatal(err) + } +} + +func readLogTestFile(t *testing.T, path string) string { + t.Helper() + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(contents) +} + +func requireLogTestContents(t *testing.T, path, want string) { + t.Helper() + if got := readLogTestFile(t, path); got != want { + t.Errorf("%s contents = %q, want %q", filepath.Base(path), got, want) + } +} + +var _ io.WriteCloser = (*weeklyLogWriter)(nil) diff --git a/release notes.md b/release notes.md index 8d662ba..5fb86f7 100644 --- a/release notes.md +++ b/release notes.md @@ -2,6 +2,9 @@ builtVersion is a const in main.go +#### v2.1.3 - 30 September 2026 +feat(logging): add weekly log rotation and retention + #### v2.1.2 - 30 September 2026 fix(dispenser): scan ACK responses without assuming 3-byte reads