enodia-sentinal/go-agent/internal/eventlog/writer.go

111 lines
3.1 KiB
Go

// SPDX-License-Identifier: GPL-3.0-or-later
// Package eventlog retains a bounded JSONL copy of emitted event envelopes.
package eventlog
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
// Writer appends records to Path and keeps one rotated segment at Path + ".1".
type Writer struct {
Path string
MaxBytes int64
mu sync.Mutex
}
// New preflights the parent directory and destination permissions. A service
// can call it before signaling readiness so a broken retention path fails the
// start instead of silently dropping records.
func New(path string, maxBytes int64) (*Writer, error) {
if path == "" {
return nil, fmt.Errorf("event log path is required")
}
if maxBytes <= 0 {
return nil, fmt.Errorf("event log max bytes must be positive")
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, fmt.Errorf("create event log directory: %w", err)
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, fmt.Errorf("open event log: %w", err)
}
if err := file.Chmod(0o600); err != nil {
file.Close()
return nil, fmt.Errorf("protect event log: %w", err)
}
if err := file.Close(); err != nil {
return nil, fmt.Errorf("close event log: %w", err)
}
return &Writer{Path: path, MaxBytes: maxBytes}, nil
}
// Append writes exactly one JSON record. Alert and incident records are synced
// before success is reported; routine status records rely on normal kernel
// writeback to avoid forcing a disk flush every sweep.
func (w *Writer) Append(record map[string]any) error {
if w == nil {
return fmt.Errorf("event log writer is required")
}
raw, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("encode event log record: %w", err)
}
raw = append(raw, '\n')
if len(raw) > maxRecordBytes {
return fmt.Errorf("event log record exceeds %d bytes", maxRecordBytes)
}
w.mu.Lock()
defer w.mu.Unlock()
if err := w.rotateBefore(int64(len(raw))); err != nil {
return err
}
file, err := os.OpenFile(w.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return fmt.Errorf("open event log: %w", err)
}
if err := file.Chmod(0o600); err != nil {
file.Close()
return fmt.Errorf("protect event log: %w", err)
}
if _, err := file.Write(raw); err != nil {
file.Close()
return fmt.Errorf("append event log: %w", err)
}
if eventType, _ := record["event_type"].(string); eventType != "status" {
if err := file.Sync(); err != nil {
file.Close()
return fmt.Errorf("sync event log: %w", err)
}
}
if err := file.Close(); err != nil {
return fmt.Errorf("close event log: %w", err)
}
return nil
}
func (w *Writer) rotateBefore(incoming int64) error {
info, err := os.Stat(w.Path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("stat event log: %w", err)
}
// Always allow one record into an empty file, even when that individual
// envelope is larger than the configured bound.
if info.Size() == 0 || info.Size()+incoming <= w.MaxBytes {
return nil
}
if err := os.Rename(w.Path, w.Path+".1"); err != nil {
return fmt.Errorf("rotate event log: %w", err)
}
return nil
}