Pulse/internal/config/migration.go
rcourtman 3ea5f54d93 chore: fix outdated comment in migration.go
RunMigrationIfNeeded IS called from pkg/server/server.go, so
removed the misleading comment about it being dormant.
2026-01-24 23:27:09 +00:00

194 lines
5.7 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/rs/zerolog/log"
)
// MigrationStatus tracks the status of a data migration.
type MigrationStatus struct {
Version string `json:"version"`
MigratedAt time.Time `json:"migratedAt"`
SourceFiles []string `json:"sourceFiles"`
TargetDir string `json:"targetDir"`
}
// filesToMigrate lists files that should be moved to the default org directory.
// NOTE: If adding new files to migrate, .encryption.key should be handled first.
var filesToMigrate = []string{
"nodes.enc",
"system.json",
"alerts.json",
"notifications.json",
"audit.db",
}
// MigrateToMultiTenant migrates existing data to the multi-tenant directory structure.
// It moves files from the root data directory to /orgs/default/ and creates symlinks
// for backward compatibility.
//
// Migration structure:
//
// /data/
// orgs/
// default/
// nodes.enc <- moved from /data/nodes.enc
// system.json <- moved from /data/system.json
// alerts.json <- moved from /data/alerts.json
// nodes.enc <- symlink to orgs/default/nodes.enc
// system.json <- symlink to orgs/default/system.json
// alerts.json <- symlink to orgs/default/alerts.json
func MigrateToMultiTenant(dataDir string) error {
if dataDir == "" {
return fmt.Errorf("data directory cannot be empty")
}
// Check if migration is needed
defaultOrgDir := filepath.Join(dataDir, "orgs", "default")
migrationMarker := filepath.Join(defaultOrgDir, ".migrated")
// If migration marker exists, migration has already been completed
if _, err := os.Stat(migrationMarker); err == nil {
log.Debug().Str("data_dir", dataDir).Msg("Multi-tenant migration already completed")
return nil
}
log.Info().Str("data_dir", dataDir).Msg("Starting multi-tenant data migration")
// Create the default org directory
if err := os.MkdirAll(defaultOrgDir, 0755); err != nil {
return fmt.Errorf("failed to create default org directory: %w", err)
}
migratedFiles := []string{}
skippedFiles := []string{}
// Migrate each file
for _, filename := range filesToMigrate {
srcPath := filepath.Join(dataDir, filename)
dstPath := filepath.Join(defaultOrgDir, filename)
// Check if source file exists
srcInfo, err := os.Lstat(srcPath)
if os.IsNotExist(err) {
log.Debug().Str("file", filename).Msg("File does not exist, skipping")
skippedFiles = append(skippedFiles, filename)
continue
}
if err != nil {
log.Warn().Err(err).Str("file", filename).Msg("Error checking source file, skipping")
skippedFiles = append(skippedFiles, filename)
continue
}
// Skip if source is already a symlink (already migrated)
if srcInfo.Mode()&os.ModeSymlink != 0 {
log.Debug().Str("file", filename).Msg("File is already a symlink, skipping")
skippedFiles = append(skippedFiles, filename)
continue
}
// Check if destination already exists
if _, err := os.Stat(dstPath); err == nil {
log.Warn().Str("file", filename).Msg("Destination already exists, skipping")
skippedFiles = append(skippedFiles, filename)
continue
}
// Move the file
if err := os.Rename(srcPath, dstPath); err != nil {
// If rename fails (cross-device), try copy + delete
if err := copyFile(srcPath, dstPath); err != nil {
log.Error().Err(err).Str("file", filename).Msg("Failed to migrate file")
continue
}
if err := os.Remove(srcPath); err != nil {
log.Warn().Err(err).Str("file", filename).Msg("Failed to remove original file after copy")
}
}
log.Info().Str("file", filename).Msg("Migrated file to default org directory")
migratedFiles = append(migratedFiles, filename)
// Create symlink for backward compatibility
relPath, _ := filepath.Rel(dataDir, dstPath)
if err := os.Symlink(relPath, srcPath); err != nil {
log.Warn().Err(err).Str("file", filename).Msg("Failed to create symlink for backward compatibility")
} else {
log.Debug().Str("file", filename).Str("target", relPath).Msg("Created symlink for backward compatibility")
}
}
// Create migration marker
markerContent := fmt.Sprintf("migrated_at=%s\nversion=1.0\nmigrated_files=%d\nskipped_files=%d\n",
time.Now().Format(time.RFC3339), len(migratedFiles), len(skippedFiles))
if err := os.WriteFile(migrationMarker, []byte(markerContent), 0644); err != nil {
log.Warn().Err(err).Msg("Failed to write migration marker")
}
log.Info().
Int("migrated", len(migratedFiles)).
Int("skipped", len(skippedFiles)).
Str("data_dir", dataDir).
Msg("Multi-tenant data migration completed")
return nil
}
// copyFile copies a file from src to dst.
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
// Get original file permissions
info, err := os.Stat(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, info.Mode())
}
// IsMigrationNeeded checks if multi-tenant migration is needed.
func IsMigrationNeeded(dataDir string) bool {
if dataDir == "" {
return false
}
defaultOrgDir := filepath.Join(dataDir, "orgs", "default")
migrationMarker := filepath.Join(defaultOrgDir, ".migrated")
// If marker exists, migration is not needed
if _, err := os.Stat(migrationMarker); err == nil {
return false
}
// Check if any files to migrate exist
for _, filename := range filesToMigrate {
srcPath := filepath.Join(dataDir, filename)
info, err := os.Lstat(srcPath)
if err != nil {
continue
}
// File exists and is not a symlink - migration needed
if info.Mode()&os.ModeSymlink == 0 {
return true
}
}
return false
}
// RunMigrationIfNeeded checks if migration is needed and runs it.
func RunMigrationIfNeeded(dataDir string) error {
if !IsMigrationNeeded(dataDir) {
return nil
}
return MigrateToMultiTenant(dataDir)
}