mirror of
https://github.com/perfect-panel/ppanel-web.git
synced 2026-08-29 05:52:08 -04:00
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>
This commit is contained in:
@@ -39,23 +39,20 @@ pub async fn init_pool(cfg: &DatabaseConfig) -> Result<Db, sqlx::Error> {
|
||||
|
||||
// ─── DSN builder ─────────────────────────────────────────────────────────
|
||||
|
||||
fn build_dsn(cfg: &DatabaseConfig) -> String {
|
||||
pub(crate) fn build_dsn(cfg: &DatabaseConfig) -> String {
|
||||
let dialect = detect_dialect(cfg);
|
||||
let addr = cfg
|
||||
.addr
|
||||
.as_deref()
|
||||
.filter(|a| !a.is_empty())
|
||||
.unwrap_or(match detect_dialect(cfg) {
|
||||
.unwrap_or(match dialect {
|
||||
Dialect::Postgres => "localhost:5432",
|
||||
Dialect::Mysql => "localhost:3306",
|
||||
});
|
||||
let password = url_encode_password(&cfg.password);
|
||||
let query = if cfg.config.is_empty() {
|
||||
default_query(cfg)
|
||||
} else {
|
||||
&cfg.config
|
||||
};
|
||||
let query = pick_query(cfg, dialect);
|
||||
|
||||
match detect_dialect(cfg) {
|
||||
match dialect {
|
||||
Dialect::Postgres => format!(
|
||||
"postgres://{}:{}@{}/{}?{}",
|
||||
cfg.username, password, addr, cfg.dbname, query,
|
||||
@@ -71,8 +68,42 @@ fn detect_dialect(cfg: &DatabaseConfig) -> Dialect {
|
||||
Dialect::from_driver(&cfg.driver)
|
||||
}
|
||||
|
||||
fn default_query(cfg: &DatabaseConfig) -> &'static str {
|
||||
match detect_dialect(cfg) {
|
||||
/// Pick the URL query string for the DSN.
|
||||
///
|
||||
/// `DatabaseConfig.config` defaults to a MySQL-flavoured string
|
||||
/// (`charset=utf8mb4&parseTime=true&loc=...`), so an unset YAML field
|
||||
/// silently gives the wrong params to a Postgres connection. To stay
|
||||
/// backwards-compatible we only honour `cfg.config` when it looks
|
||||
/// compatible with the active dialect; otherwise fall back to
|
||||
/// `default_query`.
|
||||
fn pick_query(cfg: &DatabaseConfig, dialect: Dialect) -> String {
|
||||
let raw = cfg.config.trim();
|
||||
if !raw.is_empty() && query_matches_dialect(raw, dialect) {
|
||||
return raw.to_string();
|
||||
}
|
||||
default_query(dialect).to_string()
|
||||
}
|
||||
|
||||
fn query_matches_dialect(query: &str, dialect: Dialect) -> bool {
|
||||
// Heuristic — MySQL uses `charset=` / `parseTime=` / `loc=`; Postgres
|
||||
// uses `sslmode=` / `TimeZone=` / `channel_binding=`. Treat any MySQL-
|
||||
// specific key under a Postgres dialect as a mismatch, and vice versa.
|
||||
let q = query.to_ascii_lowercase();
|
||||
match dialect {
|
||||
Dialect::Postgres => {
|
||||
!q.contains("charset=")
|
||||
&& !q.contains("parsetime=")
|
||||
&& !q.contains("&loc=")
|
||||
&& !q.starts_with("loc=")
|
||||
}
|
||||
Dialect::Mysql => {
|
||||
!(q.contains("sslmode=") || q.contains("timezone=") || q.contains("channel_binding="))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_query(dialect: Dialect) -> &'static str {
|
||||
match dialect {
|
||||
Dialect::Postgres => "sslmode=disable&TimeZone=Asia/Shanghai",
|
||||
Dialect::Mysql => "charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai",
|
||||
}
|
||||
|
||||
+2
-5
@@ -133,11 +133,8 @@ async fn main() {
|
||||
.expect("failed to connect to database");
|
||||
tracing::info!("database connected");
|
||||
|
||||
// ── Run pending migrations ──────────────────────────────────────────
|
||||
migration::run_migrations(&db)
|
||||
.await
|
||||
.expect("database migration failed");
|
||||
tracing::info!("database migrations applied");
|
||||
// ── Ensure schema is present (invoke Go migrate tool if needed) ────
|
||||
migration::ensure_schema(&db, &cfg.database).await;
|
||||
|
||||
// ── Seed initial admin account if needed ────────────────────────────
|
||||
migration::create_admin_user(&db, &cfg.administrator.email, &cfg.administrator.password)
|
||||
|
||||
+153
-30
@@ -1,39 +1,168 @@
|
||||
//! Database migration runner and bootstrap utilities.
|
||||
//! Database bootstrap utilities.
|
||||
//!
|
||||
//! Embeds both MySQL and PostgreSQL migration files at compile time via
|
||||
//! `sqlx::migrate!` and selects the correct set at runtime based on the
|
||||
//! [`Dialect`] detected from configuration.
|
||||
//! 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.
|
||||
|
||||
mod mysql_migrations {
|
||||
#![allow(unused)]
|
||||
pub(super) const MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("migrations/mysql");
|
||||
}
|
||||
|
||||
mod postgres_migrations {
|
||||
#![allow(unused)]
|
||||
pub(super) fn migrator() -> sqlx::migrate::Migrator {
|
||||
let mut m = sqlx::migrate!("migrations/postgres");
|
||||
m.set_locking(false);
|
||||
m.no_tx = true;
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/// Run all pending migrations for the detected database dialect.
|
||||
pub async fn run_migrations(db: &Db) -> Result<(), sqlx::migrate::MigrateError> {
|
||||
/// 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) => postgres_migrations::migrator().run(pool).await,
|
||||
Db::Mysql(pool) => mysql_migrations::MIGRATOR.run(pool).await,
|
||||
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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -64,7 +193,6 @@ async fn create_admin_user_pg(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let now_ts = chrono::Utc::now(); // chrono::DateTime<Utc> -> PG timestamp
|
||||
let password_hash = hash_password_pbkdf2(password);
|
||||
let refer_code = generate_invite_code();
|
||||
@@ -192,9 +320,4 @@ fn hash_password_pbkdf2(password: &str) -> String {
|
||||
|
||||
fn generate_invite_code() -> String {
|
||||
format!("u{}", &uuid::Uuid::new_v4().to_string().replace('-', "")[..12])
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Bootstrap: initial admin account
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user