fix: order command controls after dispatch
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RotatingFile is an append-only daemon log with bounded numbered archives.
|
||||
// It is deliberately a single-process writer: deployment supplies one daemon
|
||||
// process per configured path, and a second writer must use a distinct file.
|
||||
// Archive .1 is newest; .maxFiles is oldest.
|
||||
type RotatingFile struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
maxBytes uint64
|
||||
maxFiles uint32
|
||||
file *os.File
|
||||
size uint64
|
||||
}
|
||||
|
||||
// OpenRotatingFile opens path for append, creating its parent directories with
|
||||
// conservative permissions. A blank path disables file logging and returns a
|
||||
// no-op closer. Both rotation controls must be zero (unbounded) or positive.
|
||||
func OpenRotatingFile(path string, maxBytes uint64, maxFiles uint32) (io.WriteCloser, error) {
|
||||
if path == "" {
|
||||
return nopWriteCloser{Writer: io.Discard}, nil
|
||||
}
|
||||
if (maxBytes == 0) != (maxFiles == 0) {
|
||||
return nil, errors.New("log rotation byte/file limits must both be zero or positive")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create log directory: %w", err)
|
||||
}
|
||||
result := &RotatingFile{path: path, maxBytes: maxBytes, maxFiles: maxFiles}
|
||||
if err := result.open(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (file *RotatingFile) open() error {
|
||||
opened, err := os.OpenFile(file.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open log file: %w", err)
|
||||
}
|
||||
info, err := opened.Stat()
|
||||
if err != nil {
|
||||
_ = opened.Close()
|
||||
return fmt.Errorf("stat log file: %w", err)
|
||||
}
|
||||
file.file = opened
|
||||
file.size = uint64(info.Size())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (file *RotatingFile) Write(data []byte) (int, error) {
|
||||
file.mu.Lock()
|
||||
defer file.mu.Unlock()
|
||||
if file.file == nil {
|
||||
return 0, os.ErrClosed
|
||||
}
|
||||
if file.maxBytes != 0 && file.size != 0 && (file.size >= file.maxBytes || uint64(len(data)) > file.maxBytes-file.size) {
|
||||
if err := file.rotate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
written, err := file.file.Write(data)
|
||||
file.size += uint64(written)
|
||||
return written, err
|
||||
}
|
||||
|
||||
func (file *RotatingFile) Close() error {
|
||||
file.mu.Lock()
|
||||
defer file.mu.Unlock()
|
||||
if file.file == nil {
|
||||
return nil
|
||||
}
|
||||
err := file.file.Close()
|
||||
file.file = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (file *RotatingFile) rotate() error {
|
||||
if err := file.file.Close(); err != nil {
|
||||
return fmt.Errorf("close log before rotation: %w", err)
|
||||
}
|
||||
file.file = nil
|
||||
oldest := file.archivePath(file.maxFiles)
|
||||
if err := os.Remove(oldest); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("remove oldest log archive: %w", err)
|
||||
}
|
||||
for index := file.maxFiles; index > 1; index-- {
|
||||
from := file.archivePath(index - 1)
|
||||
to := file.archivePath(index)
|
||||
if err := os.Rename(from, to); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("rotate log archive: %w", err)
|
||||
}
|
||||
}
|
||||
if err := os.Rename(file.path, file.archivePath(1)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("seal current log: %w", err)
|
||||
}
|
||||
return file.open()
|
||||
}
|
||||
|
||||
func (file *RotatingFile) archivePath(index uint32) string {
|
||||
return file.path + "." + strconv.FormatUint(uint64(index), 10)
|
||||
}
|
||||
|
||||
type nopWriteCloser struct{ io.Writer }
|
||||
|
||||
func (nopWriteCloser) Close() error { return nil }
|
||||
|
||||
// FormatLog makes file records either newline-delimited JSON or plain text.
|
||||
// It is intentionally applied only to daemon diagnostics, never command
|
||||
// output, stdin, environment values, or other payload-bearing data.
|
||||
func FormatLog(destination io.Writer, format string) io.Writer {
|
||||
if destination == nil || format != "json" {
|
||||
return destination
|
||||
}
|
||||
return &jsonLogWriter{destination: destination}
|
||||
}
|
||||
|
||||
type jsonLogWriter struct {
|
||||
mu sync.Mutex
|
||||
destination io.Writer
|
||||
}
|
||||
|
||||
func (writer *jsonLogWriter) Write(data []byte) (int, error) {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
for _, line := range bytes.Split(data, []byte{'\n'}) {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
record, err := json.Marshal(struct {
|
||||
Time string `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
}{Time: time.Now().UTC().Format(time.RFC3339Nano), Level: "info", Message: string(line)})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode structured log: %w", err)
|
||||
}
|
||||
if _, err := writer.destination.Write(append(record, '\n')); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRotatingFileBoundsArchives_HP_OPS_02(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "nested", "rvbox.log")
|
||||
writer, err := OpenRotatingFile(path, 5, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("first")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("two")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("three")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("four")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, err := os.ReadFile(path)
|
||||
if err != nil || string(current) != "four" {
|
||||
t.Fatalf("current = %q, %v", current, err)
|
||||
}
|
||||
newest, err := os.ReadFile(path + ".1")
|
||||
if err != nil || string(newest) != "three" {
|
||||
t.Fatalf("newest archive = %q, %v", newest, err)
|
||||
}
|
||||
oldest, err := os.ReadFile(path + ".2")
|
||||
if err != nil || string(oldest) != "two" {
|
||||
t.Fatalf("oldest archive = %q, %v", oldest, err)
|
||||
}
|
||||
if _, err := os.Stat(path + ".3"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("unexpected third archive: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotatingFileRejectsPartialRotationConfig_BH_OPS_03(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, limits := range [][2]uint64{{1, 0}, {0, 1}} {
|
||||
_, err := OpenRotatingFile(filepath.Join(t.TempDir(), "rvbox.log"), limits[0], uint32(limits[1]))
|
||||
if err == nil || !strings.Contains(err.Error(), "both be zero") {
|
||||
t.Fatalf("limits %v error = %v", limits, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLogWritesStructuredBoundedRecords_HP_OPS_04(t *testing.T) {
|
||||
t.Parallel()
|
||||
var structured bytes.Buffer
|
||||
if _, err := FormatLog(&structured, "json").Write([]byte("connected\nrecovered\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := bytes.Split(bytes.TrimSpace(structured.Bytes()), []byte{'\n'})
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("structured records = %q", structured.String())
|
||||
}
|
||||
for index, want := range []string{"connected", "recovered"} {
|
||||
var record struct {
|
||||
Time string `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(lines[index], &record); err != nil || record.Time == "" || record.Level != "info" || record.Message != want {
|
||||
t.Fatalf("record %d = %+v, %v", index, record, err)
|
||||
}
|
||||
}
|
||||
var plain bytes.Buffer
|
||||
if _, err := FormatLog(&plain, "text").Write([]byte("plain\n")); err != nil || plain.String() != "plain\n" {
|
||||
t.Fatalf("text log = %q, %v", plain.String(), err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user