Implement routing table snapshotter with automatic loading on startup

- Create snapshotter package with periodic (10 min) and on-demand snapshots
- Add JSON serialization with gzip compression and atomic file writes
- Update routing table to track AddedAt time for each route
- Load snapshots on startup, filtering out stale routes (>30 minutes old)
- Add ROUTEWATCH_DISABLE_SNAPSHOTTER env var for tests
- Use OS-appropriate state directories (macOS: ~/Library/Application Support, Linux: /var/lib or XDG_STATE_HOME)
This commit is contained in:
2025-07-28 00:03:19 +02:00
parent 283f2ddbf2
commit ae2ef2ae0c
6 changed files with 258 additions and 32 deletions

View File

@@ -36,32 +36,44 @@ type Snapshotter struct {
}
// New creates a new Snapshotter instance
func New(ctx context.Context, rt *routingtable.RoutingTable, logger *slog.Logger) (*Snapshotter, error) {
func New(rt *routingtable.RoutingTable, logger *slog.Logger) (*Snapshotter, error) {
stateDir, err := getStateDirectory()
if err != nil {
return nil, fmt.Errorf("failed to determine state directory: %w", err)
}
// Ensure state directory exists
if err := os.MkdirAll(stateDir, 0755); err != nil {
const stateDirPerms = 0750
if err := os.MkdirAll(stateDir, stateDirPerms); err != nil {
return nil, fmt.Errorf("failed to create state directory: %w", err)
}
ctx, cancel := context.WithCancel(ctx)
s := &Snapshotter{
rt: rt,
stateDir: stateDir,
logger: logger,
ctx: ctx,
cancel: cancel,
}
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()
return s, nil
}
// getStateDirectory returns the appropriate state directory based on the OS
@@ -73,6 +85,7 @@ func getStateDirectory() (string, error) {
if err != nil {
return "", err
}
return filepath.Join(home, "Library", "Application Support", "routewatch"), nil
case "linux", "freebsd", "openbsd", "netbsd":
// Unix-like: Use /var/lib/routewatch if running as root, otherwise use XDG_STATE_HOME
@@ -88,6 +101,7 @@ func getStateDirectory() (string, error) {
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "state", "routewatch"), nil
default:
return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
@@ -154,19 +168,23 @@ func (s *Snapshotter) TakeSnapshot() error {
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()
_ = tempFile.Close()
// Clean up temp file if it still exists
os.Remove(tempPath)
_ = os.Remove(tempPath)
}()
// Create gzip writer
gzipWriter := gzip.NewWriter(tempFile)
gzipWriter.Header.Comment = fmt.Sprintf("RouteWatch snapshot taken at %s", snapshot.Timestamp.Format(time.RFC3339))
gzipWriter.Comment = fmt.Sprintf("RouteWatch snapshot taken at %s", snapshot.Timestamp.Format(time.RFC3339))
// Write compressed data
if _, err := gzipWriter.Write(jsonData); err != nil {
@@ -213,7 +231,9 @@ func (s *Snapshotter) Shutdown() error {
s.logger.Info("Shutting down snapshotter")
// Cancel context to stop periodic snapshots
s.cancel()
if s.cancel != nil {
s.cancel()
}
// Wait for periodic snapshot goroutine to finish
s.wg.Wait()
@@ -230,6 +250,7 @@ func (s *Snapshotter) Shutdown() error {
func (s *Snapshotter) GetLastSnapshotTime() time.Time {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastSnapshot
}