docs(runtime-logs): implement runtime log management features
Добавлены новые возможности для работы с файловыми логами Docker-сервисов: - Эндпоинты для получения списка логов и хвоста лог-файла. - Очистка лог-файлов с возможностью выбора режима (truncate или delete) и запись в аудит очистки. - Обновлена документация и конфигурация для поддержки новых функций. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
package runtimelogs
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// Cleanup truncates or deletes a runtime log file synchronously.
|
||||
func (s *Service) Cleanup(filename, mode string) (sizeBefore int64, sizeAfter *int64, err error) {
|
||||
if !s.Available() {
|
||||
return 0, nil, ErrUnavailable
|
||||
}
|
||||
if !store.ValidRuntimeLogCleanupAction(mode) {
|
||||
return 0, nil, ErrInvalidFilename
|
||||
}
|
||||
path, err := ResolveLogPath(s.cfg.RootDir, filename)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil, ErrNotFound
|
||||
}
|
||||
return 0, nil, err
|
||||
}
|
||||
if st.IsDir() {
|
||||
return 0, nil, ErrNotAFile
|
||||
}
|
||||
sizeBefore = st.Size()
|
||||
if sizeBefore > MaxCleanupBytes {
|
||||
return 0, nil, ErrFileTooLarge
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case store.RuntimeLogCleanupTruncate:
|
||||
if err := os.Truncate(path, 0); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
zero := int64(0)
|
||||
return sizeBefore, &zero, nil
|
||||
case store.RuntimeLogCleanupDelete:
|
||||
if err := os.Remove(path); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return sizeBefore, nil, nil
|
||||
default:
|
||||
return 0, nil, ErrInvalidFilename
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package runtimelogs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds runtime log filesystem settings.
|
||||
type Config struct {
|
||||
// RootDir is the absolute path to runtime log files (empty disables FS API).
|
||||
RootDir string
|
||||
// ServiceName is EVOBGP_SERVICE (must be evobgp-all when set).
|
||||
ServiceName string
|
||||
}
|
||||
|
||||
// ConfigFromEnv builds Config from EVOBGP_RUNTIME_LOGS_DIR and EVOBGP_SERVICE.
|
||||
func ConfigFromEnv() Config {
|
||||
root := strings.TrimSpace(os.Getenv("EVOBGP_RUNTIME_LOGS_DIR"))
|
||||
if root != "" {
|
||||
if abs, err := filepath.Abs(root); err == nil {
|
||||
root = abs
|
||||
}
|
||||
}
|
||||
svc := strings.TrimSpace(os.Getenv("EVOBGP_SERVICE"))
|
||||
return Config{RootDir: root, ServiceName: svc}
|
||||
}
|
||||
|
||||
// Enabled reports whether runtime log FS operations are allowed in this process.
|
||||
func (c Config) Enabled() bool {
|
||||
if c.RootDir == "" || c.ServiceName != ServiceNameAll {
|
||||
return false
|
||||
}
|
||||
st, err := os.Stat(c.RootDir)
|
||||
return err == nil && st.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package runtimelogs
|
||||
|
||||
const (
|
||||
// MaxCleanupBytes is the maximum file size eligible for sync cleanup.
|
||||
MaxCleanupBytes = 512 * 1024 * 1024
|
||||
// DefaultTailLines is the default number of lines returned from Tail.
|
||||
DefaultTailLines = 200
|
||||
// MaxTailLines caps the lines query parameter.
|
||||
MaxTailLines = 2000
|
||||
// MaxTailBytes caps tail read size.
|
||||
MaxTailBytes = 256 * 1024
|
||||
// MaxGrepLen caps optional grep filter length.
|
||||
MaxGrepLen = 128
|
||||
// ServiceNameAll is the only process role that may access runtime logs FS.
|
||||
ServiceNameAll = "evobgp-all"
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package runtimelogs
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors for runtime log filesystem operations.
|
||||
var (
|
||||
ErrUnavailable = errors.New("runtimelogs: unavailable")
|
||||
ErrInvalidFilename = errors.New("runtimelogs: invalid filename")
|
||||
ErrNotFound = errors.New("runtimelogs: not found")
|
||||
ErrFileTooLarge = errors.New("runtimelogs: file too large")
|
||||
ErrNotAFile = errors.New("runtimelogs: not a file")
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
package runtimelogs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var filenamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*\.log$`)
|
||||
|
||||
// ValidateFilename checks basename allowlist for runtime log files.
|
||||
func ValidateFilename(filename string) error {
|
||||
name := strings.TrimSpace(filename)
|
||||
if name == "" || len(name) > 128 {
|
||||
return ErrInvalidFilename
|
||||
}
|
||||
if name != filepath.Base(name) {
|
||||
return ErrInvalidFilename
|
||||
}
|
||||
if strings.Contains(name, "..") {
|
||||
return ErrInvalidFilename
|
||||
}
|
||||
if !filenamePattern.MatchString(name) {
|
||||
return ErrInvalidFilename
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveLogPath maps a validated basename to an absolute path under root.
|
||||
func ResolveLogPath(root, filename string) (string, error) {
|
||||
if err := ValidateFilename(filename); err != nil {
|
||||
return "", err
|
||||
}
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rootAbs = filepath.Clean(rootAbs)
|
||||
candidate := filepath.Join(rootAbs, filename)
|
||||
resolved, err := filepath.EvalSymlinks(candidate)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
resolved = filepath.Clean(candidate)
|
||||
} else {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
resolved = filepath.Clean(resolved)
|
||||
if !pathUnderRoot(resolved, rootAbs) {
|
||||
return "", ErrInvalidFilename
|
||||
}
|
||||
if st, err := os.Lstat(resolved); err == nil {
|
||||
if st.IsDir() {
|
||||
return "", ErrNotAFile
|
||||
}
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func pathUnderRoot(path, root string) bool {
|
||||
path = filepath.Clean(path)
|
||||
root = filepath.Clean(root)
|
||||
if path == root {
|
||||
return false
|
||||
}
|
||||
sep := string(os.PathSeparator)
|
||||
return strings.HasPrefix(path+sep, root+sep)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package runtimelogs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateFilename(t *testing.T) {
|
||||
valid := []string{"evobgp-all.log", "postgres.log", "bird2.log", "a.log"}
|
||||
for _, name := range valid {
|
||||
if err := ValidateFilename(name); err != nil {
|
||||
t.Fatalf("%q: %v", name, err)
|
||||
}
|
||||
}
|
||||
invalid := []string{"", ".log", "SECRET.log", "../x.log", "x/../y.log", "foo.txt", "a"}
|
||||
for _, name := range invalid {
|
||||
if err := ValidateFilename(name); err == nil {
|
||||
t.Fatalf("expected invalid: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLogPathTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := ValidateFilename("ok.log"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
okPath := filepath.Join(root, "ok.log")
|
||||
if err := os.WriteFile(okPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ResolveLogPath(root, "ok.log"); err != nil {
|
||||
t.Fatalf("ok.log: %v", err)
|
||||
}
|
||||
if _, err := ResolveLogPath(root, "../etc/passwd"); err == nil {
|
||||
t.Fatal("expected traversal reject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLogPathSymlinkEscape(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink root escape test skipped on windows")
|
||||
}
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
secret := filepath.Join(outside, "secret.log")
|
||||
if err := os.WriteFile(secret, []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(root, "evil.log")
|
||||
if err := os.Symlink(secret, link); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
if _, err := ResolveLogPath(root, "evil.log"); err == nil {
|
||||
t.Fatal("expected symlink escape to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package runtimelogs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// Service performs filesystem operations on runtime log files.
|
||||
type Service struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// NewService returns a runtime logs FS service.
|
||||
func NewService(cfg Config) *Service {
|
||||
return &Service{cfg: cfg}
|
||||
}
|
||||
|
||||
// Available reports whether the service can access runtime logs.
|
||||
func (s *Service) Available() bool {
|
||||
return s.cfg.Enabled()
|
||||
}
|
||||
|
||||
// Config returns a copy of the service configuration.
|
||||
func (s *Service) Config() Config {
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
// ListFiles returns metadata for *.log files in the configured root.
|
||||
func (s *Service) ListFiles() ([]store.RuntimeLogFile, error) {
|
||||
if !s.Available() {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
entries, err := os.ReadDir(s.cfg.RootDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]store.RuntimeLogFile, 0, len(entries))
|
||||
for _, ent := range entries {
|
||||
if ent.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := ent.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
if err := ValidateFilename(name); err != nil {
|
||||
continue
|
||||
}
|
||||
info, err := ent.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
continue
|
||||
}
|
||||
out = append(out, store.RuntimeLogFile{
|
||||
Name: name,
|
||||
SizeBytes: info.Size(),
|
||||
ModifiedAt: info.ModTime().UTC(),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package runtimelogs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func testService(t *testing.T) (*Service, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
svc := NewService(Config{RootDir: dir, ServiceName: ServiceNameAll})
|
||||
if !svc.Available() {
|
||||
t.Fatal("expected available")
|
||||
}
|
||||
return svc, dir
|
||||
}
|
||||
|
||||
func TestConfigEnabled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if (Config{}).Enabled() {
|
||||
t.Fatal("empty config")
|
||||
}
|
||||
if (Config{RootDir: dir, ServiceName: "evobgp-api"}).Enabled() {
|
||||
t.Fatal("wrong service")
|
||||
}
|
||||
if !(Config{RootDir: dir, ServiceName: ServiceNameAll}).Enabled() {
|
||||
t.Fatal("expected enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFiles(t *testing.T) {
|
||||
svc, dir := testService(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "evobgp-all.log"), []byte("line\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ".hidden.log"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dir, "subdir.log"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
items, err := svc.ListFiles()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Name != "evobgp-all.log" {
|
||||
t.Fatalf("list: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailAndCleanup(t *testing.T) {
|
||||
svc, dir := testService(t)
|
||||
path := filepath.Join(dir, "postgres.log")
|
||||
var b strings.Builder
|
||||
for i := 0; i < 50; i++ {
|
||||
b.WriteString("line\n")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tail, err := svc.Tail("postgres.log", TailOptions{Lines: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tail.LinesReturned != 3 || !strings.Contains(tail.Content, "line") {
|
||||
t.Fatalf("tail: %+v", tail)
|
||||
}
|
||||
|
||||
tailGrep, err := svc.Tail("postgres.log", TailOptions{Lines: 100, Grep: "nomatch"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tailGrep.LinesReturned != 0 {
|
||||
t.Fatalf("grep filter: %+v", tailGrep)
|
||||
}
|
||||
|
||||
before, after, err := svc.Cleanup("postgres.log", store.RuntimeLogCleanupTruncate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before <= 0 || after == nil || *after != 0 {
|
||||
t.Fatalf("truncate: before=%d after=%v", before, after)
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Size() != 0 {
|
||||
t.Fatalf("expected empty file, size=%d", st.Size())
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte("again\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = svc.Cleanup("postgres.log", store.RuntimeLogCleanupDelete)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected deleted, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupFileTooLarge(t *testing.T) {
|
||||
svc, dir := testService(t)
|
||||
path := filepath.Join(dir, "big.log")
|
||||
if err := os.WriteFile(path, make([]byte, 1024), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Patch check by using a tiny max - we test the constant path via stat size.
|
||||
// Use a file just over limit only in integration; here verify small file works.
|
||||
_, _, err := svc.Cleanup("big.log", store.RuntimeLogCleanupTruncate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableWhenDisabled(t *testing.T) {
|
||||
svc := NewService(Config{RootDir: "", ServiceName: ServiceNameAll})
|
||||
if _, err := svc.ListFiles(); err != ErrUnavailable {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if _, err := svc.Tail("a.log", TailOptions{}); err != ErrUnavailable {
|
||||
t.Fatalf("tail: %v", err)
|
||||
}
|
||||
if _, _, err := svc.Cleanup("a.log", store.RuntimeLogCleanupTruncate); err != ErrUnavailable {
|
||||
t.Fatalf("cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package runtimelogs
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// TailOptions controls tail/preview reads.
|
||||
type TailOptions struct {
|
||||
Lines int
|
||||
Bytes int
|
||||
Grep string
|
||||
}
|
||||
|
||||
// Tail reads the end of a runtime log file.
|
||||
func (s *Service) Tail(filename string, opts TailOptions) (*store.RuntimeLogTail, error) {
|
||||
if !s.Available() {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
path, err := ResolveLogPath(s.cfg.RootDir, filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if st.IsDir() {
|
||||
return nil, ErrNotAFile
|
||||
}
|
||||
|
||||
lines := opts.Lines
|
||||
if lines <= 0 {
|
||||
lines = DefaultTailLines
|
||||
}
|
||||
if lines > MaxTailLines {
|
||||
lines = MaxTailLines
|
||||
}
|
||||
maxRead := MaxTailBytes
|
||||
if opts.Bytes > 0 && opts.Bytes < maxRead {
|
||||
maxRead = opts.Bytes
|
||||
}
|
||||
|
||||
raw, truncated, err := readTailBytes(path, int64(maxRead))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
grep := strings.TrimSpace(opts.Grep)
|
||||
if len(grep) > MaxGrepLen {
|
||||
grep = grep[:MaxGrepLen]
|
||||
}
|
||||
|
||||
contentLines := splitLines(raw)
|
||||
if grep != "" {
|
||||
filtered := contentLines[:0]
|
||||
for _, line := range contentLines {
|
||||
if strings.Contains(line, grep) {
|
||||
filtered = append(filtered, line)
|
||||
}
|
||||
}
|
||||
contentLines = filtered
|
||||
}
|
||||
if len(contentLines) > lines {
|
||||
contentLines = contentLines[len(contentLines)-lines:]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
return &store.RuntimeLogTail{
|
||||
Filename: filename,
|
||||
Content: strings.Join(contentLines, "\n"),
|
||||
Truncated: truncated,
|
||||
LinesReturned: len(contentLines),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readTailBytes(path string, maxRead int64) ([]byte, bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
size := st.Size()
|
||||
truncated := size > maxRead
|
||||
start := int64(0)
|
||||
if size > maxRead {
|
||||
start = size - maxRead
|
||||
}
|
||||
if _, err := f.Seek(start, io.SeekStart); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
buf := make([]byte, size-start)
|
||||
n, err := io.ReadFull(f, buf)
|
||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||
return nil, false, err
|
||||
}
|
||||
buf = buf[:n]
|
||||
if start > 0 {
|
||||
// Drop partial first line when reading from middle of file.
|
||||
if idx := bytes.IndexByte(buf, '\n'); idx >= 0 && idx+1 < len(buf) {
|
||||
buf = buf[idx+1:]
|
||||
truncated = true
|
||||
} else if start > 0 {
|
||||
truncated = true
|
||||
}
|
||||
}
|
||||
return buf, truncated, nil
|
||||
}
|
||||
|
||||
func splitLines(b []byte) []string {
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
sc := bufio.NewScanner(bytes.NewReader(b))
|
||||
var lines []string
|
||||
for sc.Scan() {
|
||||
lines = append(lines, sc.Text())
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return []string{string(b)}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
Reference in New Issue
Block a user