238 lines
6.1 KiB
Go
238 lines
6.1 KiB
Go
package runtimelog
|
|
|
|
import (
|
|
"bufio"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Smail/utils/logpolicy"
|
|
)
|
|
|
|
func fileDay(name string) (time.Time, bool) {
|
|
base := strings.TrimSuffix(name, ".gz")
|
|
if !strings.HasPrefix(base, prefix) || !strings.HasSuffix(base, ".log") {
|
|
return time.Time{}, false
|
|
}
|
|
value := strings.TrimSuffix(strings.TrimPrefix(base, prefix), ".log")
|
|
day, err := time.Parse("2006-01-02", value)
|
|
return day, err == nil && (name == prefix+value+".log" || name == prefix+value+".log.gz")
|
|
}
|
|
|
|
func lineTime(line []byte) (time.Time, error) {
|
|
if len(line) < 3 || line[0] != '[' {
|
|
return time.Time{}, ErrInvalid
|
|
}
|
|
end := strings.IndexByte(string(line), ']')
|
|
if end < 0 {
|
|
return time.Time{}, ErrInvalid
|
|
}
|
|
at, err := time.Parse(time.RFC3339Nano, string(line[1:end]))
|
|
if err != nil {
|
|
return time.Time{}, ErrInvalid
|
|
}
|
|
return at, nil
|
|
}
|
|
|
|
// scan 严格解析每条记录及压缩校验和,损坏归档不得触发删除源文件。
|
|
func (w *Writer) scan(ctx context.Context, name string, consume func([]byte, time.Time) error) error {
|
|
return w.scanSize(ctx, name, -1, consume)
|
|
}
|
|
|
|
func (w *Writer) scanSize(ctx context.Context, name string, size int64, consume func([]byte, time.Time) error) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if err := w.regular(name, false); err != nil {
|
|
return err
|
|
}
|
|
file, err := w.root.Open(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
var reader io.Reader = file
|
|
if size >= 0 {
|
|
reader = io.LimitReader(reader, size)
|
|
}
|
|
if strings.HasSuffix(name, ".gz") {
|
|
archive, err := gzip.NewReader(reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer archive.Close()
|
|
reader = archive
|
|
}
|
|
scanner := bufio.NewScanner(reader)
|
|
scanner.Buffer(make([]byte, 4096), maxLineBytes)
|
|
for scanner.Scan() {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
at, err := lineTime(scanner.Bytes())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := consume(scanner.Bytes(), at); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return scanner.Err()
|
|
}
|
|
|
|
func (w *Writer) Maintain(ctx context.Context, now time.Time) error {
|
|
if now.IsZero() {
|
|
return ErrInvalid
|
|
}
|
|
w.archiveMu.Lock()
|
|
defer w.archiveMu.Unlock()
|
|
if w.closed {
|
|
return nil
|
|
}
|
|
cutoff := logpolicy.Cutoff(now)
|
|
w.mu.Lock()
|
|
openDay, _ := time.Parse("2006-01-02", w.day)
|
|
// 仅关闭本轮要处理的历史句柄,当天写入不与历史文件扫描共用长时间锁。
|
|
if w.file != nil && (!openDay.After(cutoff) || !openDay.Add(24*time.Hour).After(now.Add(-logpolicy.CompressionAge))) {
|
|
err := errors.Join(w.file.Sync(), w.file.Close())
|
|
w.file = nil
|
|
if err != nil {
|
|
w.mu.Unlock()
|
|
return err
|
|
}
|
|
}
|
|
w.mu.Unlock()
|
|
directory, err := w.root.Open(".")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries, err := directory.ReadDir(-1)
|
|
closeErr := directory.Close()
|
|
if err != nil || closeErr != nil {
|
|
return errors.Join(err, closeErr)
|
|
}
|
|
var failures []error
|
|
for _, entry := range entries {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
day, ok := fileDay(entry.Name())
|
|
if !ok {
|
|
continue
|
|
}
|
|
compress := !strings.HasSuffix(entry.Name(), ".gz") && !day.Add(24*time.Hour).After(now.Add(-logpolicy.CompressionAge))
|
|
if !day.After(cutoff) || compress {
|
|
if err := w.transform(ctx, entry.Name(), day, cutoff, compress); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
}
|
|
}
|
|
return errors.Join(failures...)
|
|
}
|
|
|
|
// transform 流式过滤边界日;额外内存不随一天的日志量增长。
|
|
func (w *Writer) transform(ctx context.Context, source string, day, cutoff time.Time, compress bool) error {
|
|
target := source
|
|
if compress {
|
|
target += ".gz"
|
|
}
|
|
var token [16]byte
|
|
if _, err := rand.Read(token[:]); err != nil {
|
|
return err
|
|
}
|
|
temporary := ".pending-" + hex.EncodeToString(token[:]) + ".log"
|
|
if strings.HasSuffix(target, ".gz") {
|
|
temporary += ".gz"
|
|
}
|
|
file, err := w.root.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = file.Close(); _ = w.root.Remove(temporary) }()
|
|
var output io.Writer = file
|
|
var archive *gzip.Writer
|
|
if strings.HasSuffix(target, ".gz") {
|
|
archive = gzip.NewWriter(file)
|
|
output = archive
|
|
}
|
|
hash := sha256.New()
|
|
output = io.MultiWriter(output, hash)
|
|
kept, removed := 0, 0
|
|
err = w.scan(ctx, source, func(line []byte, at time.Time) error {
|
|
// 文件名和记录日期不一致时保留现场,不按文件名误删。
|
|
if at.UTC().Format("2006-01-02") != day.Format("2006-01-02") {
|
|
return ErrInvalid
|
|
}
|
|
if at.Before(cutoff) {
|
|
removed++
|
|
return nil
|
|
}
|
|
kept++
|
|
_, err := output.Write(append(append([]byte(nil), line...), '\n'))
|
|
return err
|
|
})
|
|
if archive != nil {
|
|
err = errors.Join(err, archive.Close())
|
|
}
|
|
if err = errors.Join(err, file.Sync(), file.Close()); err != nil {
|
|
return err
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if removed == 0 && !compress {
|
|
return nil
|
|
}
|
|
if kept == 0 {
|
|
if err := w.regular(source, false); err != nil {
|
|
return err
|
|
}
|
|
return w.root.Remove(source)
|
|
}
|
|
verified := sha256.New()
|
|
if err := w.scan(ctx, temporary, func(line []byte, _ time.Time) error {
|
|
_, err := verified.Write(append(append([]byte(nil), line...), '\n'))
|
|
return err
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), hex.EncodeToString(verified.Sum(nil))) {
|
|
return ErrInvalid
|
|
}
|
|
if source != target {
|
|
if _, err := w.root.Lstat(target); err == nil {
|
|
// 压缩成功但删除前中断时,只有目标与本轮内容完全一致才允许补做删除。
|
|
existing := sha256.New()
|
|
if err := w.scan(ctx, target, func(line []byte, _ time.Time) error {
|
|
_, err := existing.Write(append(append([]byte(nil), line...), '\n'))
|
|
return err
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if hex.EncodeToString(existing.Sum(nil)) != hex.EncodeToString(hash.Sum(nil)) {
|
|
return ErrInvalid
|
|
}
|
|
return w.root.Remove(source)
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
}
|
|
if err := w.regular(target, true); err != nil {
|
|
return err
|
|
}
|
|
if err := w.root.Rename(temporary, target); err != nil {
|
|
return err
|
|
}
|
|
if source != target {
|
|
return w.root.Remove(source)
|
|
}
|
|
return nil
|
|
}
|