295 lines
10 KiB
Go
295 lines
10 KiB
Go
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"database/sql"
|
||
"database/sql/driver"
|
||
"embed"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"io/fs"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
//go:embed migrations/*.up.sql
|
||
var migrationFiles embed.FS
|
||
|
||
// freshInstallBaseline is the current schema without the historical foreign
|
||
// key DDL. Existing databases keep using the immutable forward migration
|
||
// chain; only an empty database takes this path.
|
||
//
|
||
//go:embed baseline.sql
|
||
var freshInstallBaseline string
|
||
|
||
const freshInstallBaselineVersion uint64 = 29
|
||
|
||
type Migration struct {
|
||
Version uint64
|
||
Name string
|
||
Checksum string
|
||
SQL string
|
||
}
|
||
type MigrationStatus struct {
|
||
Version uint64 `json:"version"`
|
||
Name string `json:"name"`
|
||
Checksum string `json:"checksum"`
|
||
Dirty bool `json:"dirty"`
|
||
}
|
||
|
||
func Migrations() ([]Migration, error) {
|
||
names, err := fs.Glob(migrationFiles, "migrations/*.up.sql")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result := make([]Migration, 0, len(names))
|
||
seen := map[uint64]bool{}
|
||
for _, name := range names {
|
||
base := strings.TrimPrefix(name, "migrations/")
|
||
version, err := strconv.ParseUint(strings.SplitN(base, "_", 2)[0], 10, 64)
|
||
if err != nil || version == 0 || seen[version] {
|
||
return nil, fmt.Errorf("invalid or duplicate migration version: %s", base)
|
||
}
|
||
seen[version] = true
|
||
b, err := migrationFiles.ReadFile(name)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
digest := sha256.Sum256(b)
|
||
result = append(result, Migration{version, base, hex.EncodeToString(digest[:]), string(b)})
|
||
}
|
||
sort.Slice(result, func(i, j int) bool { return result[i].Version < result[j].Version })
|
||
return result, nil
|
||
}
|
||
|
||
// Migrate applies explicit forward migrations while holding one connection-scoped
|
||
// advisory lock. MySQL DDL commits implicitly: interruption leaves a dirty record
|
||
// and stops further migrations until an operator reconciles the partial schema.
|
||
func Migrate(ctx context.Context, db *sql.DB) error {
|
||
migrations, err := Migrations()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
conn, err := db.Conn(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer conn.Close()
|
||
var dbName, version string
|
||
if err := conn.QueryRowContext(ctx, "SELECT DATABASE(), VERSION()").Scan(&dbName, &version); err != nil {
|
||
return err
|
||
}
|
||
var major, minor int
|
||
if _, err := fmt.Sscanf(version, "%d.%d", &major, &minor); err != nil || strings.Contains(strings.ToLower(version), "mariadb") || major < 8 || major == 8 && minor < 4 {
|
||
return errors.New("migrations require MySQL 8.4 or newer")
|
||
}
|
||
digest := sha256.Sum256([]byte(dbName))
|
||
lockName := "orbit_migrate_" + hex.EncodeToString(digest[:20])
|
||
var acquired sql.NullInt64
|
||
if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, 10)", lockName).Scan(&acquired); err != nil {
|
||
return err
|
||
}
|
||
if !acquired.Valid || acquired.Int64 != 1 {
|
||
return errors.New("migration lock unavailable")
|
||
}
|
||
defer func() {
|
||
cleanup, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||
defer cancel()
|
||
var released sql.NullInt64
|
||
if err := conn.QueryRowContext(cleanup, "SELECT RELEASE_LOCK(?)", lockName).Scan(&released); err != nil || !released.Valid || released.Int64 != 1 {
|
||
_ = conn.Raw(func(any) error { return driver.ErrBadConn })
|
||
}
|
||
}()
|
||
var ledgerExists, businessTables int
|
||
if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orbit_schema_migrations'`).Scan(&ledgerExists); err != nil {
|
||
return err
|
||
}
|
||
if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND LEFT(TABLE_NAME,6) IN ('orbit_','pulse_','weave_') AND TABLE_NAME<>'orbit_schema_migrations'`).Scan(&businessTables); err != nil {
|
||
return err
|
||
}
|
||
if ledgerExists == 0 && businessTables > 0 {
|
||
return errors.New("Orbit tables exist without migration ledger; refuse to guess schema history")
|
||
}
|
||
if _, err := conn.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS orbit_schema_migrations (
|
||
version BIGINT UNSIGNED PRIMARY KEY,
|
||
name VARCHAR(191) NOT NULL,
|
||
checksum CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||
dirty BOOLEAN NOT NULL,
|
||
applied_at DATETIME(6) NULL
|
||
) ENGINE=InnoDB`); err != nil {
|
||
return err
|
||
}
|
||
rows, err := conn.QueryContext(ctx, "SELECT version, name, checksum, dirty FROM orbit_schema_migrations ORDER BY version")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
applied := map[uint64]MigrationStatus{}
|
||
for rows.Next() {
|
||
var m MigrationStatus
|
||
if err := rows.Scan(&m.Version, &m.Name, &m.Checksum, &m.Dirty); err != nil {
|
||
rows.Close()
|
||
return err
|
||
}
|
||
applied[m.Version] = m
|
||
}
|
||
err = rows.Err()
|
||
rows.Close()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(applied) == 0 && businessTables > 0 {
|
||
return errors.New("Orbit tables exist with an empty migration ledger; reconcile schema history before proceeding")
|
||
}
|
||
if len(applied) == 0 && businessTables == 0 {
|
||
return applyFreshInstallBaseline(ctx, conn, migrations)
|
||
}
|
||
known := map[uint64]Migration{}
|
||
for _, m := range migrations {
|
||
known[m.Version] = m
|
||
}
|
||
for v, a := range applied {
|
||
m, ok := known[v]
|
||
if !ok {
|
||
return fmt.Errorf("database migration %d is newer than this binary", v)
|
||
}
|
||
if a.Dirty {
|
||
return fmt.Errorf("migration %d is dirty; reconcile partial DDL before proceeding", v)
|
||
}
|
||
if a.Checksum != m.Checksum || a.Name != m.Name {
|
||
return fmt.Errorf("migration %d checksum/name mismatch", v)
|
||
}
|
||
}
|
||
for _, m := range migrations {
|
||
if _, ok := applied[m.Version]; ok {
|
||
continue
|
||
}
|
||
for v := range applied {
|
||
if v > m.Version {
|
||
return errors.New("out-of-order migration requires operator review")
|
||
}
|
||
}
|
||
statements, err := splitSQL(m.SQL)
|
||
if err != nil {
|
||
return fmt.Errorf("migration %d: %w", m.Version, err)
|
||
}
|
||
if err := validateNoReferenceDDL(m.Version, statements); err != nil {
|
||
return err
|
||
}
|
||
if _, err := conn.ExecContext(ctx, "INSERT INTO orbit_schema_migrations (version,name,checksum,dirty) VALUES (?,?,?,TRUE)", m.Version, m.Name, m.Checksum); err != nil {
|
||
return err
|
||
}
|
||
for i, statement := range statements {
|
||
if _, err := conn.ExecContext(ctx, statement); err != nil {
|
||
return fmt.Errorf("migration %d statement %d failed (marked dirty): %w", m.Version, i+1, err)
|
||
}
|
||
}
|
||
if _, err := conn.ExecContext(ctx, "UPDATE orbit_schema_migrations SET dirty=FALSE, applied_at=UTC_TIMESTAMP(6) WHERE version=? AND checksum=?", m.Version, m.Checksum); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validateNoReferenceDDL protects the post-removal migration line at runtime.
|
||
// Versions 1-8 are immutable compatibility history: version 8 removes the
|
||
// legacy constraints from databases that were created before the no-FK policy.
|
||
// Every later migration must be incapable of creating or retaining a database
|
||
// reference constraint, even if a future author forgets the static test.
|
||
func validateNoReferenceDDL(version uint64, statements []string) error {
|
||
if version <= 8 {
|
||
return nil
|
||
}
|
||
for _, statement := range statements {
|
||
upper := strings.ToUpper(statement)
|
||
if strings.Contains(upper, "FOREIGN KEY") || strings.Contains(upper, " REFERENCES ") {
|
||
return fmt.Errorf("migration %d contains forbidden foreign-key DDL", version)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// applyFreshInstallBaseline creates a new database directly at the current
|
||
// schema version. It records every historical migration as dirty before DDL so
|
||
// an interrupted baseline remains blocked for operator reconciliation, just
|
||
// like an interrupted ordinary migration. The baseline is intentionally
|
||
// limited to an empty database; it never rewrites or reinterprets an upgrade.
|
||
func applyFreshInstallBaseline(ctx context.Context, conn *sql.Conn, migrations []Migration) error {
|
||
if len(migrations) == 0 || migrations[len(migrations)-1].Version != freshInstallBaselineVersion {
|
||
return errors.New("fresh-install baseline is out of date; regenerate it before release")
|
||
}
|
||
statements, err := splitSQL(freshInstallBaseline)
|
||
if err != nil {
|
||
return fmt.Errorf("fresh-install baseline: %w", err)
|
||
}
|
||
for _, statement := range statements {
|
||
upper := strings.ToUpper(statement)
|
||
if strings.Contains(upper, "FOREIGN KEY") || strings.Contains(upper, " REFERENCES ") {
|
||
return errors.New("fresh-install baseline contains forbidden foreign-key DDL")
|
||
}
|
||
}
|
||
for _, migration := range migrations {
|
||
if _, err := conn.ExecContext(ctx, "INSERT INTO orbit_schema_migrations (version,name,checksum,dirty) VALUES (?,?,?,TRUE)", migration.Version, migration.Name, migration.Checksum); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for i, statement := range statements {
|
||
if _, err := conn.ExecContext(ctx, statement); err != nil {
|
||
return fmt.Errorf("fresh-install baseline statement %d failed (migration ledger remains dirty): %w", i+1, err)
|
||
}
|
||
}
|
||
if _, err := conn.ExecContext(ctx, "UPDATE orbit_schema_migrations SET dirty=FALSE, applied_at=UTC_TIMESTAMP(6)"); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func Status(ctx context.Context, db *sql.DB) ([]MigrationStatus, error) {
|
||
rows, err := db.QueryContext(ctx, "SELECT version,name,checksum,dirty FROM orbit_schema_migrations ORDER BY version")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
result := []MigrationStatus{}
|
||
for rows.Next() {
|
||
var m MigrationStatus
|
||
if err := rows.Scan(&m.Version, &m.Name, &m.Checksum, &m.Dirty); err != nil {
|
||
return nil, err
|
||
}
|
||
result = append(result, m)
|
||
}
|
||
return result, rows.Err()
|
||
}
|
||
|
||
// CheckSchema 校验迁移记录并禁止外键,不执行 DDL;不代表已核验其他手工结构漂移。
|
||
func CheckSchema(ctx context.Context, db *sql.DB) error {
|
||
wanted, err := Migrations()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
actual, err := Status(ctx, db)
|
||
if err != nil {
|
||
return errors.New("schema unavailable; run orbit-migrate up with migration credentials")
|
||
}
|
||
if len(actual) != len(wanted) {
|
||
return errors.New("schema version mismatch")
|
||
}
|
||
for i, m := range wanted {
|
||
a := actual[i]
|
||
if a.Version != m.Version || a.Name != m.Name || a.Checksum != m.Checksum || a.Dirty {
|
||
return errors.New("schema is dirty or does not match this binary")
|
||
}
|
||
}
|
||
var foreignKeys int
|
||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND LEFT(TABLE_NAME,6) IN ('orbit_','pulse_','weave_')`).Scan(&foreignKeys); err != nil {
|
||
return err
|
||
}
|
||
if foreignKeys != 0 {
|
||
return errors.New("foreign keys are forbidden on Orbit tables; apply the supported schema before starting")
|
||
}
|
||
return nil
|
||
}
|