diff --git a/staging/src/k8s.io/component-base/logs/kube-log-runner/README.md b/staging/src/k8s.io/component-base/logs/kube-log-runner/README.md index eed219fbbaf..0a46053d219 100644 --- a/staging/src/k8s.io/component-base/logs/kube-log-runner/README.md +++ b/staging/src/k8s.io/component-base/logs/kube-log-runner/README.md @@ -15,6 +15,66 @@ Why do we need this? - Nowadays, the `--log-file` parameter is deprecated for Kubernetes components and should not be used anymore. `kube-log-runner` is a direct replacement. +## Flags + +- -flush-interval + The `-flush-interval` flag is a duration flag that specifies how frequently the log + file content is flushed to disk. The default value is 0, meaning no periodic flushing. + If set to a non-zero value, the log file is flushed at the specified interval. + + Type: Duration + + Default: 0 (flushing disabled) + + Usage: When set to non-zero, the log file is flushed every specified interval. + While this may not be necessary on Linux, on Windows, it ensures that recent log + entries are written to disk in near real-time, which is particularly useful for + users who need to monitor logs as they are generated without delay. + +- -log-file-size + The `-log-file-size` flag is an optional string flag that sets a size limit for + the log file, triggering automatic log rotation when the specified size is reached. + This is especially useful in production environments where the log file may become + too large to view effectively. + Beware that rotation can happen at arbitrary points in the byte stream emitted by the command. + This can lead to splitting a log entry into two parts, with the first part in one file + and the second part in the next file. + + Type: String (expects a value in Resource.Quantity format, such as 10M or 500K) + + Default: "0" (disabled, no automatic rotation of log files) + + Usage: When set to a positive value, the log file will rotate upon reaching the specified + size limit. The current log file’s contents will be saved to a backup file, and a new log + file will be created at the path specified by the `-log-file` flag, ready for future log entries. + + Backup File Naming Convention: + `-`. + * ``: The name of the original log file, without the file extension. + * ``: A timestamp is added to each backup file’s name to uniquely identify it + based on the time it was created. The timestamp follows the format "20060102-150405". + For example, a backup created on June 2, 2006, at 3:04:05 PM would include this timestamp. + * ``: The original file’s extension (e.g., .log) remains unchanged. + This naming convention ensures easy organization and retrieval of rotated log files based on their creation time. + +- -log-file-age + The `-log-file-age` flag is an optional time duration setting that defines how long + old backup log files are retained. This flag is used alongside log rotation (enabled + by setting a positive value for -log-file-size) to help manage storage by removing + outdated backup logs. + + Type: Duration + + Default: 0 (disabled, no automatic deletion of backup files) + + Usage: When -log-file-age is set to a positive duration (e.g., 24h for 24 hours) + and log rotation is enabled, backup log files will be automatically deleted if + the time when they were created (as encoded in the file name) is older than the + specified duration from the current time. + + This ensures that only recent backup logs are kept, preventing accumulation of old logs + and reducing storage usage. + For example instead of running kube-apiserver like this: ```bash "/bin/sh", @@ -49,6 +109,17 @@ kube-log-runner -log-file=/tmp/log echo "hello world" # Copy into log file and print to stdout (same as 2>&1 | tee -a /tmp/log). kube-log-runner -log-file=/tmp/log -also-stdout echo "hello world" +# Copy into log file and print to stdout (same as 2>&1 | tee -a /tmp/log), +# will flush the logging file in 5s, +# rotate the log file when its size exceedes 10 MB +kube-log-runner -flush-interval=5s -log-file=/tmp/log -log-file-size=10M -also-stdout echo "hello world" + +# Copy into log file and print to stdout (same as 2>&1 | tee -a /tmp/log), +# will flush the logging file in 10s, +# rotate the log file when its size exceedes 10 MB, +# and clean up old rotated log files when their age are older than 168h (7 days) +kube-log-runner -flush-interval=10s -log-file=/tmp/log -log-file-size=10M -log-file-age=168h -also-stdout echo "hello world" + # Redirect only stdout into log file (same as 1>>/tmp/log). kube-log-runner -log-file=/tmp/log -redirect-stderr=false echo "hello world" ``` diff --git a/staging/src/k8s.io/component-base/logs/kube-log-runner/internal/logrotation/logrotation.go b/staging/src/k8s.io/component-base/logs/kube-log-runner/internal/logrotation/logrotation.go new file mode 100644 index 00000000000..3204764aaa9 --- /dev/null +++ b/staging/src/k8s.io/component-base/logs/kube-log-runner/internal/logrotation/logrotation.go @@ -0,0 +1,205 @@ +/* +Copyright 2025 The Kubernetes 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 logrotation + +import ( + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const timeLayout string = "20060102-150405" + +type rotationFile struct { + // required, the max size of the log file in bytes, 0 means no rotation + maxSize int64 + // required, the max age of the log file, 0 means no cleanup + maxAge time.Duration + filePath string + mut sync.Mutex + file *os.File + currentSize int64 + lasSyncTime time.Time + flushInterval time.Duration +} + +func Open(filePath string, flushInterval time.Duration, maxSize int64, maxAge time.Duration) (io.WriteCloser, error) { + w := &rotationFile{ + filePath: filePath, + maxSize: maxSize, + maxAge: maxAge, + flushInterval: flushInterval, + } + + logFile, err := os.OpenFile(w.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + + w.file = logFile + + w.lasSyncTime = time.Now() + + if w.maxSize > 0 { + info, err := os.Stat(w.filePath) + if err != nil { + return nil, err + } + w.currentSize = info.Size() + } + + return w, nil +} + +// Write implements the io.Writer interface. +func (w *rotationFile) Write(p []byte) (n int, err error) { + w.mut.Lock() + defer w.mut.Unlock() + + n, err = w.file.Write(p) + if err != nil { + return 0, err + } + + if w.flushInterval > 0 && time.Since(w.lasSyncTime) >= w.flushInterval { + err = w.file.Sync() + if err != nil { + return 0, err + } + w.lasSyncTime = time.Now() + } + + if w.maxSize > 0 { + w.currentSize += int64(len(p)) + + // if file size over maxsize rotate the log file + if w.currentSize >= w.maxSize { + err = w.rotate() + if err != nil { + return 0, err + } + } + } + + return n, nil +} + +func (w *rotationFile) rotate() error { + // Get the file extension + ext := filepath.Ext(w.filePath) + + // Remove the extension from the filename + pathWithoutExt := strings.TrimSuffix(w.filePath, ext) + + rotateFilePath := pathWithoutExt + "-" + time.Now().Format(timeLayout) + ext + + if w.filePath == rotateFilePath { + return nil + } + + err := w.file.Close() + if err != nil { + return err + } + + err = os.Rename(w.filePath, rotateFilePath) + if err != nil { + return err + } + + w.file, err = os.OpenFile(w.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + + w.currentSize = 0 + + if w.maxAge > 0 { + go func() { + err = w.clean(pathWithoutExt, ext) + }() + } + + return nil +} + +// Clean up the old log files in the format of +// -. +// This should be safe enough to avoid false deletion. +// This will work for multiple restarts of the same program. +func (w *rotationFile) clean(pathWithoutExt string, ext string) error { + ageTime := time.Now().Add(-w.maxAge) + + directory := filepath.Dir(pathWithoutExt) + basename := filepath.Base(pathWithoutExt) + "-" + + dir, err := os.ReadDir(directory) + if err != nil { + return err + } + + err = nil + for _, v := range dir { + if strings.HasPrefix(v.Name(), basename) && strings.HasSuffix(v.Name(), ext) { + // Remove the prefix and suffix + trimmed := strings.TrimPrefix(v.Name(), basename) + trimmed = strings.TrimSuffix(trimmed, ext) + + _, err = time.Parse(timeLayout, trimmed) + if err == nil { + info, errInfo := v.Info() + if errInfo != nil { + err = errInfo + // Ignore the error while continue with the next clenup + continue + } + + if ageTime.After(info.ModTime()) { + err = os.Remove(filepath.Join(directory, v.Name())) + if err != nil { + // Ignore the error while continue with the next clenup + continue + } + } + } + + } + } + + return err +} + +func (w *rotationFile) Close() error { + w.mut.Lock() + defer w.mut.Unlock() + + // Explicitly call file.Sync() to ensure data is written to disk + err := w.file.Sync() + if err != nil { + return err + } + + err = w.file.Close() + if err != nil { + return err + } + + return nil +} diff --git a/staging/src/k8s.io/component-base/logs/kube-log-runner/internal/logrotation/logrotation_test.go b/staging/src/k8s.io/component-base/logs/kube-log-runner/internal/logrotation/logrotation_test.go new file mode 100644 index 00000000000..0b9d0b80441 --- /dev/null +++ b/staging/src/k8s.io/component-base/logs/kube-log-runner/internal/logrotation/logrotation_test.go @@ -0,0 +1,238 @@ +/* +Copyright 2025 The Kubernetes 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 logrotation + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLogrotationWrite(t *testing.T) { + tests := []struct { + name string + flushInterval time.Duration + maxSize int64 + maxAge time.Duration + rotationExpected bool + cleanupExpected bool + }{ + { + name: "no rotation", + flushInterval: 0, + maxSize: 0, + maxAge: 10 * time.Hour, + rotationExpected: false, + cleanupExpected: false, + }, + { + name: "Rotation with flushInterval, maxSize, no cleanup", + flushInterval: 5 * time.Second, + maxSize: int64(1024 * 1024), + maxAge: time.Duration(0), + rotationExpected: true, + cleanupExpected: false, + }, + { + name: "Rotation with maxSize and cleanup with maxAge", + flushInterval: 0, + maxSize: int64(1024 * 1024), + maxAge: 24 * time.Hour, + rotationExpected: true, + cleanupExpected: true, + }, + { + name: "Rotation with flushInterval, maxSize and cleanup with maxAge", + flushInterval: 10 * time.Second, + maxSize: int64(1024 * 1024), + maxAge: 24 * time.Hour, + rotationExpected: true, + cleanupExpected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "logrotation_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err = os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp directory: %v", err) + } + }) + + logFilePath := filepath.Join(tmpDir, "test.log") + flushInterval := tt.flushInterval + maxSize := tt.maxSize + maxAge := tt.maxAge + + rotationFile, err := Open(logFilePath, flushInterval, maxSize, maxAge) + if err != nil { + t.Fatalf("Failed to open RotationFile: %v", err) + } + + t.Cleanup(func() { + if err := rotationFile.Close(); err != nil { + t.Errorf("Failed to close rotationFile: %v", err) + } + }) + + testData := []byte("This is a test log entry.") + n, err := rotationFile.Write(testData) + if err != nil { + t.Fatalf("Failed to write to RotationFile: %v", err) + } + if n != len(testData) { + t.Errorf("Expected to write %d bytes, but wrote %d bytes", len(testData), n) + } + + // Check if data is written to the file + content, err := os.ReadFile(logFilePath) + if err != nil { + t.Fatalf("Failed to read log file: %v", err) + } + if string(content) != string(testData) { + t.Errorf("Expected log file content to be %q, but got %q", string(testData), string(content)) + } + + // Write more data to trigger rotation + largeData := make([]byte, 1024*1024) // 1 MB + n, err = rotationFile.Write(largeData) + if err != nil { + t.Fatalf("Failed to write large data to RotationFile: %v", err) + } + if n != len(largeData) { + t.Errorf("Expected to write %d bytes, but wrote %d bytes", len(largeData), n) + } + + // Check if rotation happened + rotatedFiles, err := filepath.Glob(filepath.Join(tmpDir, "test-*.log")) + if err != nil { + t.Fatalf("Failed to list rotated files: %v", err) + } + + if tt.rotationExpected { + numberFiles := 1 + if len(rotatedFiles) != numberFiles { + t.Errorf("Expected %d rotated log files, but found %d", numberFiles, len(rotatedFiles)) + } + + // Check if new log file is created + newContent, err := os.ReadFile(logFilePath) + if err != nil { + t.Fatalf("Failed to read new log file: %v", err) + } + // Rotation after the write, new log file should be empty + if len(newContent) != 0 { + t.Errorf("Expected new log file content to be 0 bytes, but got %d bytes", len(newContent)) + } + + // Check if content is append to the rotated log file + content, err := os.ReadFile(rotatedFiles[0]) + if err != nil { + t.Fatalf("Failed to read log file: %v", err) + } + if len(content) != len(testData)+len(largeData) { + t.Errorf("Expected log file content to be %d bytes, but got %d bytes", len(testData)+len(largeData), len(content)) + } + } else { + numberFiles := 0 + if len(rotatedFiles) != numberFiles { + t.Errorf("Expected %d rotated log files, but found %d", numberFiles, len(rotatedFiles)) + } + + // Check if content is append to the existing log file + content, err := os.ReadFile(logFilePath) + if err != nil { + t.Fatalf("Failed to read log file: %v", err) + } + if len(content) != len(testData)+len(largeData) { + t.Errorf("Expected log file content to be %d bytes, but got %d bytes", len(testData)+len(largeData), len(content)) + } + } + + // Check if rotated file was able to be cleaned up + if len(rotatedFiles) != 0 { + err = os.Chtimes(rotatedFiles[0], time.Now(), time.Now().AddDate(0, 0, -10)) + if err != nil { + t.Fatalf("Failed to change access time of rotated file: %v", err) + } + } + + // Trigger another rotation and check if the old rotated file has been cleaned up + time.Sleep(1 * time.Second) + largeData2 := make([]byte, 1024*1025) + _, err = rotationFile.Write(largeData2) + if err != nil { + t.Fatalf("Failed to write to RotationFile: %v", err) + } + rotatedFiles2, err := filepath.Glob(filepath.Join(tmpDir, "test-*.log")) + if err != nil { + t.Fatalf("Failed to list rotated files: %v", err) + } + + // Read the content of the log file + content, err = os.ReadFile(logFilePath) + if err != nil { + t.Fatalf("Failed to read log file: %v", err) + } + + if !tt.rotationExpected { + if len(rotatedFiles2) != 0 { + t.Errorf("Expected rotated log files to be 0, but found %d", len(rotatedFiles)) + } + if len(content) != len(testData)+len(largeData)+len(largeData2) { + t.Errorf("Expected log file content to be %d bytes, but got %d bytes", len(testData)+len(largeData)+len(largeData2), len(content)) + } + } else { + if tt.cleanupExpected { + // clean up is performed in a async goroutine, wait for it to finish + end := time.Now().Add(5 * time.Second) + for time.Now().Before(end) { + rotatedFiles2, err = filepath.Glob(filepath.Join(tmpDir, "test-*.log")) + if err != nil { + t.Fatalf("Failed to list rotated files: %v", err) + } + if len(rotatedFiles2) == 1 { + break + } + time.Sleep(10 * time.Millisecond) + } + rotatedFiles2, err = filepath.Glob(filepath.Join(tmpDir, "test-*.log")) + if err != nil { + t.Fatalf("Failed to list rotated files: %v", err) + } + if len(rotatedFiles2) != 1 { + t.Errorf("Expected rotated log files to be 1, but found %d", len(rotatedFiles2)) + } + if rotatedFiles[0] == rotatedFiles2[0] { + t.Errorf("Expected rotated log files to be different, but found same") + } + } else if len(rotatedFiles2) != 2 { + t.Errorf("Expected rotated log files to be 2, but found %d", len(rotatedFiles2)) + } + // Rotation after the write, new log file should be empty + if len(content) != 0 { + t.Errorf("Expected new log file content to be 0 bytes, but got %d bytes", len(content)) + } + } + }) + } +} diff --git a/staging/src/k8s.io/component-base/logs/kube-log-runner/internal/logrotation/logrotationstub.go b/staging/src/k8s.io/component-base/logs/kube-log-runner/internal/logrotation/logrotationstub.go new file mode 100644 index 00000000000..5f4630a69af --- /dev/null +++ b/staging/src/k8s.io/component-base/logs/kube-log-runner/internal/logrotation/logrotationstub.go @@ -0,0 +1,40 @@ +/* +Copyright 2025 The Kubernetes 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 logrotation + +import ( + "fmt" + "io" + "time" +) + +type RotationStub struct{} + +func OpenStub(filePath string, flushInterval time.Duration, maxSize int64, maxAge time.Duration) (io.WriteCloser, error) { + w := &RotationStub{} + fmt.Printf("filePath: %s, flushInterval: %s, maxSize: %d, maxAge: %s", filePath, flushInterval, maxSize, maxAge) + return w, nil +} + +// write func to satisfy io.writer interface +func (w *RotationStub) Write(p []byte) (int, error) { + return len(p), nil +} + +func (w *RotationStub) Close() error { + return nil +} diff --git a/staging/src/k8s.io/component-base/logs/kube-log-runner/kube-log-runner.go b/staging/src/k8s.io/component-base/logs/kube-log-runner/kube-log-runner.go index 2409db2b759..76380343ec1 100644 --- a/staging/src/k8s.io/component-base/logs/kube-log-runner/kube-log-runner.go +++ b/staging/src/k8s.io/component-base/logs/kube-log-runner/kube-log-runner.go @@ -26,23 +26,42 @@ import ( "os/signal" "strings" "syscall" + "time" + + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/component-base/logs/kube-log-runner/internal/logrotation" ) +type OpenFunc func(filePath string, flushInterval time.Duration, maxSize int64, maxAge time.Duration) (io.WriteCloser, error) + var ( - logFilePath = flag.String("log-file", "", "If non-empty, save stdout to this file") - alsoToStdOut = flag.Bool("also-stdout", false, "useful with log-file, log to standard output as well as the log file") - redirectStderr = flag.Bool("redirect-stderr", true, "treat stderr same as stdout") + flushInterval *time.Duration + logFilePath *string + logFileSize *resource.QuantityValue + logFileAge *time.Duration + alsoToStdOut *bool + redirectStderr *bool ) +func initFlags() { + logFileSize = &resource.QuantityValue{} + flushInterval = flag.Duration("flush-interval", 0, "interval to flush log file to disk, default value 0, if non-zero, flush log file every interval") + logFilePath = flag.String("log-file", "", "If non-empty, save stdout to this file") + flag.Var(logFileSize, "log-file-size", "useful with log-file, in format of resource.quantity, default value 0, if non-zero, rotate log file when it reaches this size") + logFileAge = flag.Duration("log-file-age", 0, "useful with log-file-size, in format of timeDuration, if non-zero, remove log files older than this duration") + alsoToStdOut = flag.Bool("also-stdout", false, "useful with log-file, log to standard output as well as the log file") + redirectStderr = flag.Bool("redirect-stderr", true, "treat stderr same as stdout") +} + func main() { + initFlags() flag.Parse() - if err := configureAndRun(); err != nil { + if err := configureAndRun(logrotation.Open); err != nil { log.Fatal(err) } } - -func configureAndRun() error { +func configureAndRun(open OpenFunc) error { var ( outputStream io.Writer = os.Stdout errStream io.Writer = os.Stderr @@ -53,8 +72,19 @@ func configureAndRun() error { return fmt.Errorf("not enough arguments to run") } + maxSize := logFileSize.Value() + if maxSize < 0 { + return fmt.Errorf("log-file-size must be non-negative quantity") + } + + // Check the time.duration is not negative + if *logFileAge < 0 { + return fmt.Errorf("log-file-age must be non-negative") + } + if logFilePath != nil && *logFilePath != "" { - logFile, err := os.OpenFile(*logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + logFile, err := open(*logFilePath, *flushInterval, maxSize, *logFileAge) + if err != nil { return fmt.Errorf("failed to create log file %v: %w", *logFilePath, err) } @@ -95,10 +125,10 @@ func configureAndRun() error { // cmdInfo generates a useful look at what the command is for printing/debug. func cmdInfo(cmd *exec.Cmd) string { return fmt.Sprintf( - `Command env: (log-file=%v, also-stdout=%v, redirect-stderr=%v) + `Command env: (enable-flush=%v, log-file=%v, log-file-size=%v, log-file-age=%v, also-stdout=%v, redirect-stderr=%v) Run from directory: %v Executable path: %v -Args (comma-delimited): %v`, *logFilePath, *alsoToStdOut, *redirectStderr, +Args (comma-delimited): %v`, *flushInterval, *logFilePath, logFileSize.Value(), *logFileAge, *alsoToStdOut, *redirectStderr, cmd.Dir, cmd.Path, strings.Join(cmd.Args, ","), ) } diff --git a/staging/src/k8s.io/component-base/logs/kube-log-runner/kube-log-runner_test.go b/staging/src/k8s.io/component-base/logs/kube-log-runner/kube-log-runner_test.go new file mode 100644 index 00000000000..2f93a360743 --- /dev/null +++ b/staging/src/k8s.io/component-base/logs/kube-log-runner/kube-log-runner_test.go @@ -0,0 +1,187 @@ +/* +Copyright 2025 The Kubernetes 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 main + +import ( + "bytes" + "flag" + "os" + "testing" + + "k8s.io/component-base/logs/kube-log-runner/internal/logrotation" +) + +// TestConfigureAndRun tests configureAndRun with various flag inputs. +func TestConfigureAndRun(t *testing.T) { + tests := []struct { + name string + commandLine []string + outputWanted string + }{ + { + name: "No_log-file_flag", + commandLine: []string{"echo", "hello"}, + outputWanted: "hello\n", + }, + { + name: "No_log-file_flag_with_extra_other_flags", + commandLine: []string{"-flush-interval=5s", "-log-file-size=1Gi", "-log-file-age=24h30m", "echo", "hello"}, + outputWanted: "hello\n", + }, + { + name: "log-file_flag_only", + commandLine: []string{"-log-file=test.log", "echo", "hello"}, + outputWanted: "filePath: test.log, flushInterval: 0s, maxSize: 0, maxAge: 0s", + }, + { + name: "log-file_flag_with_enable-flush_flag", + commandLine: []string{"-log-file=test.log", "-flush-interval=10s", "echo", "hello"}, + outputWanted: "filePath: test.log, flushInterval: 10s, maxSize: 0, maxAge: 0s", + }, + { + name: "log-file_flag_with_enable-flush_flag_and_log-file-size_flag", + commandLine: []string{"-log-file=test.log", "-flush-interval=1m", "-log-file-size=15M", "echo", "hello"}, + outputWanted: "filePath: test.log, flushInterval: 1m0s, maxSize: 15000000, maxAge: 0s", + }, + { + name: "Invalid_CPU_format_log-file-size_flag", + commandLine: []string{"-log-file=test.log", "-flush-interval=5s", "-log-file-size=125m", "echo", "hello"}, + outputWanted: "filePath: test.log, flushInterval: 5s, maxSize: 1, maxAge: 0s", + }, + { + name: "log-file_flag_with_enable-flush_flag_and_log-file-size_flag_and_log-file-age_flag", + commandLine: []string{"-log-file=test.log", "-flush-interval=5s", "-log-file-size=1Gi", "-log-file-age=24h30m", "echo", "hello"}, + outputWanted: "filePath: test.log, flushInterval: 5s, maxSize: 1073741824, maxAge: 24h30m0s", + }, + { + name: "log-file_flag_with_enable-flush_flag_and_log-file-size_flag_and_log-file-age_flag_and_also-stdout_flag", + commandLine: []string{"-log-file=test.log", "-flush-interval=5s", "-log-file-size=1500", "-log-file-age=24h30m", "-also-stdout", "echo", "hello"}, + outputWanted: "filePath: test.log, flushInterval: 5s, maxSize: 1500, maxAge: 24h30m0shello\n", + }, + { + name: "log-file_flag_with_enable-flush_flag_and_log-file-size_flag_and_log-file-age_flag_and_also-stdout_flag_and_redirect-stderr", + commandLine: []string{"-log-file=test.log", "-flush-interval=5s", "-log-file-size=1500", "-log-file-age=24h30m", "-also-stdout", "-redirect-stderr=false", "echo", "hello"}, + outputWanted: "filePath: test.log, flushInterval: 5s, maxSize: 1500, maxAge: 24h30m0shello\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetFlags() + err := flag.CommandLine.Parse(tt.commandLine) + if err != nil { + t.Fatalf("Failed to parse commandline: %v", err) + return + } + // Capture the log output and compare + originalStdout := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("Failed to create pipe: %v", err) + return + } + defer func() { + err = reader.Close() + if err != nil { + t.Fatalf("Failed to close pipe reader: %v", err) + return + } + }() + + os.Stdout = writer + + var buf bytes.Buffer + + err = configureAndRun(logrotation.OpenStub) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + err = writer.Close() + if err != nil { + t.Fatalf("Failed to close pipe writer: %v", err) + return + } + // <-done + + _, err = buf.ReadFrom(reader) + if err != nil { + t.Fatalf("Failed to read from pipe: %v", err) + return + } + output := buf.String() + if output != tt.outputWanted { + t.Errorf("Expected '%s' but got '%s'", tt.outputWanted, output) + } + + // Restore original os.Stdout + os.Stdout = originalStdout + }) + } +} + +func TestNegativeConfigureAndRun(t *testing.T) { + tests := []struct { + name string + commandLine []string + errorWanted string + }{ + { + name: "Empty_commandline", + commandLine: []string{}, + errorWanted: "not enough arguments to run", + }, + { + name: "Invalid_log-file-size_flag", + commandLine: []string{"-log-file-size=10MM", "test"}, + errorWanted: "invalid value \"10MM\" for flag -log-file-size: unable to parse quantity's suffix", + }, + { + name: "Negative_log-file-size_flag", + commandLine: []string{"-log-file-size=-10M", "test"}, + errorWanted: "log-file-size must be non-negative quantity", + }, + { + name: "Invalid_log-file-age_flag", + commandLine: []string{"-log-file-age=-10h", "test"}, + errorWanted: "log-file-age must be non-negative", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetFlags() + err := flag.CommandLine.Parse(tt.commandLine) + if err != nil { + if err.Error() != tt.errorWanted { + t.Errorf("Expected error: %v, got: %v", tt.errorWanted, err) + } + return + } + err = configureAndRun(logrotation.OpenStub) + if err == nil || err.Error() != tt.errorWanted { + t.Errorf("Expected error: %v, got: %v", tt.errorWanted, err) + } + }) + } +} + +func resetFlags() { + // Reinitialize the flag to the default value + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + initFlags() +}