Implement IP API daemon with GeoIP database support
- Create modular architecture with separate packages for config, database, HTTP, logging, and state management - Implement Cobra CLI with daemon command - Set up Uber FX dependency injection - Add Chi router with health check and IP lookup endpoints - Implement GeoIP database downloader with automatic updates - Add state persistence for tracking database download times - Include comprehensive test coverage for all components - Configure structured logging with slog - Add Makefile with test, lint, and build targets - Support both IPv4 and IPv6 lookups - Return country, city, ASN, and location data in JSON format
This commit is contained in:
134
internal/state/state.go
Normal file
134
internal/state/state.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Package state manages daemon state persistence.
|
||||
package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/ipapi/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
stateFileName = "daemon.json"
|
||||
dirPermissions = 0750
|
||||
filePermissions = 0600
|
||||
)
|
||||
|
||||
// State holds the daemon's persistent state information.
|
||||
type State struct {
|
||||
LastASNDownload time.Time `json:"lastAsnDownload"`
|
||||
LastCityDownload time.Time `json:"lastCityDownload"`
|
||||
LastCountryDownload time.Time `json:"lastCountryDownload"`
|
||||
}
|
||||
|
||||
// Manager handles state file operations.
|
||||
type Manager struct {
|
||||
config *config.Config
|
||||
logger *slog.Logger
|
||||
statePath string
|
||||
}
|
||||
|
||||
// New creates a new state manager.
|
||||
func New(cfg *config.Config, logger *slog.Logger) (*Manager, error) {
|
||||
statePath := filepath.Join(cfg.StateDir, stateFileName)
|
||||
|
||||
return &Manager{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
statePath: statePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Initialize ensures the state directory exists.
|
||||
func (m *Manager) Initialize(_ context.Context) error {
|
||||
// Ensure state directory exists
|
||||
dir := filepath.Dir(m.statePath)
|
||||
if err := os.MkdirAll(dir, dirPermissions); err != nil {
|
||||
return fmt.Errorf("failed to create state directory: %w", err)
|
||||
}
|
||||
|
||||
m.logger.Info("State manager initialized", "path", m.statePath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load reads the state from disk.
|
||||
func (m *Manager) Load() (*State, error) {
|
||||
data, err := os.ReadFile(m.statePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Return empty state if file doesn't exist
|
||||
return &State{}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to read state file: %w", err)
|
||||
}
|
||||
|
||||
var state State
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state file: %w", err)
|
||||
}
|
||||
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// Save writes the state to disk.
|
||||
func (m *Manager) Save(state *State) error {
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal state: %w", err)
|
||||
}
|
||||
|
||||
// Write to temporary file first
|
||||
tmpPath := m.statePath + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, filePermissions); err != nil {
|
||||
return fmt.Errorf("failed to write state file: %w", err)
|
||||
}
|
||||
|
||||
// Rename to final path (atomic operation)
|
||||
if err := os.Rename(tmpPath, m.statePath); err != nil {
|
||||
return fmt.Errorf("failed to save state file: %w", err)
|
||||
}
|
||||
|
||||
m.logger.Debug("State saved", "path", m.statePath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateASNDownloadTime updates the ASN database download timestamp.
|
||||
func (m *Manager) UpdateASNDownloadTime() error {
|
||||
state, err := m.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.LastASNDownload = time.Now().UTC()
|
||||
|
||||
return m.Save(state)
|
||||
}
|
||||
|
||||
// UpdateCityDownloadTime updates the City database download timestamp.
|
||||
func (m *Manager) UpdateCityDownloadTime() error {
|
||||
state, err := m.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.LastCityDownload = time.Now().UTC()
|
||||
|
||||
return m.Save(state)
|
||||
}
|
||||
|
||||
// UpdateCountryDownloadTime updates the Country database download timestamp.
|
||||
func (m *Manager) UpdateCountryDownloadTime() error {
|
||||
state, err := m.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.LastCountryDownload = time.Now().UTC()
|
||||
|
||||
return m.Save(state)
|
||||
}
|
||||
86
internal/state/state_test.go
Normal file
86
internal/state/state_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/ipapi/internal/config"
|
||||
)
|
||||
|
||||
func TestManager(t *testing.T) {
|
||||
// Create temp directory for testing
|
||||
tmpDir, err := os.MkdirTemp("", "ipapi-state-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
StateDir: tmpDir,
|
||||
}
|
||||
logger := slog.Default()
|
||||
|
||||
m, err := New(cfg, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create manager: %v", err)
|
||||
}
|
||||
|
||||
// Test Initialize
|
||||
ctx := context.Background()
|
||||
if err := m.Initialize(ctx); err != nil {
|
||||
t.Fatalf("failed to initialize: %v", err)
|
||||
}
|
||||
|
||||
// Test Load with non-existent file
|
||||
state, err := m.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load empty state: %v", err)
|
||||
}
|
||||
if !state.LastASNDownload.IsZero() {
|
||||
t.Error("expected zero time for new state")
|
||||
}
|
||||
|
||||
// Test Save and Load
|
||||
now := time.Now().UTC()
|
||||
state.LastASNDownload = now
|
||||
state.LastCityDownload = now
|
||||
state.LastCountryDownload = now
|
||||
|
||||
if err := m.Save(state); err != nil {
|
||||
t.Fatalf("failed to save state: %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
statePath := filepath.Join(tmpDir, stateFileName)
|
||||
if _, err := os.Stat(statePath); os.IsNotExist(err) {
|
||||
t.Error("state file was not created")
|
||||
}
|
||||
|
||||
// Load and verify
|
||||
loaded, err := m.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load saved state: %v", err)
|
||||
}
|
||||
|
||||
if !loaded.LastASNDownload.Equal(now) {
|
||||
t.Errorf("ASN download time mismatch: got %v, want %v", loaded.LastASNDownload, now)
|
||||
}
|
||||
|
||||
// Test update methods
|
||||
if err := m.UpdateASNDownloadTime(); err != nil {
|
||||
t.Fatalf("failed to update ASN download time: %v", err)
|
||||
}
|
||||
|
||||
loaded, err = m.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load after update: %v", err)
|
||||
}
|
||||
|
||||
if loaded.LastASNDownload.Before(now) || loaded.LastASNDownload.Equal(now) {
|
||||
t.Error("ASN download time was not updated")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user