feat(migration)

This commit is contained in:
Ember Moth
2026-07-07 16:33:17 +08:00
parent 953a9e5409
commit aa71d56154
172 changed files with 966 additions and 103 deletions
Generated
+216 -4
View File
@@ -511,6 +511,26 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "bindgen"
version = "0.71.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex 1.3.0",
"syn 2.0.118",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.0" version = "2.13.0"
@@ -596,6 +616,24 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
[[package]]
name = "cbindgen"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff"
dependencies = [
"heck 0.4.1",
"indexmap 2.14.0",
"log",
"proc-macro2",
"quote",
"serde",
"serde_json",
"syn 2.0.118",
"tempfile",
"toml",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.65" version = "1.2.65"
@@ -605,7 +643,16 @@ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
"libc", "libc",
"shlex", "shlex 2.0.1",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom 7.1.3",
] ]
[[package]] [[package]]
@@ -655,6 +702,17 @@ dependencies = [
"inout", "inout",
] ]
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.1" version = "4.6.1"
@@ -683,7 +741,7 @@ version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [ dependencies = [
"heck", "heck 0.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.118", "syn 2.0.118",
@@ -1304,6 +1362,17 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs-set-times"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
dependencies = [
"io-lifetimes",
"rustix",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "fs_extra" name = "fs_extra"
version = "1.3.0" version = "1.3.0"
@@ -1726,6 +1795,12 @@ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.16.1",
] ]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -2197,6 +2272,12 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "io-lifetimes"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
[[package]] [[package]]
name = "ip" name = "ip"
version = "0.1.0" version = "0.1.0"
@@ -2388,6 +2469,16 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]] [[package]]
name = "libm" name = "libm"
version = "0.2.16" version = "0.2.16"
@@ -2486,6 +2577,14 @@ version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "migrate"
version = "0.1.0"
dependencies = [
"rust2go",
"tracing",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@@ -3148,6 +3247,7 @@ dependencies = [
"hex", "hex",
"jwt", "jwt",
"md5 0.7.0", "md5 0.7.0",
"migrate",
"oauth", "oauth",
"opentelemetry", "opentelemetry",
"opentelemetry-otlp", "opentelemetry-otlp",
@@ -3240,7 +3340,7 @@ version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
dependencies = [ dependencies = [
"heck", "heck 0.5.0",
"itertools 0.14.0", "itertools 0.14.0",
"log", "log",
"multimap", "multimap",
@@ -3735,6 +3835,62 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rust2go"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "021695cb0d56dbef5f29b24d2dd9a23552464500574260a41c198fe17c78a3fe"
dependencies = [
"bindgen",
"fs-set-times",
"rust2go-cli",
"rust2go-convert",
"rust2go-macro",
"syn 2.0.118",
]
[[package]]
name = "rust2go-cli"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8490df1c618176bfd681c0e27b46fa98f42739c441c12a8f7bc85eb6550e54fe"
dependencies = [
"cbindgen",
"clap",
"itertools 0.14.0",
"rust2go-common",
]
[[package]]
name = "rust2go-common"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a27eb858d3df960a07bc7f7e43f642d6a8368cc32110d7daacb6aa069eb7f9c5"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "rust2go-convert"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21ee4d6efac91720550e564892b1fddfe103683f7f0b19196f59dae1621e6e83"
[[package]]
name = "rust2go-macro"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a694d2aa8c7265126b0f3c712bd8411e5415d5e5f906fa2996a10beb81b5efca"
dependencies = [
"proc-macro2",
"quote",
"rust2go-common",
"syn 2.0.118",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -4027,6 +4183,15 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -4143,6 +4308,12 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@@ -4382,7 +4553,7 @@ dependencies = [
"cfg-if", "cfg-if",
"dotenvy", "dotenvy",
"either", "either",
"heck", "heck 0.5.0",
"hex", "hex",
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -4777,6 +4948,47 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.14.0",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "tonic" name = "tonic"
version = "0.12.3" version = "0.12.3"
+1
View File
@@ -46,3 +46,4 @@ sms = { path = "crates/sms" }
email = { path = "crates/email" } email = { path = "crates/email" }
turnstile = { path = "crates/turnstile" } turnstile = { path = "crates/turnstile" }
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
migrate = { path = "crates/migrate" }
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "migrate"
version = "0.1.0"
edition = "2021"
[lib]
# The name of the lib target. We expose `ppanel_migrate` so the main
# binary can `use ppanel_migrate::*`. The actual library name in
# Cargo's eyes is "migrate" (matches [package].name), but the
# `crate-type = ["staticlib"]` is what rust2go::Builder compiles the
# Go code into for FFI.
crate-type = ["staticlib", "rlib"]
[features]
# Default: build with the FFI bridge compiled in. The Go side is compiled
# by build.rs via rust2go::Builder.
default = []
[dependencies]
rust2go = "0.4"
tracing = "0.1"
[build-dependencies]
rust2go = { version = "0.4", features = ["build"] }
+19
View File
@@ -0,0 +1,19 @@
// rust2go build script.
//
// Wires up the Go source tree (./go/r2g) into the Rust build:
// - Embeds the Go source path so the Builder can compile it via CGO.
// - Wires `src/idl.rs` (Rust IDL) → `./go/r2g/gen.go`
// (auto-generated Go glue).
//
// On every `cargo build` the Builder regenerates gen.go if the IDL
// changed, then compiles the Go code into a static library that the
// Rust crate is linked against.
fn main() {
rust2go::Builder::new()
.with_go_src("./go/r2g")
// gen.go is written relative to the build script's CWD (the
// crate root), so we spell out the path under go/r2g/ where
// the Go package lives.
.with_regen("src/idl.rs", "go/r2g/gen.go")
.build();
}
+98
View File
@@ -0,0 +1,98 @@
//! End-to-end test for the rust2go FFI migration path.
//!
//! Usage:
//! GODEBUG=invalidptr=0,cgocheck=0 cargo run --example test_migrate -- \
//! postgres://user:pass@host:5432/dbname?sslmode=disable
//!
//! Resets the database, runs `migrate::up()` via the FFI bridge, and
//! verifies the resulting schema. Exits with non-zero status on any
//! failure.
use std::env;
fn main() {
let dsn = env::args()
.nth(1)
.expect("usage: test_migrate <postgres-dsn>");
// Make sure the schema is empty so we can verify migration works
// from a fresh state. We do this with a side connection in pure
// SQL — we don't want to import the sqlx stack just for the test.
// The drop must run as a superuser since `ppanel_test` does not
// own the public schema.
println!("== dropping schema public (as postgres superuser) ==");
let admin_dsn = env::var("PPANEL_ADMIN_DSN")
.unwrap_or_else(|_| "postgres://postgres@localhost:5432/postgres".to_string());
let drop_sql = format!(
"DROP SCHEMA public CASCADE; CREATE SCHEMA public; \
GRANT ALL ON SCHEMA public TO public; \
ALTER DATABASE ppanel_rust2go_test OWNER TO ppanel_test;",
);
exec_via_psql(&admin_dsn, &drop_sql);
println!("== calling migrate::up() via FFI ==");
let outcome = migrate::up("postgres", &dsn);
println!(
" -> version={}, dirty={}, error={:?}",
outcome.version, outcome.dirty, outcome.error
);
if !outcome.error.is_empty() {
eprintln!("MIGRATION FAILED: {}", outcome.error);
std::process::exit(1);
}
println!("== verifying schema ==");
let count_sql = "SELECT count(*) FROM information_schema.tables \
WHERE table_schema = 'public' AND table_type = 'BASE TABLE';";
let n: u32 = query_via_psql(&dsn, count_sql)
.parse()
.expect("count should parse as u32");
println!(" -> {} public tables", n);
if n < 20 {
eprintln!("expected at least 20 tables, got {}", n);
std::process::exit(1);
}
println!("== verifying schema_migrations state ==");
let ver_sql = "SELECT version::text || '|' || dirty::text FROM schema_migrations;";
let state = query_via_psql(&dsn, ver_sql);
println!(" -> version,dirty = {}", state);
if !state.starts_with("2131|") {
eprintln!("expected version 2131, got {}", state);
std::process::exit(1);
}
println!("== testing idempotency: re-run up() ==");
let outcome2 = migrate::up("postgres", &dsn);
println!(
" -> version={}, dirty={}, error={:?}",
outcome2.version, outcome2.dirty, outcome2.error
);
if !outcome2.error.is_empty() {
eprintln!("IDEMPOTENT RUN FAILED: {}", outcome2.error);
std::process::exit(1);
}
println!("\n✅ ALL CHECKS PASSED");
}
/// Run a SQL statement via `psql` and return its trimmed stdout.
fn exec_via_psql(dsn: &str, sql: &str) -> String {
let out = std::process::Command::new("psql")
.arg(dsn)
.arg("-c")
.arg(sql)
.arg("-A")
.arg("-t")
.output()
.expect("failed to invoke psql (is it installed and on PATH?)");
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
panic!("psql failed: {}\n{}", out.status, stderr);
}
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn query_via_psql(dsn: &str, sql: &str) -> String {
exec_via_psql(dsn, sql)
}
@@ -22,7 +22,7 @@ import (
gomigrate "github.com/golang-migrate/migrate/v4" gomigrate "github.com/golang-migrate/migrate/v4"
ppmigrate "github.com/perfect-panel/ppanel-backend/tools/migrate/migrate" ppmigrate "github.com/perfect-panel/ppanel-backend/crates/migrate/go/migrate"
) )
func main() { func main() {
@@ -53,7 +53,7 @@ func main() {
} }
cmd := flag.Arg(0) cmd := flag.Arg(0)
sess := ppmigrate.Migrate(*driver, *dsn) sess := ppmigrate.MigrateOrPanic(*driver, *dsn)
m := sess.Migrate m := sess.Migrate
switch cmd { switch cmd {
+16
View File
@@ -0,0 +1,16 @@
module github.com/perfect-panel/ppanel-backend/crates/migrate/go
go 1.25.0
require (
github.com/golang-migrate/migrate/v4 v4.19.1
// rust2go has no tagged releases; pin to the latest commit on master.
// Bump by re-running `go get github.com/ihciah/rust2go@master`.
github.com/ihciah/rust2go v0.0.0-20260706074211-1c851bd436aa
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/lib/pq v1.10.9 // indirect
)
@@ -32,6 +32,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/ihciah/rust2go v0.0.0-20260706074211-1c851bd436aa h1:u8Mzn+6v64BdSV6ry7OGXt2lbSjHQh4711+0whU/sYE=
github.com/ihciah/rust2go v0.0.0-20260706074211-1c851bd436aa/go.mod h1:SpcZZoVYDXYhx373JZ1onR8dnEcalKA0H61x/FJ9O0s=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -23,6 +23,10 @@ var sqlFiles embed.FS
// NoChange is re-exported so callers don't have to import golang-migrate directly. // NoChange is re-exported so callers don't have to import golang-migrate directly.
var NoChange = migrate.ErrNoChange var NoChange = migrate.ErrNoChange
// ErrNilVersion is re-exported for the same reason — it is the sentinel
// returned by Version() when the schema_migrations table is empty.
var ErrNilVersion = migrate.ErrNilVersion
// Session bundles a configured golang-migrate instance alongside the parsed // Session bundles a configured golang-migrate instance alongside the parsed
// source-version list so callers can introspect what migrations exist. // source-version list so callers can introspect what migrations exist.
type Session struct { type Session struct {
@@ -37,7 +41,10 @@ type Session struct {
// dsn: golang-migrate URL (e.g. postgres://user:pass@host:port/db?sslmode=disable). // dsn: golang-migrate URL (e.g. postgres://user:pass@host:port/db?sslmode=disable).
// //
// If dsn does not include a URL scheme, the driver is prepended automatically. // If dsn does not include a URL scheme, the driver is prepended automatically.
func Migrate(driver, dsn string) *Session { //
// Returns an error (no panics) so the function is safe to call across an FFI
// boundary.
func Migrate(driver, dsn string) (*Session, error) {
sourcePath := "sql/postgres" sourcePath := "sql/postgres"
databaseURL := dsn databaseURL := dsn
switch driver { switch driver {
@@ -47,20 +54,30 @@ func Migrate(driver, dsn string) *Session {
case "postgres": case "postgres":
databaseURL = ensureScheme("postgres://", dsn) databaseURL = ensureScheme("postgres://", dsn)
default: default:
panic(fmt.Errorf("[Migrate] unsupported database driver: %s", driver)) return nil, fmt.Errorf("[Migrate] unsupported database driver: %s", driver)
} }
d, err := iofs.New(sqlFiles, sourcePath) d, err := iofs.New(sqlFiles, sourcePath)
if err != nil { if err != nil {
panic(fmt.Errorf("[Migrate] iofs.New error: %v", err)) return nil, fmt.Errorf("[Migrate] iofs.New error: %v", err)
} }
client, err := migrate.NewWithSourceInstance("iofs", d, databaseURL) client, err := migrate.NewWithSourceInstance("iofs", d, databaseURL)
if err != nil { if err != nil {
panic(fmt.Errorf("[Migrate] NewWithSourceInstance error: %v", err)) return nil, fmt.Errorf("[Migrate] NewWithSourceInstance error: %v", err)
} }
return &Session{ return &Session{
Migrate: client, Migrate: client,
Versions: scanVersions(sourcePath), Versions: scanVersions(sourcePath),
}, nil
}
// MigrateOrPanic is a panic-on-error variant retained for the standalone CLI
// (`cmd/migrate/main.go`). Production code (FFI callers) must use Migrate.
func MigrateOrPanic(driver, dsn string) *Session {
s, err := Migrate(driver, dsn)
if err != nil {
panic(err)
} }
return s
} }
// sourceVersionRe matches migration filenames like "02131_xxx.up.sql" / ".down.sql". // sourceVersionRe matches migration filenames like "02131_xxx.up.sql" / ".down.sql".

Some files were not shown because too many files have changed in this diff Show More