routewatch/internal/snapshotter/snapshotter.go
sneak 67f6b78aaa Add custom logger with source location tracking and remove verbose database logs
- Create internal/logger package with Logger wrapper around slog
- Logger automatically adds source file, line number, and function name to all log entries
- Use golang.org/x/term to properly detect if stdout is a terminal
- Replace all slog.Logger usage with logger.Logger throughout the codebase
- Remove verbose logging from database GetStats() method
- Update all constructors and dependencies to use the new logger
2025-07-28 01:14:51 +02:00

243 lines
6.0 KiB
Go

// Package snapshotter provides functionality for creating periodic and on-demand
// snapshots of the routing table state.
package snapshotter
import (
"compress/gzip"
"context"
"encoding/json"
"fmt"
"git.eeqj.de/sneak/routewatch/internal/logger"
"os"
"path/filepath"
"sync"
"time"
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/routingtable"
)
const (
snapshotInterval = 10 * time.Minute
snapshotFilename = "routingtable.json.gz"
tempFileSuffix = ".tmp"
)
// Snapshotter handles periodic and on-demand snapshots of the routing table
type Snapshotter struct {
rt *routingtable.RoutingTable
stateDir string
logger *logger.Logger
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex // Ensures only one snapshot runs at a time
wg sync.WaitGroup
lastSnapshot time.Time
}
// New creates a new Snapshotter instance
func New(rt *routingtable.RoutingTable, cfg *config.Config, logger *logger.Logger) (*Snapshotter, error) {
stateDir := cfg.GetStateDir()
// If state directory is specified, ensure it exists
if stateDir != "" {
const stateDirPerms = 0750
if err := os.MkdirAll(stateDir, stateDirPerms); err != nil {
return nil, fmt.Errorf("failed to create snapshot directory: %w", err)
}
}
s := &Snapshotter{
rt: rt,
stateDir: stateDir,
logger: logger,
}
return s, nil
}
// Start begins the periodic snapshot process
func (s *Snapshotter) Start(ctx context.Context) {
s.mu.Lock()
defer s.mu.Unlock()
if s.ctx != nil {
// Already started
return
}
ctx, cancel := context.WithCancel(ctx)
s.ctx = ctx
s.cancel = cancel
// Start periodic snapshot goroutine
s.wg.Add(1)
go s.periodicSnapshot()
}
// periodicSnapshot runs periodic snapshots
func (s *Snapshotter) periodicSnapshot() {
defer s.wg.Done()
ticker := time.NewTicker(snapshotInterval)
defer ticker.Stop()
// Wait for the first interval before taking any snapshots
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
if err := s.TakeSnapshot(); err != nil {
s.logger.Error("Failed to take periodic snapshot", "error", err)
}
}
}
}
// TakeSnapshot creates a snapshot of the current routing table state
func (s *Snapshotter) TakeSnapshot() error {
// Can't take snapshot without a state directory
if s.stateDir == "" {
return nil
}
// Ensure only one snapshot runs at a time
s.mu.Lock()
defer s.mu.Unlock()
start := time.Now()
s.logger.Info("Starting routing table snapshot")
// Get a copy of all routes while holding read lock
copyStart := time.Now()
s.rt.RLock()
routes := s.rt.GetAllRoutesUnsafe() // We'll need to add this method
s.rt.RUnlock()
// Get stats separately to avoid deadlock
stats := s.rt.GetDetailedStats()
s.logger.Info("Copied routes from routing table",
"duration", time.Since(copyStart),
"route_count", len(routes))
// Create snapshot data structure
snapshot := struct {
Timestamp time.Time `json:"timestamp"`
Stats routingtable.DetailedStats `json:"stats"`
Routes []*routingtable.Route `json:"routes"`
}{
Timestamp: time.Now().UTC(),
Stats: stats,
Routes: routes,
}
// Serialize to JSON
marshalStart := time.Now()
jsonData, err := json.Marshal(snapshot)
if err != nil {
return fmt.Errorf("failed to marshal snapshot: %w", err)
}
s.logger.Info("Marshaled snapshot to JSON",
"duration", time.Since(marshalStart),
"size_bytes", len(jsonData))
// Write compressed data to temporary file
tempPath := filepath.Join(s.stateDir, snapshotFilename+tempFileSuffix)
finalPath := filepath.Join(s.stateDir, snapshotFilename)
// Clean the paths to avoid any path traversal issues
tempPath = filepath.Clean(tempPath)
finalPath = filepath.Clean(finalPath)
tempFile, err := os.Create(tempPath)
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
defer func() {
_ = tempFile.Close()
// Clean up temp file if it still exists
_ = os.Remove(tempPath)
}()
// Create gzip writer
gzipWriter := gzip.NewWriter(tempFile)
gzipWriter.Comment = fmt.Sprintf("RouteWatch snapshot taken at %s", snapshot.Timestamp.Format(time.RFC3339))
// Write compressed data
writeStart := time.Now()
if _, err := gzipWriter.Write(jsonData); err != nil {
return fmt.Errorf("failed to write compressed data: %w", err)
}
// Close gzip writer to flush all data
if err := gzipWriter.Close(); err != nil {
return fmt.Errorf("failed to close gzip writer: %w", err)
}
// Sync to disk
if err := tempFile.Sync(); err != nil {
return fmt.Errorf("failed to sync temporary file: %w", err)
}
// Close temp file before rename
if err := tempFile.Close(); err != nil {
return fmt.Errorf("failed to close temporary file: %w", err)
}
// Atomically rename temp file to final location
if err := os.Rename(tempPath, finalPath); err != nil {
return fmt.Errorf("failed to rename temporary file: %w", err)
}
s.logger.Info("Wrote compressed snapshot to disk",
"duration", time.Since(writeStart))
duration := time.Since(start)
s.lastSnapshot = time.Now()
s.logger.Info("Routing table snapshot completed",
"duration", duration,
"routes", len(routes),
"ipv4_routes", stats.IPv4Routes,
"ipv6_routes", stats.IPv6Routes,
"size_bytes", len(jsonData),
"path", finalPath,
)
return nil
}
// Shutdown performs a final snapshot and cleans up resources
func (s *Snapshotter) Shutdown() error {
s.logger.Info("Shutting down snapshotter")
// Cancel context to stop periodic snapshots
if s.cancel != nil {
s.cancel()
}
// Wait for periodic snapshot goroutine to finish
s.wg.Wait()
// Take final snapshot
if err := s.TakeSnapshot(); err != nil {
return fmt.Errorf("failed to take final snapshot: %w", err)
}
return nil
}
// GetLastSnapshotTime returns the time of the last successful snapshot
func (s *Snapshotter) GetLastSnapshotTime() time.Time {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastSnapshot
}
// GetSnapshotPath returns the path to the snapshot file
func (s *Snapshotter) GetSnapshotPath() string {
return filepath.Join(s.stateDir, snapshotFilename)
}