// 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 }