package swmigration import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "errors" "fmt" "log/slog" "sort" "strings" "time" "gorm.io/gorm" scripts "kra/deploy/migrations" "kra/pkg/database/migration" "kra/pkg/module" ) // Recognized identifies an old SW database or a previous automatic upgrade. // Database names are not used to infer ownership of unrelated schemas. func Recognized(db *gorm.DB) (bool, error) { if db.Dialector.Name() != "mysql" { return false, nil } tables, err := tableSet(db) if err != nil { return false, err } if tables["sw_kra_state"] || tables["sys_user"] && tables["tab_sw_report_day"] && tables["tab_sw_report_server_day"] { return true, nil } if !tables[TableName] { return false, nil } // The state table also exists in new template installations. Only a // recorded legacy workflow identifies a previously upgraded SW database. var count int64 if err := db.Table(TableName).Where("name=?", workflowName).Count(&count).Error; err != nil { return false, err } return count > 0, nil } func tableSet(db *gorm.DB) (map[string]bool, error) { var names []string if err := db.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() AND table_type='BASE TABLE'").Scan(&names).Error; err != nil { return nil, err } set := map[string]bool{} for _, name := range names { set[name] = true } return set, nil } func Run(ctx context.Context, db *gorm.DB, catalog module.Catalog, backupRoot string, logger *slog.Logger) (runErr error) { if logger == nil { logger = slog.Default() } db = db.Session(&gorm.Session{NewDB: true}).WithContext(ctx) pool, err := db.DB() if err != nil { return err } conn, err := pool.Conn(ctx) if err != nil { return err } defer conn.Close() var schema, serverUUID string if err = conn.QueryRowContext(ctx, "SELECT DATABASE(),@@server_uuid").Scan(&schema, &serverUUID); err != nil { return err } if schema == "" { return fmt.Errorf("automatic upgrade requires a selected database") } var lockName string if err = conn.QueryRowContext(ctx, "SELECT CONCAT('sw_to_kra:',MD5(DATABASE()))").Scan(&lockName); err != nil { return err } for { var locked sql.NullInt64 if err = conn.QueryRowContext(ctx, "SELECT GET_LOCK(?,10)", lockName).Scan(&locked); err != nil { return err } if locked.Valid && locked.Int64 == 1 { break } if !locked.Valid { return fmt.Errorf("database migration lock is unavailable") } logger.Info("waiting for another database upgrade", "database", schema) } defer func() { clean, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() _, _ = conn.ExecContext(clean, "DO RELEASE_LOCK(?)", lockName) }() if err = StateMigration().Migrate(db); err != nil { return err } state := statePO{Name: workflowName} err = db.First(&state, "name=?", workflowName).Error if errors.Is(err, gorm.ErrRecordNotFound) { state = statePO{Name: workflowName, Version: workflowVersion, Phase: "backup", DatabaseIdentity: serverUUID + ":" + schema, StartedAt: time.Now(), UpdatedAt: time.Now()} initial, readErr := tableSet(db) if readErr != nil { return readErr } state.SourceWasLegacy = initial["sys_user"] if initial["sw_kra_state"] { var phase string if err := db.Table("sw_kra_state").Select("phase").Where("id=1").Scan(&phase).Error; err != nil { return err } state.SourceWasLegacy = state.SourceWasLegacy || phase != "completed" } if err = db.Create(&state).Error; err != nil { return err } } else if err != nil { return err } if state.Version != workflowVersion || state.DatabaseIdentity != serverUUID+":"+schema { return fmt.Errorf("automatic upgrade identity/version does not match this database") } started := time.Now() logger.Info("automatic business database upgrade started", "database", schema, "phase", state.Phase) defer func() { status := "completed" if runErr != nil { status = "failed" clean, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() _ = db.Session(&gorm.Session{NewDB: true}).WithContext(clean).Model(&statePO{}).Where("name=?", workflowName).Updates(map[string]any{"last_error": runErr.Error(), "updated_at": time.Now()}).Error } logger.Info("automatic business database upgrade finished", "database", schema, "status", status, "elapsed_ms", time.Since(started).Milliseconds()) }() save := func() error { state.UpdatedAt = time.Now(); return db.Save(&state).Error } stage := func(name string, work func() error) error { state.Phase = name state.LastError = "" if err := save(); err != nil { return err } begin := time.Now() logger.Info("database upgrade stage started", "stage", name) done := make(chan struct{}) stopped := make(chan struct{}) go func() { defer close(stopped) ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() for { select { case <-done: return case <-ticker.C: logger.Info("database upgrade is still running", "stage", name, "elapsed_ms", time.Since(begin).Milliseconds()) } } }() err := work() close(done) <-stopped if err != nil { return fmt.Errorf("database upgrade %s: %w", name, err) } logger.Info("database upgrade stage completed", "stage", name, "elapsed_ms", time.Since(begin).Milliseconds()) return nil } resumingCleanup := state.CleanupBackupPath != "" && state.VerifiedAt != nil wasComplete := state.CompletedAt != nil if !resumingCleanup && !wasComplete { if state.BackupPath == "" { if err := stage("backup", func() error { var e error state.BackupPath, state.BackupSHA256, e = backupDatabase(ctx, db, backupRoot, state.DatabaseIdentity, "source", nil, logger) return e }); err != nil { return err } if err := save(); err != nil { return err } } else if err := verifyBackup(state.BackupPath, state.BackupSHA256); err != nil { return err } if err := stage("framework", func() error { return migrateFramework(ctx, db, conn, logger) }); err != nil { return err } } if err := stage("migrations", func() error { if err := catalog.CheckPrerequisites(db); err != nil { return err } return migration.Run(db, catalog.MigrationSteps()) }); err != nil { return err } if err := stage("verify", func() error { if err := migration.CheckApplied(db, catalog.MigrationSteps()); err != nil { return err } return catalog.CheckSchema(db) }); err != nil { return err } remaining, err := remainingTargets(db) if err != nil { return err } if len(remaining) > 0 { if wasComplete { return fmt.Errorf("retired tables reappeared after a completed upgrade; automatic deletion refused") } if !resumingCleanup && !wasComplete { if err := verifyLegacyChecks(db); err != nil { return err } } if state.SourceWasLegacy && !state.TasksRestored { if err := stage("tasks", func() error { return db.Transaction(func(tx *gorm.DB) error { if err := tx.Exec("UPDATE sys_timed_tasks t JOIN sw_kra_id_map m ON m.entity='task' AND m.new_id=t.id JOIN sj_job j ON CAST(j.id AS CHAR)=m.old_id SET t.enabled=(j.job_status=1) WHERE j.deleted=0").Error; err != nil { return err } return tx.Model(&statePO{}).Where("name=?", workflowName).Update("tasks_restored", true).Error }) }); err != nil { return err } state.TasksRestored = true } if state.CleanupBackupPath == "" { if err := stage("cleanup-backup", func() error { var e error state.CleanupBackupPath, state.CleanupSHA256, e = backupDatabase(ctx, db, backupRoot, state.DatabaseIdentity, "cleanup", remaining, logger) return e }); err != nil { return err } if err := save(); err != nil { return err } } else if err := verifyBackup(state.CleanupBackupPath, state.CleanupSHA256); err != nil { return err } // The journal survives deletion of all old migration helpers. A restart // can finish a partially completed DROP list using the verified archive. now := time.Now() state.VerifiedAt = &now state.VerifiedMigrations = migrationHash(catalog.MigrationSteps()) if err := stage("cleanup", func() error { // CALL can commit before a killed process reaches DROP PROCEDURE. // Remove only our own shipped routine before checking dependencies. if _, err := conn.ExecContext(ctx, "DROP PROCEDURE IF EXISTS sw_kra_migrate_20261103"); err != nil { return err } return executeScript(ctx, conn, scripts.CleanupSQL(), true, logger) }); err != nil { return err } } if err := migration.CheckApplied(db, catalog.MigrationSteps()); err != nil { return err } if err := catalog.CheckSchema(db); err != nil { return err } remaining, err = remainingTargets(db) if err != nil { return err } if len(remaining) != 0 { return fmt.Errorf("legacy cleanup left %d tables", len(remaining)) } now := time.Now() state.Phase = "complete" if state.CompletedAt == nil { state.CompletedAt = &now } state.VerifiedAt = &now state.LastError = "" state.VerifiedMigrations = migrationHash(catalog.MigrationSteps()) return save() } func migrationHash(steps []migration.Step) string { ids := make([]string, len(steps)) for i, step := range steps { ids[i] = step.ID } sort.Strings(ids) sum := sha256.Sum256([]byte(strings.Join(ids, "\n"))) return hex.EncodeToString(sum[:]) } func remainingTargets(db *gorm.DB) (map[string]bool, error) { tables, err := tableSet(db) if err != nil { return nil, err } remaining := map[string]bool{} for _, name := range scripts.CleanupTables() { if tables[name] { remaining[name] = true } } return remaining, nil } func executeScript(ctx context.Context, conn *sql.Conn, source string, cleanup bool, logger *slog.Logger) error { statements, err := scripts.Statements(source) if err != nil { return err } for _, statement := range statements { upper := strings.ToUpper(strings.TrimSpace(statement)) if strings.HasPrefix(upper, "USE ") { continue } if cleanup && strings.HasPrefix(upper, "SET @SW_CLEANUP_EXECUTE") { statement = "SET @sw_cleanup_execute=1" } rows, err := conn.QueryContext(ctx, statement) if err != nil { return err } for { columns, err := rows.Columns() if err != nil { rows.Close() return err } for rows.Next() { values := make([]any, len(columns)) dest := make([]any, len(columns)) for i := range values { dest[i] = &values[i] } if err := rows.Scan(dest...); err != nil { rows.Close() return err } // Result sets contain progress/metadata only, never copied rows. if len(columns) > 0 && (columns[0] == "dropped_table" || columns[0] == "result") { attrs := make([]any, 0, len(columns)*2) for i, name := range columns { value := values[i] if b, ok := value.([]byte); ok { value = string(b) } attrs = append(attrs, name, value) } logger.Info("database migration SQL result", attrs...) } } if err := rows.Err(); err != nil { rows.Close() return err } if !rows.NextResultSet() { break } } if err := rows.Err(); err != nil { rows.Close() return err } rows.Close() } return nil } func verifyLegacyChecks(db *gorm.DB) error { tables, err := tableSet(db) if err != nil { return err } if !tables["sw_kra_state"] || !tables["sw_kra_checks"] { return fmt.Errorf("legacy migration evidence is missing; cleanup refused") } var completed int64 if err := db.Table("sw_kra_state").Where("id=1 AND version=? AND phase='completed' AND completed_at IS NOT NULL", "20261103_framework_migration_v5").Count(&completed).Error; err != nil { return err } if completed != 1 { return fmt.Errorf("legacy framework migration is incomplete") } if err := verifyCheckRows(db); err != nil { return err } if tables["legacy_sw_20261103_sys_user"] && tables["sw_kra_id_map"] { var archived, mapped int64 if err := db.Table("legacy_sw_20261103_sys_user").Where("del_flag='0'").Count(&archived).Error; err != nil { return err } if err := db.Table("sw_kra_id_map").Where("entity='user'").Count(&mapped).Error; err != nil { return err } if archived != mapped { return fmt.Errorf("legacy accounts changed during conversion; cleanup refused") } } return nil } func verifyCheckRows(db *gorm.DB) error { var invalid, backups, checks int64 if err := db.Table("sw_kra_checks").Where("passed<>1 OR expected_value IS NULL OR actual_value IS NULL OR expected_value<>actual_value").Count(&invalid).Error; err != nil { return err } if err := db.Table("sw_kra_checks").Where("stage='backup'").Distinct("check_name").Count(&backups).Error; err != nil { return err } if err := db.Table("sw_kra_checks").Where("stage='migration'").Distinct("check_name").Count(&checks).Error; err != nil { return err } if invalid != 0 || backups != 52 || checks != 24 { return fmt.Errorf("legacy migration validation is incomplete; cleanup refused") } return nil }