From 5a7f8b5bd361f5080a0790817a58adee7b90330a Mon Sep 17 00:00:00 2001 From: Dominik Eisenberg <64131471+Dominik-esb@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:40:05 +0200 Subject: [PATCH] log: add size-based log file rotation (#2682) Signed-off-by: EisenbergD Co-authored-by: EisenbergD Co-authored-by: Claude Opus 5.5 --- README.md | 3 + cmd/windows_exporter/main.go | 8 +- internal/config/config.go | 11 +- internal/log/flag/flag.go | 13 + internal/log/logger.go | 91 ++++++- internal/log/logger_test.go | 156 +++++++++++ internal/log/rotate.go | 393 ++++++++++++++++++++++++++++ internal/log/rotate_other.go | 41 +++ internal/log/rotate_test.go | 339 ++++++++++++++++++++++++ internal/log/rotate_windows.go | 90 +++++++ internal/log/rotate_windows_test.go | 169 ++++++++++++ 11 files changed, 1299 insertions(+), 15 deletions(-) create mode 100644 internal/log/logger_test.go create mode 100644 internal/log/rotate.go create mode 100644 internal/log/rotate_other.go create mode 100644 internal/log/rotate_test.go create mode 100644 internal/log/rotate_windows.go create mode 100644 internal/log/rotate_windows_test.go diff --git a/README.md b/README.md index 8bb01501..a5609127 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,9 @@ windows_exporter accepts flags to configure certain behaviours. The ones configu | `--web.config.file` | A [web config][web_config] for setting up TLS and Auth | None | | `--config.file` | [Using a config file](#using-a-configuration-file) from path | None | | `--log.file` | Output file of log messages. One of [stdout, stderr, eventlog, \]
**NOTE:** The MSI installer will add a default argument to the installed service setting this to eventlog | stderr | +| `--log.rotation.max-size` | Size in megabytes at which the log file is rotated. Requires `--log.file` to be a path. If another process holds the file open without delete sharing, the exporter keeps writing to it and retries the rotation every minute. A moved or deleted log file is recreated within a minute. | `0` (off) | +| `--log.rotation.max-backups` | Number of rotated log files to keep. `0` keeps all. | `0` | +| `--log.rotation.compress` | Compress rotated log files with gzip. | `false` | ## Installation diff --git a/cmd/windows_exporter/main.go b/cmd/windows_exporter/main.go index e12e2405..d012fe00 100644 --- a/cmd/windows_exporter/main.go +++ b/cmd/windows_exporter/main.go @@ -175,13 +175,19 @@ func run(ctx context.Context, args []string) int { _ = logFile.Set("stderr") } - logger, err := log.New(logConfig) + logger, logCloser, err := log.New(logConfig) if err != nil { logStartupError(ctx, "failed to create logger", err) return 1 } + if logCloser != nil { + defer func() { + _ = logCloser.Close() + }() + } + logger.LogAttrs(ctx, slog.LevelDebug, "logging has Started") if configFile != nil && *configFile != "" { diff --git a/internal/config/config.go b/internal/config/config.go index 0e29ee3e..1cc965ea 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,9 +41,14 @@ type configFile struct { } `yaml:"collectors"` Collector collector.Config `yaml:"collector"` Log struct { - Level string `yaml:"level"` - Format string `yaml:"format"` - File string `yaml:"file"` + Level string `yaml:"level"` + Format string `yaml:"format"` + File string `yaml:"file"` + Rotation struct { + MaxSize string `yaml:"max-size"` + MaxBackups string `yaml:"max-backups"` + Compress bool `yaml:"compress"` + } `yaml:"rotation"` } `yaml:"log"` Process struct { Priority string `yaml:"priority"` diff --git a/internal/log/flag/flag.go b/internal/log/flag/flag.go index 21794374..56c5f615 100644 --- a/internal/log/flag/flag.go +++ b/internal/log/flag/flag.go @@ -41,4 +41,17 @@ func AddFlags(a *kingpin.Application, config *log.Config) { } a.Flag(FileFlagName, FileFlagHelp).Default(config.File.String()).SetValue(config.File) + + a.Flag( + "log.rotation.max-size", + "Size in megabytes at which the log file is rotated. 0 disables rotation. Requires log.file to be a file path.", + ).Default("0").IntVar(&config.Rotation.MaxSize) + a.Flag( + "log.rotation.max-backups", + "Number of rotated log files to keep. 0 keeps all.", + ).Default("0").IntVar(&config.Rotation.MaxBackups) + a.Flag( + "log.rotation.compress", + "Compress rotated log files with gzip.", + ).Default("false").BoolVar(&config.Rotation.Compress) } diff --git a/internal/log/logger.go b/internal/log/logger.go index fb6d4872..0bcd8cb2 100644 --- a/internal/log/logger.go +++ b/internal/log/logger.go @@ -22,7 +22,9 @@ import ( "fmt" "io" "log/slog" + "math" "os" + "time" "github.com/prometheus-community/windows_exporter/internal/log/eventlog" "github.com/prometheus/common/promslog" @@ -30,6 +32,8 @@ import ( ) // AllowedFile is a settable identifier for the output file that the logger can have. +// A path is only stored by Set; New opens it, since the rotation settings are +// parsed after the file flag. type AllowedFile struct { s string w io.Writer @@ -60,31 +64,96 @@ func (f *AllowedFile) Set(s string) error { f.w = eventlog.NewEventLogWriter(eventLog) default: - file, err := os.OpenFile(s, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o200) - if err != nil { - return fmt.Errorf("failed to open log file: %w", err) - } - - f.w = file + f.w = nil } return nil } +// isPath reports whether the output is a log file path rather than a stream. +func (f *AllowedFile) isPath() bool { + return f.s != "" && f.w == nil +} + +// Rotation configures the size-based rotation of a log file. Rotation is +// disabled unless MaxSize is set. +type Rotation struct { + // MaxSize is the size in megabytes at which the log file is rotated. 0 disables rotation. + MaxSize int + // MaxBackups is the number of rotated files to keep. 0 keeps all. + MaxBackups int + // Compress compresses rotated files with gzip. + Compress bool +} + +func (r Rotation) configured() bool { + return r != Rotation{} +} + +func (r Rotation) validate() error { + var errs []error + + if r.MaxSize < 0 { + errs = append(errs, errors.New("log.rotation.max-size must not be negative")) + } + + if r.MaxBackups < 0 { + errs = append(errs, errors.New("log.rotation.max-backups must not be negative")) + } + + return errors.Join(errs...) +} + // Config is a struct containing configurable settings for the logger. type Config struct { *promslog.Config - File *AllowedFile + File *AllowedFile + Rotation Rotation } -func New(config *Config) (*slog.Logger, error) { +// New creates the logger. The returned io.Closer releases the log file; it is +// nil for stdout, stderr and the event log. +func New(config *Config) (*slog.Logger, io.Closer, error) { if config.File == nil { - return nil, errors.New("log file undefined") + return nil, nil, errors.New("log file undefined") } - config.Writer = config.File.w + if err := config.Rotation.validate(); err != nil { + return nil, nil, err + } + + writer := config.File.w + + var closer io.Closer + + switch { + case !config.File.isPath(): + if config.Rotation.configured() { + return nil, nil, fmt.Errorf("log rotation requires log.file to be a file path, not %s", config.File.s) + } + case config.Rotation.MaxSize > 0: + file, err := newRotatingFile(config.File.s, int64(config.Rotation.MaxSize)<<20, config.Rotation.MaxBackups, config.Rotation.Compress, time.Now) + if err != nil { + return nil, nil, fmt.Errorf("failed to open log file: %w", err) + } + + writer, closer = file, file + case config.Rotation.configured(): + return nil, nil, errors.New("log rotation requires log.rotation.max-size") + default: + // Without a size limit the file is never rotated, but a moved or + // deleted file is still replaced by a new one at the path. + file, err := newRotatingFile(config.File.s, math.MaxInt64, 0, false, time.Now) + if err != nil { + return nil, nil, fmt.Errorf("failed to open log file: %w", err) + } + + writer, closer = file, file + } + + config.Writer = writer config.Style = promslog.SlogStyle - return promslog.New(config.Config), nil + return promslog.New(config.Config), closer, nil } diff --git a/internal/log/logger_test.go b/internal/log/logger_test.go new file mode 100644 index 00000000..87cf0a92 --- /dev/null +++ b/internal/log/logger_test.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package log + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/prometheus/common/promslog" + "github.com/stretchr/testify/require" +) + +func newConfig(t *testing.T, file string, rotation Rotation) *Config { + t.Helper() + + allowedFile := &AllowedFile{} + require.NoError(t, allowedFile.Set(file)) + + return &Config{Config: &promslog.Config{}, File: allowedFile, Rotation: rotation} +} + +func TestNewFileWithoutRotation(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "windows_exporter.log") + + logger, closer, err := New(newConfig(t, path, Rotation{})) + require.NoError(t, err) + require.NotNil(t, closer) + + logger.Info("hello") + require.NoError(t, closer.Close()) + + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(data), "hello") +} + +func writeMegabytes(logger *slog.Logger, megabytes int) { + message := strings.Repeat("x", 1024) + for range megabytes * 1100 { + logger.Info(message) + } +} + +func TestNewFileWithSizeRotation(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "windows_exporter.log") + + logger, closer, err := New(newConfig(t, path, Rotation{MaxSize: 1})) + require.NoError(t, err) + require.NotNil(t, closer) + + writeMegabytes(logger, 2) + require.NoError(t, closer.Close()) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + require.GreaterOrEqual(t, len(entries), 2, "expected the active log file and at least one backup") + + info, err := os.Stat(path) + require.NoError(t, err) + require.LessOrEqual(t, info.Size(), int64(1024*1024)) +} + +func TestNewFileMaxBackups(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "windows_exporter.log") + + logger, closer, err := New(newConfig(t, path, Rotation{MaxSize: 1, MaxBackups: 2})) + require.NoError(t, err) + + // Rotates at least four times; only the two newest backups are kept. + writeMegabytes(logger, 5) + require.NoError(t, closer.Close()) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + require.Len(t, entries, 3, "expected the active log file and two backups") +} + +func TestNewFileUnwritable(t *testing.T) { + t.Parallel() + + // The parent of the log file is a file, so the log file can't be created. + parent := filepath.Join(t.TempDir(), "not-a-directory") + require.NoError(t, os.WriteFile(parent, nil, 0o600)) + + path := filepath.Join(parent, "windows_exporter.log") + + for name, rotation := range map[string]Rotation{ + "without rotation": {}, + "with rotation": {MaxSize: 1}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + _, _, err := New(newConfig(t, path, rotation)) + require.ErrorContains(t, err, "failed to open log file") + }) + } +} + +func TestNewRotationValidation(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "windows_exporter.log") + + for name, tc := range map[string]struct { + file string + rotation Rotation + err string + }{ + "stream": {"stdout", Rotation{MaxSize: 10}, "requires log.file to be a file path"}, + "no max size": {path, Rotation{MaxBackups: 3}, "requires log.rotation.max-size"}, + "negative size": {path, Rotation{MaxSize: -1}, "max-size must not be negative"}, + "negative backups": {path, Rotation{MaxSize: 1, MaxBackups: -1}, "max-backups must not be negative"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + _, _, err := New(newConfig(t, tc.file, tc.rotation)) + require.ErrorContains(t, err, tc.err) + }) + } +} + +func TestNewStreamHasNoCloser(t *testing.T) { + t.Parallel() + + _, closer, err := New(newConfig(t, "stderr", Rotation{})) + require.NoError(t, err) + require.Nil(t, closer) +} diff --git a/internal/log/rotate.go b/internal/log/rotate.go new file mode 100644 index 00000000..1a324ef5 --- /dev/null +++ b/internal/log/rotate.go @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "compress/gzip" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "time" +) + +const ( + // rotationRetryInterval is how long a log file whose rotation failed is + // appended to before the rotation is tried again. + rotationRetryInterval = time.Minute + + backupTimeFormat = "2006-01-02T15-04-05.000" + gzipSuffix = ".gz" +) + +// rotatingFile appends to a log file and rotates it before a write would make +// it larger than maxSize. A rotation that fails, for example because another +// process holds the file open without delete sharing, never drops a line: the +// file is appended to and the rotation is retried after rotationRetryInterval. +type rotatingFile struct { + path string + maxSize int64 + maxBackups int + compress bool + + now func() time.Time + rename func(oldPath, newPath string) error + retryInterval time.Duration + + mu sync.Mutex + file *os.File + size int64 + retryAfter time.Time + // checkAfter is when the next write checks that the path still points + // to the open file; an admin may have moved or deleted it. + checkAfter time.Time + + // cleanup compresses and prunes rotated files in the background; Close + // waits for it. cleanupMu runs one cleanup at a time. + cleanup sync.WaitGroup + cleanupMu sync.Mutex +} + +func newRotatingFile(path string, maxSize int64, maxBackups int, compress bool, now func() time.Time) (*rotatingFile, error) { + return newRotatingFileWithRename(path, maxSize, maxBackups, compress, now, os.Rename) +} + +func newRotatingFileWithRename( + path string, + maxSize int64, + maxBackups int, + compress bool, + now func() time.Time, + rename func(oldPath, newPath string) error, +) (*rotatingFile, error) { + r := &rotatingFile{ + path: path, + maxSize: maxSize, + maxBackups: maxBackups, + compress: compress, + now: now, + rename: rename, + retryInterval: rotationRetryInterval, + } + + file, size, err := openLogFileWithSize(path) + if err != nil { + return nil, err + } + + r.file, r.size = file, size + r.checkAfter = now().Add(r.retryInterval) + + // A file that is already too large is rotated right away. If that fails, + // the logger still starts and the rotation is retried on a later write. + if r.size >= r.maxSize { + r.mu.Lock() + _ = r.rotate() + r.mu.Unlock() + } + + return r, nil +} + +func openLogFileWithSize(path string) (*os.File, int64, error) { + file, err := openLogFile(path) + if err != nil { + return nil, 0, err + } + + info, err := file.Stat() + if err != nil { + _ = file.Close() + + return nil, 0, fmt.Errorf("failed to read log file size: %w", err) + } + + return file, info.Size(), nil +} + +// Write appends p to the log file. p is never split across files. +func (r *rotatingFile) Write(p []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.file == nil { + return 0, os.ErrClosed + } + + if now := r.now(); !now.Before(r.checkAfter) { + r.checkAfter = now.Add(r.retryInterval) + + // The file was moved, deleted or replaced: start a new one at the + // path. If that fails, keep writing to the current file. + if same, err := sameFile(r.file, r.path); err != nil || !same { + _ = r.reopen() + } + } + + if r.size > 0 && int64(len(p)) > r.maxSize-r.size && !r.now().Before(r.retryAfter) { + // On failure, keep appending to the current file. + _ = r.rotate() + } + + n, err := r.file.Write(p) + r.size += int64(n) + + return n, err +} + +// Close closes the log file and waits for compression and pruning of rotated files. +func (r *rotatingFile) Close() error { + r.mu.Lock() + + var err error + + if r.file != nil { + err = r.file.Close() + r.file = nil + } + + r.mu.Unlock() + + r.cleanup.Wait() + + return err +} + +// rotate renames the log file to a backup and opens a new one. r.mu must be held. +func (r *rotatingFile) rotate() error { + backup := r.backupName() + + if err := r.rename(r.path, backup); err != nil { + // The file was moved or deleted: there is nothing to rename, so + // start a new file at the path. + if errors.Is(err, os.ErrNotExist) { + err = r.reopen() + if err == nil { + return nil + } + } + + r.retryAfter = r.now().Add(r.retryInterval) + + return fmt.Errorf("failed to rename log file: %w", err) + } + + file, size, err := openLogFileWithSize(r.path) + if err != nil { + // Keep the current file under its name and keep writing to it. + _ = r.rename(backup, r.path) + r.retryAfter = r.now().Add(r.retryInterval) + + return fmt.Errorf("failed to open new log file: %w", err) + } + + _ = r.file.Close() + r.file, r.size = file, size + r.retryAfter = time.Time{} + + r.cleanup.Go(func() { + r.cleanupMu.Lock() + defer r.cleanupMu.Unlock() + + r.cleanupBackups(backup) + }) + + return nil +} + +// reopen opens the file at the path and writes to it from now on. r.mu must be held. +func (r *rotatingFile) reopen() error { + file, size, err := openLogFileWithSize(r.path) + if err != nil { + return err + } + + _ = r.file.Close() + r.file, r.size = file, size + r.retryAfter = time.Time{} + + return nil +} + +// backupName returns - next to the log file, with a +// counter before the extension if that name, or its compressed version, exists. +func (r *rotatingFile) backupName() string { + dir, prefix, ext := r.nameParts() + stamp := r.now().UTC().Format(backupTimeFormat) + + for i := 0; ; i++ { + name := prefix + stamp + ext + if i > 0 { + name = prefix + stamp + "-" + strconv.Itoa(i) + ext + } + + path := filepath.Join(dir, name) + + if !exists(path) && !exists(path+gzipSuffix) { + return path + } + } +} + +// nameParts returns the directory of the log file, the prefix of its backups +// (name without extension and "-") and its extension. +func (r *rotatingFile) nameParts() (string, string, string) { + base := filepath.Base(r.path) + ext := filepath.Ext(base) + + return filepath.Dir(r.path), strings.TrimSuffix(base, ext) + "-", ext +} + +func exists(path string) bool { + _, err := os.Lstat(path) + + return err == nil +} + +// cleanupBackups compresses the new backup and removes the oldest backups +// beyond maxBackups. Errors only leave files behind, so they are ignored. +func (r *rotatingFile) cleanupBackups(backup string) { + if r.compress { + _ = compressFile(backup) + } + + if r.maxBackups <= 0 { + return + } + + backups := r.backups() + if len(backups) <= r.maxBackups { + return + } + + for _, old := range backups[:len(backups)-r.maxBackups] { + _ = os.Remove(old.path) + } +} + +type backupFile struct { + path string + time time.Time + counter int +} + +// backups returns the rotated files of the log file, oldest first. Files that +// don't match the backup name exactly are ignored. +func (r *rotatingFile) backups() []backupFile { + dir, prefix, ext := r.nameParts() + + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + var backups []backupFile + + for _, entry := range entries { + name, ok := strings.CutPrefix(entry.Name(), prefix) + if !ok || entry.IsDir() { + continue + } + + name = strings.TrimSuffix(name, gzipSuffix) + + name, ok = strings.CutSuffix(name, ext) + if !ok || len(name) < len(backupTimeFormat) { + continue + } + + stamp, counter := name[:len(backupTimeFormat)], 0 + + if rest := name[len(backupTimeFormat):]; rest != "" { + number, ok := strings.CutPrefix(rest, "-") + if !ok { + continue + } + + if counter, err = strconv.Atoi(number); err != nil { + continue + } + } + + t, err := time.Parse(backupTimeFormat, stamp) + if err != nil { + continue + } + + backups = append(backups, backupFile{path: filepath.Join(dir, entry.Name()), time: t, counter: counter}) + } + + slices.SortFunc(backups, func(a, b backupFile) int { + if c := a.time.Compare(b.time); c != 0 { + return c + } + + return a.counter - b.counter + }) + + return backups +} + +// compressFile replaces path with path.gz. +func compressFile(path string) (err error) { + src, err := os.Open(path) + if err != nil { + return err + } + + defer func() { _ = src.Close() }() + + tmp := path + gzipSuffix + ".tmp" + + dst, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640) + if err != nil { + return err + } + + defer func() { + if err != nil { + _ = dst.Close() + _ = os.Remove(tmp) + } + }() + + writer := gzip.NewWriter(dst) + + if _, err = io.Copy(writer, src); err != nil { + return err + } + + if err = writer.Close(); err != nil { + return err + } + + if err = dst.Close(); err != nil { + return err + } + + _ = src.Close() + + if err = os.Rename(tmp, path+gzipSuffix); err != nil { + return err + } + + return os.Remove(path) +} diff --git a/internal/log/rotate_other.go b/internal/log/rotate_other.go new file mode 100644 index 00000000..31506d35 --- /dev/null +++ b/internal/log/rotate_other.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package log + +import "os" + +// openLogFile opens path for appending. The exporter only runs on Windows; +// this keeps the rotation testable on other platforms. +func openLogFile(path string) (*os.File, error) { + return os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o640) +} + +// sameFile reports whether path still refers to file. +func sameFile(file *os.File, path string) (bool, error) { + fileInfo, err := file.Stat() + if err != nil { + return false, err + } + + pathInfo, err := os.Stat(path) + if err != nil { + return false, err + } + + return os.SameFile(fileInfo, pathInfo), nil +} diff --git a/internal/log/rotate_test.go b/internal/log/rotate_test.go new file mode 100644 index 00000000..d4909f6f --- /dev/null +++ b/internal/log/rotate_test.go @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "compress/gzip" + "errors" + "io" + "math" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// testRotatingFile returns a rotating file with a small size limit and a +// clock that only moves when the test moves it. +func testRotatingFile(t *testing.T, maxSize int64, maxBackups int, compress bool) (*rotatingFile, string, *time.Time) { + t.Helper() + + path := filepath.Join(t.TempDir(), "windows_exporter.log") + now := time.Date(2026, 10, 15, 14, 3, 5, 0, time.UTC) + + file, err := newRotatingFile(path, maxSize, maxBackups, compress, func() time.Time { return now }) + require.NoError(t, err) + + t.Cleanup(func() { _ = file.Close() }) + + return file, path, &now +} + +func write(t *testing.T, w io.Writer, line string) { + t.Helper() + + n, err := w.Write([]byte(line)) + require.NoError(t, err) + require.Equal(t, len(line), n) +} + +func dirEntries(t *testing.T, dir string) []string { + t.Helper() + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + + slices.Sort(names) + + return names +} + +func readFile(t *testing.T, path string) string { + t.Helper() + + data, err := os.ReadFile(path) + require.NoError(t, err) + + return string(data) +} + +func TestRotatingFileRotatesBeforeExceedingMaxSize(t *testing.T) { + t.Parallel() + + file, path, now := testRotatingFile(t, 10, 0, false) + + write(t, file, "12345\n") + write(t, file, "1234\n") // 11 bytes would exceed 10: rotate first + + *now = now.Add(time.Second) + + write(t, file, "abcdefghijklmn\n") // longer than max-size on its own: rotate, then write it whole + + require.NoError(t, file.Close()) + + require.Equal(t, []string{ + "windows_exporter-2026-10-15T14-03-05.000.log", + "windows_exporter-2026-10-15T14-03-06.000.log", + "windows_exporter.log", + }, dirEntries(t, filepath.Dir(path))) + + require.Equal(t, "12345\n", readFile(t, filepath.Join(filepath.Dir(path), "windows_exporter-2026-10-15T14-03-05.000.log"))) + require.Equal(t, "1234\n", readFile(t, filepath.Join(filepath.Dir(path), "windows_exporter-2026-10-15T14-03-06.000.log"))) + require.Equal(t, "abcdefghijklmn\n", readFile(t, path)) +} + +func TestRotatingFileKeepsWritingWhenRenameFails(t *testing.T) { + t.Parallel() + + file, path, now := testRotatingFile(t, 10, 0, false) + + errBlocked := errors.New("file is in use") + renames := 0 + file.rename = func(oldPath, newPath string) error { + renames++ + + if renames == 1 { + return errBlocked + } + + return os.Rename(oldPath, newPath) + } + + write(t, file, "1234567890") + write(t, file, "after a failed rotation\n") // the rename fails: the line goes to the current file + require.Equal(t, 1, renames) + + write(t, file, "still no retry\n") // within the retry interval: no new attempt + require.Equal(t, 1, renames) + + *now = now.Add(file.retryInterval) + write(t, file, "rotated\n") // the retry succeeds + require.Equal(t, 2, renames) + + require.NoError(t, file.Close()) + + backup := filepath.Join(filepath.Dir(path), "windows_exporter-2026-10-15T14-04-05.000.log") + require.Equal(t, "1234567890after a failed rotation\nstill no retry\n", readFile(t, backup), "no line may be lost") + require.Equal(t, "rotated\n", readFile(t, path)) +} + +func TestRotatingFileStartsWhenInitialRotationFails(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "windows_exporter.log") + require.NoError(t, os.WriteFile(path, []byte("an old log that is already too large\n"), 0o600)) + + now := time.Date(2026, 10, 15, 14, 3, 5, 0, time.UTC) + + // Rotating the oversized file on startup works. + file, err := newRotatingFile(path, 10, 0, false, func() time.Time { return now }) + require.NoError(t, err) + write(t, file, "new\n") + require.NoError(t, file.Close()) + require.Equal(t, "new\n", readFile(t, path)) + + // If it fails, the logger still starts and appends. + require.NoError(t, os.WriteFile(path, []byte("an old log that is already too large\n"), 0o600)) + + file, err = newRotatingFileWithRename(path, 10, 0, false, func() time.Time { return now }, func(string, string) error { + return errors.New("file is in use") + }) + require.NoError(t, err) + write(t, file, "appended\n") + require.NoError(t, file.Close()) + require.Equal(t, "an old log that is already too large\nappended\n", readFile(t, path)) +} + +func TestRotatingFileMaxBackups(t *testing.T) { + t.Parallel() + + file, path, now := testRotatingFile(t, 4, 2, false) + + // A file that only looks similar is never removed. + other := filepath.Join(filepath.Dir(path), "windows_exporter-notes.log") + require.NoError(t, os.WriteFile(other, []byte("keep"), 0o600)) + + for i := range 5 { + *now = now.Add(time.Second) + + write(t, file, strings.Repeat(string(rune('a'+i)), 4)) + } + + require.NoError(t, file.Close()) + + require.Equal(t, []string{ + "windows_exporter-2026-10-15T14-03-09.000.log", + "windows_exporter-2026-10-15T14-03-10.000.log", + "windows_exporter-notes.log", + "windows_exporter.log", + }, dirEntries(t, filepath.Dir(path))) +} + +func TestRotatingFileCompress(t *testing.T) { + t.Parallel() + + file, path, now := testRotatingFile(t, 4, 0, true) + + write(t, file, "abcd") + + *now = now.Add(time.Second) + + write(t, file, "efgh") + + require.NoError(t, file.Close()) + + require.Equal(t, []string{ + "windows_exporter-2026-10-15T14-03-06.000.log.gz", + "windows_exporter.log", + }, dirEntries(t, filepath.Dir(path))) + + compressed, err := os.Open(filepath.Join(filepath.Dir(path), "windows_exporter-2026-10-15T14-03-06.000.log.gz")) + require.NoError(t, err) + + defer compressed.Close() + + reader, err := gzip.NewReader(compressed) + require.NoError(t, err) + + data, err := io.ReadAll(reader) + require.NoError(t, err) + require.Equal(t, "abcd", string(data)) +} + +func TestRotatingFileBackupNamesAreUnique(t *testing.T) { + t.Parallel() + + // The clock doesn't move, so every rotation has the same timestamp. + file, path, _ := testRotatingFile(t, 4, 0, false) + + write(t, file, "aaaa") + write(t, file, "bbbb") + write(t, file, "cccc") + + require.NoError(t, file.Close()) + + require.Equal(t, []string{ + "windows_exporter-2026-10-15T14-03-05.000-1.log", + "windows_exporter-2026-10-15T14-03-05.000.log", + "windows_exporter.log", + }, dirEntries(t, filepath.Dir(path))) + require.Equal(t, "aaaa", readFile(t, filepath.Join(filepath.Dir(path), "windows_exporter-2026-10-15T14-03-05.000.log"))) + require.Equal(t, "bbbb", readFile(t, filepath.Join(filepath.Dir(path), "windows_exporter-2026-10-15T14-03-05.000-1.log"))) +} + +func TestRotatingFileWriteAfterClose(t *testing.T) { + t.Parallel() + + file, _, _ := testRotatingFile(t, 10, 0, false) + require.NoError(t, file.Close()) + + _, err := file.Write([]byte("late\n")) + require.ErrorIs(t, err, os.ErrClosed) + require.NoError(t, file.Close(), "a second Close is a no-op") +} + +func TestRotatingFileRecreatesMovedFileOnRotation(t *testing.T) { + t.Parallel() + + file, path, _ := testRotatingFile(t, 100, 0, false) + + write(t, file, strings.Repeat("a", 59)+"\n") + + moved := filepath.Join(filepath.Dir(path), "moved.log") + require.NoError(t, os.Rename(path, moved)) + + // The rotation finds no file to rename and starts a new one at the path. + write(t, file, strings.Repeat("b", 59)+"\n") + require.NoError(t, file.Close()) + + require.Equal(t, strings.Repeat("a", 59)+"\n", readFile(t, moved)) + require.Equal(t, strings.Repeat("b", 59)+"\n", readFile(t, path)) +} + +func TestRotatingFileRecreatesMovedOrDeletedFile(t *testing.T) { + t.Parallel() + + for name, remove := range map[string]func(path string) error{ + "moved": func(path string) error { return os.Rename(path, path+".moved") }, + "deleted": os.Remove, + "replaced": func(path string) error { + return errors.Join(os.Rename(path, path+".old"), os.WriteFile(path, []byte("other\n"), 0o600)) + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + // Far below max-size: no rotation is involved. + file, path, now := testRotatingFile(t, 1<<20, 0, false) + + write(t, file, "before\n") + require.NoError(t, remove(path)) + + // Until the next check, lines go to the old file. + write(t, file, "until the check\n") + + *now = now.Add(file.retryInterval) + + write(t, file, "after\n") + require.NoError(t, file.Close()) + + require.True(t, strings.HasSuffix(readFile(t, path), "after\n"), "the path has a new log file") + require.NotContains(t, readFile(t, path), "before", "the new file doesn't continue the old one") + }) + } +} + +func TestRotatingFileKeepsFileAtPath(t *testing.T) { + t.Parallel() + + file, path, now := testRotatingFile(t, 1<<20, 0, false) + + write(t, file, "one\n") + + *now = now.Add(file.retryInterval) + + // The check finds the same file: nothing is reopened. + write(t, file, "two\n") + require.NoError(t, file.Close()) + + require.Equal(t, "one\ntwo\n", readFile(t, path)) + require.Equal(t, []string{"windows_exporter.log"}, dirEntries(t, filepath.Dir(path))) +} + +func TestRotatingFileWithoutSizeLimit(t *testing.T) { + t.Parallel() + + // A plain log file: no size limit, never rotated. + file, path, now := testRotatingFile(t, math.MaxInt64, 0, false) + + write(t, file, strings.Repeat("a", 1000)) + *now = now.Add(file.retryInterval) + + write(t, file, strings.Repeat("b", 1000)) + require.NoError(t, file.Close()) + + require.Equal(t, []string{"windows_exporter.log"}, dirEntries(t, filepath.Dir(path))) + require.Len(t, readFile(t, path), 2000) +} diff --git a/internal/log/rotate_windows.go b/internal/log/rotate_windows.go new file mode 100644 index 00000000..45c31e05 --- /dev/null +++ b/internal/log/rotate_windows.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package log + +import ( + "os" + + "golang.org/x/sys/windows" +) + +// openLogFile opens path for appending. Unlike os.OpenFile, it allows other +// processes to read, write, rename and delete the file while it is open +// (FILE_SHARE_DELETE), so the file can be rotated without closing it first and +// admins can move or delete it while the service runs. +func openLogFile(path string) (*os.File, error) { + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + + handle, err := windows.CreateFile( + pathPtr, + windows.FILE_APPEND_DATA|windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_ALWAYS, + windows.FILE_ATTRIBUTE_NORMAL, + 0, + ) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + + return os.NewFile(uintptr(handle), path), nil +} + +// sameFile reports whether path still refers to file, by volume serial number +// and file index. +func sameFile(file *os.File, path string) (bool, error) { + var fileInfo windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(windows.Handle(file.Fd()), &fileInfo); err != nil { + return false, err + } + + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return false, err + } + + // No access rights are needed to read the file index; backup semantics + // would also open a directory, which then doesn't match. + handle, err := windows.CreateFile( + pathPtr, + 0, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS, + 0, + ) + if err != nil { + return false, err + } + + defer func() { _ = windows.CloseHandle(handle) }() + + var pathInfo windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &pathInfo); err != nil { + return false, err + } + + return fileInfo.VolumeSerialNumber == pathInfo.VolumeSerialNumber && + fileInfo.FileIndexHigh == pathInfo.FileIndexHigh && + fileInfo.FileIndexLow == pathInfo.FileIndexLow, nil +} diff --git a/internal/log/rotate_windows_test.go b/internal/log/rotate_windows_test.go new file mode 100644 index 00000000..cdd399b0 --- /dev/null +++ b/internal/log/rotate_windows_test.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package log + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// openWithoutDeleteSharing opens path like a log shipper that doesn't allow +// delete sharing, which blocks renaming the file. +func openWithoutDeleteSharing(t *testing.T, path string) windows.Handle { + t.Helper() + + pathPtr, err := windows.UTF16PtrFromString(path) + require.NoError(t, err) + + handle, err := windows.CreateFile(pathPtr, windows.GENERIC_READ, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + require.NoError(t, err) + + return handle +} + +func TestRotatingFileBlockedByOtherProcess(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "windows_exporter.log") + now := time.Date(2026, 10, 15, 14, 3, 5, 0, time.UTC) + + file, err := newRotatingFile(path, 10, 0, false, func() time.Time { return now }) + require.NoError(t, err) + + t.Cleanup(func() { _ = file.Close() }) + + write(t, file, "1234567890") + + blocker := openWithoutDeleteSharing(t, path) + + // The rename fails while the file is held: the line is appended instead. + write(t, file, "while blocked\n") + require.Equal(t, []string{"windows_exporter.log"}, dirEntries(t, filepath.Dir(path))) + + require.NoError(t, windows.CloseHandle(blocker)) + + now = now.Add(file.retryInterval) + write(t, file, "after release\n") + require.NoError(t, file.Close()) + + require.Equal(t, "1234567890while blocked\n", readFile(t, filepath.Join(filepath.Dir(path), "windows_exporter-2026-10-15T14-04-05.000.log"))) + require.Equal(t, "after release\n", readFile(t, path)) +} + +func TestOpenLogFileAllowsRenameAndDelete(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "windows_exporter.log") + + file, err := openLogFile(path) + require.NoError(t, err) + + t.Cleanup(func() { _ = file.Close() }) + + _, err = file.WriteString("first\n") + require.NoError(t, err) + + // An admin can move the file while the exporter writes to it. + moved := filepath.Join(dir, "moved.log") + require.NoError(t, os.Rename(path, moved)) + + _, err = file.WriteString("second\n") + require.NoError(t, err) + require.Equal(t, "first\nsecond\n", readFile(t, moved)) + + // And delete it. + require.NoError(t, os.Remove(moved)) +} + +func TestOpenLogFileAppends(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "windows_exporter.log") + require.NoError(t, os.WriteFile(path, []byte("existing\n"), 0o600)) + + file, err := openLogFile(path) + require.NoError(t, err) + + _, err = file.WriteString("appended\n") + require.NoError(t, err) + require.NoError(t, file.Close()) + + require.Equal(t, "existing\nappended\n", readFile(t, path)) +} + +func TestRotatingFileRecoversWhenMovedOrDeleted(t *testing.T) { + t.Parallel() + + for name, remove := range map[string]func(path string) error{ + "moved": func(path string) error { return os.Rename(path, path+".moved") }, + "deleted": os.Remove, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "windows_exporter.log") + now := time.Date(2026, 10, 15, 14, 3, 5, 0, time.UTC) + + file, err := newRotatingFile(path, 1<<20, 0, false, func() time.Time { return now }) + require.NoError(t, err) + + t.Cleanup(func() { _ = file.Close() }) + + write(t, file, "before\n") + + // Share-delete lets an admin move or delete the file while it is open. + require.NoError(t, remove(path)) + + now = now.Add(file.retryInterval) + + write(t, file, "after\n") + require.NoError(t, file.Close()) + + require.Equal(t, "after\n", readFile(t, path)) + }) + } +} + +func TestSameFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "windows_exporter.log") + + file, err := openLogFile(path) + require.NoError(t, err) + + t.Cleanup(func() { _ = file.Close() }) + + same, err := sameFile(file, path) + require.NoError(t, err) + require.True(t, same) + + require.NoError(t, os.Rename(path, filepath.Join(dir, "moved.log"))) + require.NoError(t, os.WriteFile(path, []byte("other"), 0o600)) + + same, err = sameFile(file, path) + require.NoError(t, err) + require.False(t, same, "a new file at the path is not the open file") +}