fix(database): make cross-db migration lossless, transactional, and pre-checked

migrationModels was missing ClientGroup and ClientGlobalTraffic, so both
migration directions silently dropped client groups and global client
traffic; the model list is now extracted to allModels and a parity test
keeps the two lists from drifting again. MigrateData runs its truncate
and copy inside one transaction so a failed import rolls back instead of
leaving the destination truncated (sequences resync after commit since
setval is non-transactional). New PrepareSQLiteForMigration rejects
uploads that are not a panel database and AutoMigrates old backups so
their missing tables cannot break the row copy.
This commit is contained in:
MHSanaei
2026-07-12 20:14:07 +02:00
parent 30b611614b
commit 54fc0fd47c
5 changed files with 242 additions and 31 deletions
+67 -25
View File
@@ -25,7 +25,8 @@ import (
// related tests.
//
// Important: When adding a new top-level model (like OutboundSubscription),
// you must add it here **in addition to** the list in internal/database/db.go:initModels().
// you must add it here **in addition to** allModels() in internal/database/db.go;
// TestMigrationModelsMatchPanelModels fails when the two lists drift apart.
// This list is used for:
// - Creating the destination schema during cross-DB migration
// - Truncating tables
@@ -48,17 +49,21 @@ func migrationModels() []any {
&model.ClientRecord{},
&model.ClientInbound{},
&model.ClientExternalLink{},
&model.ClientGroup{},
&model.InboundFallback{},
&model.Host{},
&model.NodeClientTraffic{},
&model.NodeClientIp{},
&model.ClientGlobalTraffic{},
&model.OutboundSubscription{},
}
}
// MigrateData copies every row from the configured SQLite file at srcPath into
// a fresh PostgreSQL database described by dstDSN. The destination tables are
// (re)created with AutoMigrate before the copy. Source data is left untouched.
// (re)created with AutoMigrate; truncate and copy then run in one transaction,
// so a failed migration leaves the destination data unchanged. Source data is
// left untouched.
func MigrateData(srcPath, dstDSN string) error {
if _, err := os.Stat(srcPath); err != nil {
return fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
@@ -100,33 +105,41 @@ func MigrateData(srcPath, dstDSN string) error {
}
}
// AutoMigrate re-creates the legacy client_traffics -> inbounds foreign key,
// but the running panel drops it (see dropLegacyForeignKeys) and tolerates
// client_traffics rows whose inbound was deleted. Drop it here too so copying
// such orphaned rows can't fail with an fk_inbounds_client_stats violation.
if err := dst.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil {
return fmt.Errorf("drop legacy foreign key: %w", err)
}
// Empty the destination tables so the migration is idempotent: a fresh
// PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior
// panel start, and a partially-failed earlier run leaves rows behind. Either
// way a plain INSERT with explicit ids would collide on users_pkey, so clear
// our tables (only) before copying.
if err := truncatePostgresTables(dst, migrationModels()); err != nil {
return fmt.Errorf("clear destination tables: %w", err)
}
totalRows := 0
for _, m := range migrationModels() {
n, err := copyTable(src, dst, m)
if err != nil {
return fmt.Errorf("copy %T: %w", m, err)
txErr := dst.Transaction(func(tx *gorm.DB) error {
// AutoMigrate re-creates the legacy client_traffics -> inbounds foreign key,
// but the running panel drops it (see dropLegacyForeignKeys) and tolerates
// client_traffics rows whose inbound was deleted. Drop it here too so copying
// such orphaned rows can't fail with an fk_inbounds_client_stats violation.
if err := tx.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil {
return fmt.Errorf("drop legacy foreign key: %w", err)
}
totalRows += n
log.Printf(" %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
// Empty the destination tables before copying: a fresh PostgreSQL DB
// already holds an auto-seeded admin (id=1) from any prior panel start,
// so a plain INSERT with explicit ids would collide on users_pkey. Only
// the panel's own tables are cleared, and a failure anywhere in this
// transaction rolls the clear back with everything else.
if err := truncatePostgresTables(tx, migrationModels()); err != nil {
return fmt.Errorf("clear destination tables: %w", err)
}
for _, m := range migrationModels() {
n, err := copyTable(src, tx, m)
if err != nil {
return fmt.Errorf("copy %T: %w", m, err)
}
totalRows += n
log.Printf(" %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
}
return nil
})
if txErr != nil {
return txErr
}
// setval is never rolled back by PostgreSQL, so sequences are resynced only
// after the transaction has committed.
if err := resetPostgresSequences(dst); err != nil {
log.Printf("warning: failed to reset some postgres sequences: %v", err)
}
@@ -301,3 +314,32 @@ func tableWithIdColumn(db *gorm.DB, m any) (string, bool) {
}
return stmt.Table, true
}
// PrepareSQLiteForMigration rejects SQLite files that are not a panel database
// before the caller causes any downtime, then AutoMigrates the panel schema
// onto the file so backups from older versions gain the newer tables and
// columns the row copy reads. Data-level upgrades are not needed here: they
// run dialect-agnostically on the destination via InitDB after the import.
func PrepareSQLiteForMigration(dbPath string) error {
gdb, err := gorm.Open(sqlite.Open(dbPath+"?_busy_timeout=10000"), &gorm.Config{Logger: logger.Discard})
if err != nil {
return err
}
sqlDB, err := gdb.DB()
if err != nil {
return err
}
defer sqlDB.Close()
for _, table := range []string{"users", "settings", "inbounds"} {
if !sqliteTableExists(sqlDB, table) {
return fmt.Errorf("not a 3x-ui panel database: required table %q is missing", table)
}
}
for _, m := range migrationModels() {
if err := gdb.AutoMigrate(m); err != nil && !isIgnorableDuplicateColumnErr(gdb, err, m) {
return fmt.Errorf("upgrade panel schema for %T: %w", m, err)
}
}
return nil
}