201 lines
5.9 KiB
Go
201 lines
5.9 KiB
Go
package services
|
|
|
|
import (
|
|
"fmt"
|
|
jsonparser "github.com/knadh/koanf/parsers/json"
|
|
"github.com/knadh/koanf/providers/structs"
|
|
"github.com/knadh/koanf/v2"
|
|
"github.com/wailsapp/wails/v3/pkg/services/log"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// BackupFilePattern backup file name pattern
|
|
BackupFilePattern = "%s.backup.%s.json"
|
|
// MaxConfigFileSize maximum config file size
|
|
MaxConfigFileSize = 10 * 1024 * 1024 // 10MB
|
|
)
|
|
|
|
// ConfigMigrator elegant configuration migrator with automatic field detection
|
|
type ConfigMigrator struct {
|
|
logger *log.LogService
|
|
configDir string
|
|
configName string
|
|
configPath string
|
|
}
|
|
|
|
// MigrationResult migration operation result
|
|
type MigrationResult struct {
|
|
Migrated bool `json:"migrated"` // Whether migration was performed
|
|
MissingFields []string `json:"missingFields"` // Fields that were missing
|
|
BackupPath string `json:"backupPath"` // Path to backup file
|
|
Description string `json:"description"` // Description of migration
|
|
}
|
|
|
|
// NewConfigMigrator creates a new configuration migrator
|
|
func NewConfigMigrator(
|
|
logger *log.LogService,
|
|
configDir string,
|
|
configName, configPath string,
|
|
) *ConfigMigrator {
|
|
if logger == nil {
|
|
logger = log.New()
|
|
}
|
|
return &ConfigMigrator{
|
|
logger: logger,
|
|
configDir: configDir,
|
|
configName: configName,
|
|
configPath: configPath,
|
|
}
|
|
}
|
|
|
|
// AutoMigrate automatically detects and migrates missing configuration fields
|
|
func (cm *ConfigMigrator) AutoMigrate(defaultConfig interface{}, currentConfig *koanf.Koanf) (*MigrationResult, error) {
|
|
defaultKoanf := koanf.New(".")
|
|
if err := defaultKoanf.Load(structs.Provider(defaultConfig, "json"), nil); err != nil {
|
|
return nil, fmt.Errorf("failed to load default config: %w", err)
|
|
}
|
|
|
|
// Detect missing fields
|
|
missingFields := cm.detectMissingFields(currentConfig.All(), defaultKoanf.All())
|
|
|
|
// Create result object
|
|
result := &MigrationResult{
|
|
MissingFields: missingFields,
|
|
Migrated: len(missingFields) > 0,
|
|
Description: fmt.Sprintf("Found %d missing fields", len(missingFields)),
|
|
}
|
|
|
|
// No migration needed
|
|
if !result.Migrated {
|
|
return result, nil
|
|
}
|
|
|
|
// Create backup before migration
|
|
backupPath, err := cm.createBackup()
|
|
if err != nil {
|
|
return result, fmt.Errorf("backup creation failed: %w", err)
|
|
}
|
|
result.BackupPath = backupPath
|
|
|
|
// Merge missing fields from default config
|
|
if err := cm.mergeDefaultFields(currentConfig, defaultKoanf, missingFields); err != nil {
|
|
return result, fmt.Errorf("failed to merge default fields: %w", err)
|
|
}
|
|
|
|
// Save updated config
|
|
if err := cm.saveConfig(currentConfig); err != nil {
|
|
return result, fmt.Errorf("failed to save updated config: %w", err)
|
|
}
|
|
|
|
// Clean up backup on success
|
|
if backupPath != "" {
|
|
if err := os.Remove(backupPath); err != nil {
|
|
cm.logger.Error("Failed to remove backup", "error", err)
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// detectMissingFields detects missing configuration fields
|
|
func (cm *ConfigMigrator) detectMissingFields(current, defaultConfig map[string]interface{}) []string {
|
|
var missing []string
|
|
cm.findMissing("", defaultConfig, current, &missing)
|
|
return missing
|
|
}
|
|
|
|
// findMissing recursively finds missing fields
|
|
func (cm *ConfigMigrator) findMissing(prefix string, defaultMap, currentMap map[string]interface{}, missing *[]string) {
|
|
for key, defaultVal := range defaultMap {
|
|
fullKey := key
|
|
if prefix != "" {
|
|
fullKey = prefix + "." + key
|
|
}
|
|
|
|
currentVal, exists := currentMap[key]
|
|
if !exists {
|
|
// Field completely missing - add it
|
|
*missing = append(*missing, fullKey)
|
|
} else if defaultNestedMap, ok := defaultVal.(map[string]interface{}); ok {
|
|
if currentNestedMap, ok := currentVal.(map[string]interface{}); ok {
|
|
// Both are maps, recurse into them
|
|
cm.findMissing(fullKey, defaultNestedMap, currentNestedMap, missing)
|
|
}
|
|
// Type mismatch: user has different type, don't recurse
|
|
}
|
|
// For non-map default values, field exists, preserve user's value
|
|
}
|
|
}
|
|
|
|
// mergeDefaultFields merges default values for missing fields into current config
|
|
func (cm *ConfigMigrator) mergeDefaultFields(current, defaultConfig *koanf.Koanf, missingFields []string) error {
|
|
actuallyMerged := 0
|
|
|
|
for _, field := range missingFields {
|
|
if defaultConfig.Exists(field) {
|
|
if defaultValue := defaultConfig.Get(field); defaultValue != nil {
|
|
// Always set the field, even if it causes type conflicts
|
|
// This allows configuration structure evolution during upgrades
|
|
current.Set(field, defaultValue)
|
|
actuallyMerged++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update timestamp if we actually merged fields
|
|
if actuallyMerged > 0 {
|
|
current.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createBackup creates a backup of the configuration file
|
|
func (cm *ConfigMigrator) createBackup() (string, error) {
|
|
if _, err := os.Stat(cm.configPath); os.IsNotExist(err) {
|
|
return "", nil
|
|
}
|
|
|
|
timestamp := time.Now().Format("20060102150405")
|
|
backupPath := filepath.Join(cm.configDir, fmt.Sprintf(BackupFilePattern, cm.configName, timestamp))
|
|
|
|
data, err := os.ReadFile(cm.configPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read config file: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(backupPath, data, 0644); err != nil {
|
|
return "", fmt.Errorf("failed to create backup: %w", err)
|
|
}
|
|
|
|
return backupPath, nil
|
|
}
|
|
|
|
// saveConfig saves configuration to file
|
|
func (cm *ConfigMigrator) saveConfig(config *koanf.Koanf) error {
|
|
configBytes, err := config.Marshal(jsonparser.Parser())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal config: %w", err)
|
|
}
|
|
|
|
if len(configBytes) > MaxConfigFileSize {
|
|
return fmt.Errorf("config size (%d bytes) exceeds limit (%d bytes)", len(configBytes), MaxConfigFileSize)
|
|
}
|
|
|
|
// Atomic write
|
|
tempPath := cm.configPath + ".tmp"
|
|
if err := os.WriteFile(tempPath, configBytes, 0644); err != nil {
|
|
return fmt.Errorf("failed to write temp config: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tempPath, cm.configPath); err != nil {
|
|
os.Remove(tempPath)
|
|
return fmt.Errorf("failed to rename temp config: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|