208 lines
6.2 KiB
Go
208 lines
6.2 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
|
|
|
|
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 })
|
|
}
|
|
}()
|
|
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
|
|
}
|
|
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 := 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
|
|
}
|
|
|
|
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 rejects missing, modified, dirty and unexpected versions. It does
|
|
// not execute DDL. A matched migration ledger is not proof against manual DDL drift.
|
|
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")
|
|
}
|
|
}
|
|
return nil
|
|
}
|