mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-17 10:09:05 +02:00
log: add size-based log file rotation (#2682)
Signed-off-by: EisenbergD <dominik.eisenberg@beiersdorf.com> Co-authored-by: EisenbergD <dominik.eisenberg@beiersdorf.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
EisenbergD
Claude Opus 5.5
parent
6e242d7af2
commit
5a7f8b5bd3
@@ -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, \<path to log file>]<br>**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
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+80
-11
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <name>-<UTC time><ext> 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user