1
0

migrate_data.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. package database
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "log"
  7. "os"
  8. "path"
  9. "reflect"
  10. "strings"
  11. "time"
  12. "github.com/mhsanaei/3x-ui/v3/database/model"
  13. "github.com/mhsanaei/3x-ui/v3/xray"
  14. "gorm.io/driver/postgres"
  15. "gorm.io/driver/sqlite"
  16. "gorm.io/gorm"
  17. "gorm.io/gorm/logger"
  18. )
  19. // migrationModels is the FK-aware order in which tables are created and copied.
  20. // Parents come before their children so foreign-key constraints stay satisfied
  21. // even when checks are not explicitly disabled.
  22. func migrationModels() []any {
  23. return []any{
  24. &model.User{},
  25. &model.Setting{},
  26. &model.HistoryOfSeeders{},
  27. &model.CustomGeoResource{},
  28. &model.Node{},
  29. &model.ApiToken{},
  30. &model.Inbound{},
  31. &xray.ClientTraffic{},
  32. &model.OutboundTraffics{},
  33. &model.InboundClientIps{},
  34. &model.ClientRecord{},
  35. &model.ClientInbound{},
  36. &model.InboundFallback{},
  37. &model.NodeClientTraffic{},
  38. }
  39. }
  40. // MigrateData copies every row from the configured SQLite file at srcPath into
  41. // a fresh PostgreSQL database described by dstDSN. The destination tables are
  42. // (re)created with AutoMigrate before the copy. Source data is left untouched.
  43. func MigrateData(srcPath, dstDSN string) error {
  44. if _, err := os.Stat(srcPath); err != nil {
  45. return fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
  46. }
  47. if dstDSN == "" {
  48. return errors.New("destination DSN is required")
  49. }
  50. if err := os.MkdirAll(path.Dir(srcPath), 0755); err != nil {
  51. return err
  52. }
  53. srcDSN := srcPath + "?_journal_mode=WAL&_busy_timeout=10000"
  54. src, err := gorm.Open(sqlite.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
  55. if err != nil {
  56. return fmt.Errorf("open sqlite source: %w", err)
  57. }
  58. srcSQL, err := src.DB()
  59. if err != nil {
  60. return err
  61. }
  62. defer srcSQL.Close()
  63. dst, err := gorm.Open(postgres.Open(dstDSN), &gorm.Config{Logger: logger.Discard})
  64. if err != nil {
  65. return fmt.Errorf("open postgres destination: %w", err)
  66. }
  67. dstSQL, err := dst.DB()
  68. if err != nil {
  69. return err
  70. }
  71. defer dstSQL.Close()
  72. dstSQL.SetConnMaxLifetime(time.Hour)
  73. log.Println("Creating destination schema...")
  74. for _, m := range migrationModels() {
  75. if err := dst.AutoMigrate(m); err != nil {
  76. return fmt.Errorf("AutoMigrate %T: %w", m, err)
  77. }
  78. }
  79. // AutoMigrate re-creates the legacy client_traffics -> inbounds foreign key,
  80. // but the running panel drops it (see dropLegacyForeignKeys) and tolerates
  81. // client_traffics rows whose inbound was deleted. Drop it here too so copying
  82. // such orphaned rows can't fail with an fk_inbounds_client_stats violation.
  83. if err := dst.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil {
  84. return fmt.Errorf("drop legacy foreign key: %w", err)
  85. }
  86. // Empty the destination tables so the migration is idempotent: a fresh
  87. // PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior
  88. // panel start, and a partially-failed earlier run leaves rows behind. Either
  89. // way a plain INSERT with explicit ids would collide on users_pkey, so clear
  90. // our tables (only) before copying.
  91. if err := truncatePostgresTables(dst, migrationModels()); err != nil {
  92. return fmt.Errorf("clear destination tables: %w", err)
  93. }
  94. totalRows := 0
  95. for _, m := range migrationModels() {
  96. n, err := copyTable(src, dst, m)
  97. if err != nil {
  98. return fmt.Errorf("copy %T: %w", m, err)
  99. }
  100. totalRows += n
  101. log.Printf(" %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
  102. }
  103. if err := resetPostgresSequences(dst); err != nil {
  104. log.Printf("warning: failed to reset some postgres sequences: %v", err)
  105. }
  106. log.Printf("Migration complete: %d rows across %d tables.", totalRows, len(migrationModels()))
  107. log.Println("Set XUI_DB_TYPE=postgres and XUI_DB_DSN=... in /etc/default/x-ui, then restart x-ui.")
  108. return nil
  109. }
  110. // ExportPostgresToSQLite copies every row from the PostgreSQL database described
  111. // by srcDSN into a fresh SQLite file at dstPath. It is the reverse of
  112. // MigrateData and is used to hand a PostgreSQL-backed panel a portable .db file.
  113. // dstPath is created/overwritten; the PostgreSQL source is left untouched.
  114. func ExportPostgresToSQLite(srcDSN, dstPath string) error {
  115. if srcDSN == "" {
  116. return errors.New("source DSN is required")
  117. }
  118. if err := os.MkdirAll(path.Dir(dstPath), 0755); err != nil {
  119. return err
  120. }
  121. // Start from an empty file so AutoMigrate creates the canonical schema.
  122. if err := os.Remove(dstPath); err != nil && !os.IsNotExist(err) {
  123. return err
  124. }
  125. src, err := gorm.Open(postgres.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
  126. if err != nil {
  127. return fmt.Errorf("open postgres source: %w", err)
  128. }
  129. srcSQL, err := src.DB()
  130. if err != nil {
  131. return err
  132. }
  133. defer srcSQL.Close()
  134. // No WAL: keep all data in the main file so it is complete once closed.
  135. dst, err := gorm.Open(sqlite.Open(dstPath+"?_busy_timeout=10000"), &gorm.Config{Logger: logger.Discard})
  136. if err != nil {
  137. return fmt.Errorf("open sqlite destination: %w", err)
  138. }
  139. dstSQL, err := dst.DB()
  140. if err != nil {
  141. return err
  142. }
  143. defer dstSQL.Close()
  144. return copyAllModels(src, dst)
  145. }
  146. // copyAllModels (re)creates the schema on dst and copies every migrated table
  147. // from src to dst in FK-safe order. src/dst may be any gorm backend.
  148. func copyAllModels(src, dst *gorm.DB) error {
  149. for _, m := range migrationModels() {
  150. if err := dst.AutoMigrate(m); err != nil {
  151. return fmt.Errorf("AutoMigrate %T: %w", m, err)
  152. }
  153. }
  154. for _, m := range migrationModels() {
  155. if _, err := copyTable(src, dst, m); err != nil {
  156. return fmt.Errorf("copy %T: %w", m, err)
  157. }
  158. }
  159. return nil
  160. }
  161. func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
  162. const batchSize = 500
  163. sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
  164. stmt := &gorm.Statement{DB: src}
  165. if err := stmt.Parse(mdl); err != nil {
  166. return 0, err
  167. }
  168. order := strings.Join(stmt.Schema.PrimaryFieldDBNames, ", ")
  169. table := stmt.Schema.Table
  170. columns := stmt.Schema.DBNames
  171. ctx := context.Background()
  172. total := 0
  173. for offset := 0; ; offset += batchSize {
  174. batchPtr := reflect.New(sliceType)
  175. q := src.Model(mdl).Limit(batchSize).Offset(offset)
  176. if order != "" {
  177. q = q.Order(order)
  178. }
  179. if err := q.Find(batchPtr.Interface()).Error; err != nil {
  180. return total, err
  181. }
  182. slice := batchPtr.Elem()
  183. n := slice.Len()
  184. if n == 0 {
  185. break
  186. }
  187. rows := make([]map[string]any, n)
  188. for i := 0; i < n; i++ {
  189. rv := reflect.Indirect(slice.Index(i))
  190. row := make(map[string]any, len(columns))
  191. for _, name := range columns {
  192. value, _ := stmt.Schema.FieldsByDBName[name].ValueOf(ctx, rv)
  193. row[name] = value
  194. }
  195. rows[i] = row
  196. }
  197. if err := dst.Table(table).CreateInBatches(rows, 200).Error; err != nil {
  198. return total, err
  199. }
  200. total += n
  201. if n < batchSize {
  202. break
  203. }
  204. }
  205. return total, nil
  206. }
  207. // truncatePostgresTables empties every migrated table on dst in a single
  208. // statement, resetting identity sequences. CASCADE covers the inbound/client
  209. // foreign keys regardless of insertion order. Only the panel's own tables are
  210. // touched, never the rest of the schema.
  211. func truncatePostgresTables(dst *gorm.DB, models []any) error {
  212. tables := make([]string, 0, len(models))
  213. for _, m := range models {
  214. stmt := &gorm.Statement{DB: dst}
  215. if err := stmt.Parse(m); err != nil {
  216. return err
  217. }
  218. tables = append(tables, `"`+stmt.Schema.Table+`"`)
  219. }
  220. if len(tables) == 0 {
  221. return nil
  222. }
  223. log.Println("Clearing destination tables...")
  224. return dst.Exec("TRUNCATE TABLE " + strings.Join(tables, ", ") + " RESTART IDENTITY CASCADE").Error
  225. }
  226. // resetPostgresSequences advances each migrated table's id sequence past MAX(id),
  227. // otherwise the next INSERT-without-id would clash with copied rows.
  228. func resetPostgresSequences(dst *gorm.DB) error {
  229. return resyncPostgresSequences(dst, migrationModels())
  230. }
  231. // resyncPostgresSequences sets each model's id sequence to MAX(id) so the next
  232. // auto-increment INSERT won't collide with an existing row. Table names are
  233. // resolved from the models themselves (not hardcoded), so they always match the
  234. // migrated tables. The statement is a no-op for tables without an id sequence
  235. // (e.g. composite-PK tables), and idempotent on a healthy DB, so it is safe to
  236. // run both after migration and on every Postgres startup.
  237. func resyncPostgresSequences(db *gorm.DB, models []any) error {
  238. for _, m := range models {
  239. stmt := &gorm.Statement{DB: db}
  240. if err := stmt.Parse(m); err != nil {
  241. continue
  242. }
  243. t := stmt.Table
  244. // t comes from the trusted model set parsed by GORM, not user input, so
  245. // interpolating it as an identifier is safe. We ignore errors per-table.
  246. _ = db.Exec(
  247. `SELECT setval(pg_get_serial_sequence(?, 'id'), COALESCE((SELECT MAX(id) FROM "`+t+`"), 1), true)
  248. WHERE pg_get_serial_sequence(?, 'id') IS NOT NULL`,
  249. t, t,
  250. ).Error
  251. }
  252. return nil
  253. }