Files
orbit/internal/storage/mysql/migrate.go

295 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}