feat(migration): replace sqlx::migrate with standalone Go migrate tool

Schema is now managed by a separate Go CLI at tools/migrate/, which embeds
the SQL files copied from server/initialize/migrate/database/ and tracks
state in the shared schema_migrations table. Rust no longer owns migrations
at startup.

Rust startup:
  1. Connect to DB
  2. ALWAYS invoke `ppanel-migrate up` (idempotent — no-op if already at latest)
  3. Create admin if not present
  4. Continue with redis/queue/scheduler

Changes:
  - Add tools/migrate/ — Go module wrapping golang-migrate
    * migrate/migrate.go: Migrate() + RunUp() (RunUp bypasses the iofs vs
      os.ErrNotExist sentinel mismatch that breaks Up() on already-migrated DBs)
    * migrate/sql/{postgres,mysql}/: SQL files copied from server
    * cmd/migrate/main.go: CLI (up/down/version/force/drop)
  - Widen password column from varchar(100) → varchar(255) in
    00001_init_schema.up.sql to fit Rust's 112-char PBKDF2 hash
  - Rewrite src/migration.rs:
    * Remove sqlx::migrate!, run_migrations(), schema_present()
    * Add ensure_schema(cfg): unconditionally invokes ppanel-migrate up
  - Fix src/db.rs::build_dsn: previously used cfg.config verbatim even when
    the default config string was for the wrong dialect (PG got MySQL's
    charset=utf8mb4 string and crashed on connect)
  - Delete old migrations/{postgres,mysql}/ (no longer used)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ember Moth
2026-07-06 02:11:10 +08:00
parent 0305d058c4
commit 953a9e5409
166 changed files with 495 additions and 124 deletions
+129
View File
@@ -0,0 +1,129 @@
// ppanel-migrate is a standalone CLI for managing PPanel database schema.
//
// It uses the migrate package (copied from server/initialize/migrate) which embeds
// the SQL migration files and applies them via golang-migrate. Tracks state in the
// schema_migrations table — the same table the Go server uses — so the Rust
// ppanel-backend can attach to a database that was already initialised by either tool.
//
// Usage:
//
// ppanel-migrate -driver=postgres -dsn="postgres://..." up
// ppanel-migrate -driver=postgres -dsn="postgres://..." version
// ppanel-migrate -driver=postgres -dsn="postgres://..." force 2131
package main
import (
"errors"
"flag"
"fmt"
"log"
"os"
"strconv"
gomigrate "github.com/golang-migrate/migrate/v4"
ppmigrate "github.com/perfect-panel/ppanel-backend/tools/migrate/migrate"
)
func main() {
var (
driver = flag.String("driver", "postgres", "Database driver: postgres | mysql")
dsn = flag.String("dsn", "", "Database DSN. URL scheme (postgres:// | mysql://) is auto-prepended if absent.")
verbose = flag.Bool("v", false, "Verbose logging")
)
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "ppanel-migrate: standalone PPanel schema migration tool\n\n")
fmt.Fprintf(os.Stderr, "Usage: ppanel-migrate -driver=<postgres|mysql> -dsn=<dsn> <command> [args]\n\n")
fmt.Fprintf(os.Stderr, "Commands:\n")
fmt.Fprintf(os.Stderr, " up [N] Apply all (or N) pending migrations\n")
fmt.Fprintf(os.Stderr, " down [N] Roll back one (or N) migration\n")
fmt.Fprintf(os.Stderr, " version Print current schema version\n")
fmt.Fprintf(os.Stderr, " force <version> Mark database at <version> without running migrations\n")
fmt.Fprintf(os.Stderr, " drop Drop every object in the database (dangerous)\n\n")
flag.PrintDefaults()
}
flag.Parse()
if *dsn == "" {
flag.Usage()
os.Exit(2)
}
if *verbose {
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
}
cmd := flag.Arg(0)
sess := ppmigrate.Migrate(*driver, *dsn)
m := sess.Migrate
switch cmd {
case "", "up":
if err := ppmigrate.RunUp(sess); err != nil && !errors.Is(err, gomigrate.ErrNoChange) {
log.Fatalf("up: %v", err)
}
reportVersion(m)
case "down":
n := -parseStep(flag.Arg(1))
if err := m.Steps(n); err != nil && !errors.Is(err, gomigrate.ErrNoChange) {
log.Fatalf("down: %v", err)
}
reportVersion(m)
case "version":
v, dirty, err := m.Version()
if errors.Is(err, gomigrate.ErrNilVersion) {
fmt.Println("no schema_migrations row (database is empty / not yet migrated)")
os.Exit(0)
}
if err != nil {
log.Fatalf("version: %v", err)
}
fmt.Printf("version=%d dirty=%v\n", v, dirty)
case "force":
v, err := strconv.Atoi(flag.Arg(1))
if err != nil {
log.Fatalf("force: bad version %q: %v", flag.Arg(1), err)
}
if err := m.Force(v); err != nil {
log.Fatalf("force %d: %v", v, err)
}
fmt.Printf("forced to version %d\n", v)
case "drop":
log.Println("WARNING: dropping all database objects")
if err := m.Drop(); err != nil {
log.Fatalf("drop: %v", err)
}
fmt.Println("dropped")
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", cmd)
flag.Usage()
os.Exit(2)
}
}
func parseStep(s string) int {
if s == "" {
return 1
}
n, err := strconv.Atoi(s)
if err != nil || n <= 0 {
log.Fatalf("step must be a positive integer, got %q", s)
}
return n
}
func reportVersion(m *gomigrate.Migrate) {
v, dirty, err := m.Version()
if errors.Is(err, gomigrate.ErrNilVersion) {
fmt.Println("no schema_migrations row (empty database)")
return
}
if err != nil {
log.Fatalf("version: %v", err)
}
fmt.Printf("schema_migrations: version=%d dirty=%v\n", v, dirty)
}