kra-oa/internal/data/system/log_file.go

425 lines
11 KiB
Go

package system
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
pathpkg "path"
"path/filepath"
"sort"
"strings"
"time"
"kra/internal/biz"
)
const (
defaultLogChunkLines = 500
maxLogChunkBytes = 2 * 1024 * 1024
logReadBlockSize = 64 * 1024
)
func (r *logFileRepo) configuredLogRoot() (root string, exists bool, err error) {
admin := r.data.Runtime().Admin()
if admin == nil || admin.Zap == nil || strings.TrimSpace(admin.Zap.Director) == "" {
return "", false, biz.ErrLogRootUnavailable
}
root, err = filepath.Abs(admin.Zap.Director)
if err != nil {
return "", false, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err)
}
info, err := os.Stat(root)
if errors.Is(err, fs.ErrNotExist) {
return root, false, nil
}
if err != nil {
return "", false, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err)
}
if !info.IsDir() {
return "", false, biz.ErrLogRootUnavailable
}
return root, true, nil
}
func (r *logFileRepo) openConfiguredLogRoot() (root *os.Root, exists bool, err error) {
rootPath, exists, err := r.configuredLogRoot()
if err != nil || !exists {
return nil, exists, err
}
root, err = os.OpenRoot(rootPath)
if err != nil {
return nil, false, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err)
}
return root, true, nil
}
func (r *logFileRepo) LogDates(ctx context.Context, month string) ([]biz.LogDate, error) {
if err := validateLogMonth(month); err != nil {
return nil, err
}
logRoot, exists, err := r.openConfiguredLogRoot()
if err != nil {
return nil, err
}
if !exists {
return []biz.LogDate{}, nil
}
defer logRoot.Close()
entries, err := fs.ReadDir(logRoot.FS(), ".")
if err != nil {
return nil, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err)
}
result := make([]biz.LogDate, 0)
for _, entry := range entries {
if err = ctx.Err(); err != nil {
return nil, err
}
if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() || !strings.HasPrefix(entry.Name(), month+"-") {
continue
}
if validateLogDate(entry.Name()) != nil {
continue
}
count, countErr := countLogFiles(ctx, logRoot, entry.Name())
if countErr != nil {
return nil, countErr
}
if count > 0 {
result = append(result, biz.LogDate{Date: entry.Name(), FileCount: count})
}
}
sort.Slice(result, func(i, j int) bool { return result[i].Date < result[j].Date })
return result, nil
}
func (r *logFileRepo) LogFiles(ctx context.Context, date string) ([]biz.LogFile, error) {
if err := validateLogDate(date); err != nil {
return nil, err
}
logRoot, exists, err := r.openConfiguredLogRoot()
if err != nil {
return nil, err
}
if !exists {
return []biz.LogFile{}, nil
}
defer logRoot.Close()
info, err := logRoot.Lstat(date)
if errors.Is(err, fs.ErrNotExist) {
return []biz.LogFile{}, nil
}
if err != nil {
return nil, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return []biz.LogFile{}, nil
}
dateRoot, err := logRoot.OpenRoot(date)
if err != nil {
return nil, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err)
}
defer dateRoot.Close()
result := make([]biz.LogFile, 0)
err = fs.WalkDir(dateRoot.FS(), ".", func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if err := ctx.Err(); err != nil {
return err
}
if path == "." {
return nil
}
if entry.Type()&os.ModeSymlink != 0 {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".log") {
return nil
}
fileInfo, infoErr := entry.Info()
if infoErr != nil {
return infoErr
}
if fileInfo.Mode().IsRegular() {
result = append(result, biz.LogFile{Path: path, Name: entry.Name(), Size: fileInfo.Size(), ModifiedAt: fileInfo.ModTime()})
}
return nil
})
if err != nil {
return nil, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err)
}
sort.Slice(result, func(i, j int) bool { return result[i].Path < result[j].Path })
return result, nil
}
func (r *logFileRepo) LogContent(ctx context.Context, date, apiPath string, cursor *int64) (*biz.LogContent, error) {
result := &biz.LogContent{Date: date, Path: apiPath}
if err := ctx.Err(); err != nil {
return nil, err
}
if cursor != nil && *cursor < 0 {
return nil, biz.ErrInvalidLogPath
}
file, info, err := r.openValidatedLogFile(date, apiPath)
if err != nil {
return nil, err
}
defer file.Close()
end := info.Size()
if cursor != nil && *cursor <= end {
end = *cursor
}
start, limitedByBytes, err := findLogChunkStart(file, end)
if err != nil {
return nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, err)
}
data, err := readLogRange(file, start, end)
if err != nil {
return nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, err)
}
if int64(len(data)) < end-start {
currentInfo, statErr := file.Stat()
if statErr != nil || currentInfo.Size() < start {
if statErr != nil {
return nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, statErr)
}
return nil, biz.ErrLogFileUnreadable
}
info = currentInfo
}
result.Content = string(data)
result.LineCount = countLogicalLines(data)
result.NextCursor = start
result.HasMore = start > 0
result.LimitedByBytes = limitedByBytes
result.Size = info.Size()
result.ModifiedAt = info.ModTime()
return result, nil
}
func (r *logFileRepo) openValidatedLogFile(date, apiPath string) (file *os.File, info os.FileInfo, err error) {
if err = validateLogDate(date); err != nil {
return nil, nil, err
}
segments, err := validateLogAPIPath(apiPath)
if err != nil {
return nil, nil, err
}
logRoot, exists, err := r.openConfiguredLogRoot()
if err != nil {
return nil, nil, err
}
if !exists {
return nil, nil, biz.ErrLogFileNotFound
}
defer logRoot.Close()
dateInfo, err := logRoot.Lstat(date)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil, biz.ErrLogFileNotFound
}
if err != nil {
return nil, nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, err)
}
if dateInfo.Mode()&os.ModeSymlink != 0 || !dateInfo.IsDir() {
return nil, nil, biz.ErrInvalidLogPath
}
dateRoot, err := logRoot.OpenRoot(date)
if err != nil {
return nil, nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, err)
}
defer dateRoot.Close()
relativePath := ""
var validatedInfo os.FileInfo
for index, segment := range segments {
relativePath = filepath.Join(relativePath, segment)
validatedInfo, err = dateRoot.Lstat(relativePath)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil, biz.ErrLogFileNotFound
}
if err != nil {
return nil, nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, err)
}
if validatedInfo.Mode()&os.ModeSymlink != 0 {
return nil, nil, biz.ErrInvalidLogPath
}
if index < len(segments)-1 && !validatedInfo.IsDir() {
return nil, nil, biz.ErrInvalidLogPath
}
}
if validatedInfo == nil || !validatedInfo.Mode().IsRegular() {
return nil, nil, biz.ErrInvalidLogPath
}
file, err = dateRoot.Open(relativePath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil, biz.ErrLogFileNotFound
}
return nil, nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, err)
}
openedInfo, statErr := file.Stat()
if statErr != nil || !openedInfo.Mode().IsRegular() || !os.SameFile(validatedInfo, openedInfo) {
file.Close()
if statErr != nil {
return nil, nil, fmt.Errorf("%w: %v", biz.ErrLogFileUnreadable, statErr)
}
return nil, nil, biz.ErrInvalidLogPath
}
return file, openedInfo, nil
}
func validateLogMonth(month string) error {
parsed, err := time.Parse("2006-01", month)
if err != nil || parsed.Format("2006-01") != month {
return biz.ErrInvalidLogMonth
}
return nil
}
func validateLogDate(date string) error {
parsed, err := time.Parse("2006-01-02", date)
if err != nil || parsed.Format("2006-01-02") != date {
return biz.ErrInvalidLogDate
}
return nil
}
func validateLogAPIPath(apiPath string) ([]string, error) {
if apiPath == "" || strings.Contains(apiPath, "\\") || strings.Contains(apiPath, ":") || pathpkg.IsAbs(apiPath) {
return nil, biz.ErrInvalidLogPath
}
segments := strings.Split(apiPath, "/")
for _, segment := range segments {
if segment == "" || segment == "." || segment == ".." {
return nil, biz.ErrInvalidLogPath
}
}
if !strings.EqualFold(pathpkg.Ext(apiPath), ".log") {
return nil, biz.ErrInvalidLogPath
}
return segments, nil
}
func countLogFiles(ctx context.Context, logRoot *os.Root, date string) (count int, err error) {
dateInfo, err := logRoot.Lstat(date)
if err != nil || dateInfo.Mode()&os.ModeSymlink != 0 || !dateInfo.IsDir() {
return 0, err
}
dateRoot, err := logRoot.OpenRoot(date)
if err != nil {
return 0, err
}
defer dateRoot.Close()
err = fs.WalkDir(dateRoot.FS(), ".", func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if err := ctx.Err(); err != nil {
return err
}
if path == "." {
return nil
}
if entry.Type()&os.ModeSymlink != 0 {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".log") {
return nil
}
info, infoErr := entry.Info()
if infoErr != nil {
return infoErr
}
if info.Mode().IsRegular() {
count++
}
return nil
})
if err != nil {
return 0, fmt.Errorf("%w: %v", biz.ErrLogRootUnavailable, err)
}
return count, nil
}
func readLogRange(file *os.File, start, end int64) ([]byte, error) {
if end <= start {
return []byte{}, nil
}
data := make([]byte, int(end-start))
n, err := file.ReadAt(data, start)
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
return data[:n], nil
}
func findLogChunkStart(file *os.File, end int64) (start int64, limitedByBytes bool, err error) {
if end <= 0 {
return 0, false, nil
}
lastByte := []byte{0}
if _, err = file.ReadAt(lastByte, end-1); err != nil {
return 0, false, err
}
targetNewlines := defaultLogChunkLines
if lastByte[0] == '\n' {
targetNewlines++
}
position, scanned, newlines := end, int64(0), 0
for position > 0 && scanned < maxLogChunkBytes {
readSize := int64(logReadBlockSize)
if readSize > position {
readSize = position
}
if remaining := int64(maxLogChunkBytes) - scanned; readSize > remaining {
readSize = remaining
}
blockStart := position - readSize
block := make([]byte, int(readSize))
n, readErr := file.ReadAt(block, blockStart)
if readErr != nil && !errors.Is(readErr, io.EOF) {
return 0, false, readErr
}
for index := n - 1; index >= 0; index-- {
if block[index] == '\n' {
newlines++
if newlines == targetNewlines {
return blockStart + int64(index) + 1, false, nil
}
}
}
scanned += int64(n)
position = blockStart
}
if position > 0 {
return end - int64(maxLogChunkBytes), true, nil
}
return 0, false, nil
}
func countLogicalLines(data []byte) int {
if len(data) == 0 {
return 0
}
count := bytes.Count(data, []byte{'\n'})
if data[len(data)-1] != '\n' {
count++
}
return count
}