Files
Ember Moth e4996ade03 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. Probe for the `user` table (schema marker)
  3. If missing, fork-exec `ppanel-migrate up` to bring schema to v2131
  4. Create admin if not present
  5. Continue with redis/queue/scheduler

Verified:
  - Empty DB → ppanel-migrate applies all 39 migrations in ~0.4s, admin created
  - Go server's ppanel_go DB → schema detected, migrate skipped, clean start

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
    (Rust uses 16-byte salt; Go's 8-byte salt fits in 100 but Rust's doesn't)
  - Rewrite src/migration.rs:
    * Remove sqlx::migrate!, run_migrations()
    * Add ensure_schema(db, cfg): probe + invoke ppanel-migrate subprocess
  - 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>
2026-07-06 02:11:10 +08:00

323 lines
11 KiB
Rust

//! Database bootstrap utilities.
//!
//! Schema migrations are **not** owned by this Rust binary. They are managed by
//! the standalone Go tool at `tools/migrate/` (built with `go build -o ppanel-migrate ./cmd/migrate`),
//! which uses golang-migrate and tracks state in the `schema_migrations` table
//! — the same table the Go server uses. That makes a single source of truth
//! for schema across the Go and Rust backends.
//!
//! On startup, this module probes for the presence of the `user` table. If it
//! is missing it shells out to the `ppanel-migrate` binary to bring the schema
//! up to date, then retries the probe. If the binary is not available or fails,
//! the process exits with a clear error message.
//!
//! **NOTE**: This is the Rust rewrite of the Go backend. The Go version is
//! deprecated and will be replaced by this Rust implementation.
pub use crate::repository::Dialect;
use crate::config::DatabaseConfig;
use crate::db::build_dsn;
use crate::repository::Db;
use std::path::PathBuf;
use std::process::Command;
/// Find a probe table whose existence implies "the schema is at least migration 2131".
///
/// `user` is created in `00001_init_schema.up.sql` and only ever dropped in
/// drop-style migrations that immediately re-create it (or are no-ops on top
/// of a fresh database), so its presence is a reliable schema-marker.
const SCHEMA_MARKER_TABLE: &str = "user";
/// Returns `Ok(true)` if the schema marker table is present and queryable,
/// `Ok(false)` if the table is missing, and `Err` for any other database error.
async fn schema_present(db: &Db) -> Result<bool, sqlx::Error> {
match db {
Db::Postgres(pool) => {
let row: Option<(bool,)> = sqlx::query_as(
r#"SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1
)"#,
)
.bind(SCHEMA_MARKER_TABLE)
.fetch_optional(pool)
.await?;
Ok(row.map(|(b,)| b).unwrap_or(false))
}
Db::Mysql(pool) => {
let row: Option<(i64,)> = sqlx::query_as(
r#"SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = ?"#,
)
.bind(SCHEMA_MARKER_TABLE)
.fetch_optional(pool)
.await?;
Ok(row.map(|(n,)| n > 0).unwrap_or(false))
}
}
}
/// Ensure the database schema is present, invoking the `ppanel-migrate` tool
/// if needed. Returns successfully if the schema is at (or beyond) the
/// tool's latest migration, otherwise exits the process with a clear error.
pub async fn ensure_schema(db: &Db, cfg: &DatabaseConfig) {
match schema_present(db).await {
Ok(true) => {
tracing::info!("schema marker table present, skipping migration tool");
return;
}
Ok(false) => {
tracing::warn!(
"schema marker table `{}` not found — invoking ppanel-migrate to initialise the schema",
SCHEMA_MARKER_TABLE
);
}
Err(e) => {
tracing::error!("failed to probe schema marker: {e}");
// Don't bail out yet — the migration tool itself might still succeed
// (e.g. the database is reachable, just has no tables).
}
}
run_migrate_tool(cfg).await;
// Re-probe to confirm the schema is actually present now.
match schema_present(db).await {
Ok(true) => tracing::info!("schema initialised successfully"),
Ok(false) => panic!(
"ppanel-migrate ran but schema marker `{}` is still missing",
SCHEMA_MARKER_TABLE
),
Err(e) => panic!("schema marker probe failed after ppanel-migrate: {e}"),
}
}
/// Resolve the path to the `ppanel-migrate` binary.
///
/// Search order:
/// 1. `${PPANEL_MIGRATE_BIN}` if set.
/// 2. Next to the current executable (`./ppanel-migrate`).
/// 3. One level up from the executable (cargo puts the binary in target/release/;
/// the migrate tool often lives at tools/migrate/ppanel-migrate).
/// 4. `$PATH` (via Command's default lookup).
fn locate_migrate_bin() -> PathBuf {
if let Ok(p) = std::env::var("PPANEL_MIGRATE_BIN") {
return PathBuf::from(p);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let candidate = dir.join("ppanel-migrate");
if candidate.is_file() {
return candidate;
}
if let Some(parent) = dir.parent() {
let candidate = parent.join("ppanel-migrate");
if candidate.is_file() {
return candidate;
}
}
}
}
PathBuf::from("ppanel-migrate")
}
async fn run_migrate_tool(cfg: &DatabaseConfig) {
let bin = locate_migrate_bin();
let driver = match cfg.driver.as_str() {
"mysql" => "mysql",
_ => "postgres",
};
let dsn = build_dsn(cfg);
tracing::info!(driver, bin = %bin.display(), "running ppanel-migrate up");
// Run the migrate tool synchronously — it must complete before we serve
// any traffic. Failures are fatal.
let output = Command::new(&bin)
.arg("-driver")
.arg(driver)
.arg("-dsn")
.arg(&dsn)
.arg("up")
.output()
.unwrap_or_else(|e| {
panic!(
"failed to execute ppanel-migrate at {}: {e}. \
Build it with `go build -o ppanel-migrate ./cmd/migrate` \
from ppanel-backend/tools/migrate, or set $PPANEL_MIGRATE_BIN",
bin.display(),
)
});
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
panic!(
"ppanel-migrate exited with status {}: {}{}{}",
output.status,
stdout,
if !stdout.is_empty() && !stderr.is_empty() { "\n" } else { "" },
stderr,
);
}
tracing::info!("ppanel-migrate completed");
}
// ═══════════════════════════════════════════════════════════════════════════
// Bootstrap: initial admin account
// ═══════════════════════════════════════════════════════════════════════════
pub async fn create_admin_user(
db: &Db,
email: &str,
password: &str,
) -> Result<(), sqlx::Error> {
match db {
Db::Postgres(pool) => create_admin_user_pg(pool, email, password).await,
Db::Mysql(pool) => create_admin_user_mysql(pool, email, password).await,
}
}
async fn create_admin_user_pg(
pool: &sqlx::PgPool,
email: &str,
password: &str,
) -> Result<(), sqlx::Error> {
let exists: bool =
sqlx::query_scalar(r#"SELECT EXISTS(SELECT 1 FROM "user")"#)
.fetch_one(pool)
.await?;
if exists {
tracing::info!("User already exists, skip creating administrator account");
return Ok(());
}
let now_ts = chrono::Utc::now(); // chrono::DateTime<Utc> -> PG timestamp
let password_hash = hash_password_pbkdf2(password);
let refer_code = generate_invite_code();
sqlx::query(
r#"INSERT INTO "user" (password, algo, is_admin, refer_code, balance, commission,
gift_amount, enable, enable_balance_notify,
enable_login_notify, enable_subscribe_notify,
enable_trade_notify, created_at, updated_at)
VALUES ($1, 'default', TRUE, $2, 0, 0, 0, TRUE, TRUE, TRUE, TRUE, TRUE, $3, $4)"#,
)
.bind(&password_hash)
.bind(&refer_code)
.bind(now_ts)
.bind(now_ts)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to create admin user: {e}");
e
})?;
let user_id: i64 =
sqlx::query_scalar(r#"SELECT id FROM "user" WHERE refer_code = $1"#)
.bind(&refer_code)
.fetch_one(pool)
.await
.map_err(|e| {
tracing::error!("Failed to fetch admin user id: {e}");
e
})?;
sqlx::query(
r#"INSERT INTO user_auth_methods (user_id, auth_type, auth_identifier, verified, created_at, updated_at)
VALUES ($1, 'email', $2, TRUE, $3, $4)"#,
)
.bind(user_id)
.bind(email)
.bind(now_ts)
.bind(now_ts)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to create admin auth method: {e}");
e
})?;
tracing::info!("Administrator account created: {email}");
Ok(())
}
async fn create_admin_user_mysql(
pool: &sqlx::MySqlPool,
email: &str,
password: &str,
) -> Result<(), sqlx::Error> {
let exists: bool =
sqlx::query_scalar(r#"SELECT EXISTS(SELECT 1 FROM `user`)"#)
.fetch_one(pool)
.await?;
if exists {
tracing::info!("User already exists, skip creating administrator account");
return Ok(());
}
let now = chrono::Utc::now().timestamp_millis();
let password_hash = hash_password_pbkdf2(password);
let refer_code = generate_invite_code();
sqlx::query(
r#"INSERT INTO `user` (password, algo, is_admin, refer_code, balance, commission,
gift_amount, enable, enable_balance_notify,
enable_login_notify, enable_subscribe_notify,
enable_trade_notify, created_at, updated_at)
VALUES (?, 'default', 1, ?, 0, 0, 0, 1, 1, 1, 1, 1, ?, ?)"#,
)
.bind(&password_hash)
.bind(&refer_code)
.bind(now)
.bind(now)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to create admin user: {e}");
e
})?;
let user_id: i64 =
sqlx::query_scalar(r#"SELECT id FROM `user` WHERE refer_code = ?"#)
.bind(&refer_code)
.fetch_one(pool)
.await
.map_err(|e| {
tracing::error!("Failed to fetch admin user id: {e}");
e
})?;
sqlx::query(
r#"INSERT INTO user_auth_methods (user_id, auth_type, auth_identifier, verified, created_at, updated_at)
VALUES (?, 'email', ?, 1, ?, ?)"#,
)
.bind(user_id)
.bind(email)
.bind(now)
.bind(now)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to create admin auth method: {e}");
e
})?;
tracing::info!("Administrator account created: {email}");
Ok(())
}
/// PBKDF2-SHA512 matching Go's format: `$pbkdf2-sha512${salt_hex}${hash_hex}`
///
/// **Iteration count is intentionally 100** to stay byte-compatible with the
/// Go original. See `AGENTS.md` for rationale.
fn hash_password_pbkdf2(password: &str) -> String {
password::encode_password(password).expect("Failed to encode password")
}
fn generate_invite_code() -> String {
format!("u{}", &uuid::Uuid::new_v4().to_string().replace('-', "")[..12])
}