// Package migrate provides explicit MySQL schema migration jobs. Application // startup never runs migrations automatically; use a separate deployment step. package migrate import ( "archive/zip" "bytes" "context" "crypto/sha256" "database/sql" "encoding/hex" "errors" "fmt" mysqlDriver "github.com/go-sql-driver/mysql" migration "github.com/golang-migrate/migrate/v4" migrateMySQL "github.com/golang-migrate/migrate/v4/database/mysql" "github.com/golang-migrate/migrate/v4/source/iofs" "io" "io/fs" "path" "regexp" "sort" "strconv" "strings" "sync" "time" ) type File struct { Version uint64 `json:"version"` Name string `json:"name"` Direction string `json:"direction"` SHA256 string `json:"sha256"` } var filename = regexp.MustCompile(`^([0-9]+)_([A-Za-z][A-Za-z0-9]*)\.(up|down)\.sql$`) // Snapshot returns immutable migration bytes and a reviewable digest manifest. // Follow lowerCamelCase names, e.g. 0001_createUsers.up.sql. func Snapshot(source fs.FS, directory string) (fs.FS, []File, error) { if source == nil || !fs.ValidPath(directory) { return nil, nil, errors.New("migrate: invalid source directory") } entries, err := fs.ReadDir(source, directory) if err != nil { return nil, nil, err } var archive bytes.Buffer writer := zip.NewWriter(&archive) manifest := []File{} seen := map[string]bool{} total := 0 for _, entry := range entries { if entry.IsDir() { continue } parts := filename.FindStringSubmatch(entry.Name()) if parts == nil { if strings.HasSuffix(entry.Name(), ".sql") { return nil, nil, fmt.Errorf("migrate: invalid migration filename %s", entry.Name()) } continue } if len(manifest) >= 1000 { return nil, nil, errors.New("migrate: too many files") } version, err := strconv.ParseUint(parts[1], 10, 32) if err != nil || version == 0 { return nil, nil, errors.New("migrate: version must be 1..4294967295") } identity := fmt.Sprint(version) + parts[3] if seen[identity] { return nil, nil, errors.New("migrate: duplicate migration version/direction") } seen[identity] = true file, err := source.Open(path.Join(directory, entry.Name())) if err != nil { return nil, nil, err } data, err := io.ReadAll(io.LimitReader(file, (1<<20)+1)) closeErr := file.Close() if err == nil { err = closeErr } if err != nil { return nil, nil, err } total += len(data) if len(data) == 0 || len(data) > 1<<20 || total > 16<<20 { return nil, nil, errors.New("migrate: files must be 1..1MiB and <=16MiB total") } sum := sha256.Sum256(data) manifest = append(manifest, File{Version: version, Name: parts[2], Direction: parts[3], SHA256: hex.EncodeToString(sum[:])}) fileWriter, err := writer.CreateHeader(&zip.FileHeader{Name: entry.Name(), Method: zip.Store}) if err != nil { return nil, nil, err } if _, err = fileWriter.Write(data); err != nil { return nil, nil, err } } if len(manifest) == 0 { return nil, nil, errors.New("migrate: no SQL migrations") } for _, file := range manifest { if file.Direction == "down" && !seen[fmt.Sprint(file.Version)+"up"] { return nil, nil, errors.New("migrate: down file lacks up migration") } } sort.Slice(manifest, func(i, j int) bool { if manifest[i].Version == manifest[j].Version { return manifest[i].Direction < manifest[j].Direction } return manifest[i].Version < manifest[j].Version }) if err = writer.Close(); err != nil { return nil, nil, err } files, err := zip.NewReader(bytes.NewReader(archive.Bytes()), int64(archive.Len())) return files, manifest, err } type Runner struct { mu sync.Mutex engine *migration.Migrate db *sql.DB closed bool stopped bool } func NewMySQL(ctx context.Context, dsn string, source fs.FS, directory string) (runner *Runner, err error) { if ctx == nil { return nil, errors.New("migrate: context required") } snapshot, _, err := Snapshot(source, directory) if err != nil { return nil, err } cfg, err := mysqlDriver.ParseDSN(dsn) if err != nil || cfg.DBName == "" { return nil, errors.New("migrate: valid MySQL DSN with database required") } cfg.MultiStatements = true if cfg.Timeout == 0 || cfg.Timeout > 5*time.Second { cfg.Timeout = 5 * time.Second } if cfg.ReadTimeout == 0 || cfg.ReadTimeout > 30*time.Second { cfg.ReadTimeout = 30 * time.Second } if cfg.WriteTimeout == 0 || cfg.WriteTimeout > 30*time.Second { cfg.WriteTimeout = 30 * time.Second } connector, err := mysqlDriver.NewConnector(cfg) if err != nil { return nil, err } db := sql.OpenDB(connector) db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) defer func() { if err != nil { db.Close() } }() conn, err := db.Conn(ctx) if err != nil { return nil, err } driver, err := migrateMySQL.WithConnection(ctx, conn, &migrateMySQL.Config{DatabaseName: cfg.DBName, StatementTimeout: 30 * time.Second}) if err != nil { conn.Close() return nil, err } src, err := iofs.New(snapshot, ".") if err != nil { driver.Close() return nil, err } engine, err := migration.NewWithInstance("iofs", src, cfg.DBName, driver) if err != nil { src.Close() driver.Close() return nil, err } engine.LockTimeout = 10 * time.Second return &Runner{engine: engine, db: db}, nil } // Up applies pending up scripts. MySQL DDL can partially commit; a failed // migration remains dirty and requires operator investigation. No automatic // down/force/dirty reset is provided. Cancellation stops after the current SQL // operation; statements/read I/O are bounded to 30 seconds. func (r *Runner) Up(ctx context.Context) error { if ctx == nil { return errors.New("migrate: context required") } if err := ctx.Err(); err != nil { return err } r.mu.Lock() defer r.mu.Unlock() if r.closed || r.stopped { return errors.New("migrate: closed") } done := make(chan error, 1) go func() { done <- r.engine.Up() }() var err error select { case err = <-done: case <-ctx.Done(): r.stopped = true // golang-migrate retains its graceful-stop state; create a fresh runner to retry. select { case r.engine.GracefulStop <- true: default: } err = <-done return errors.Join(ctx.Err(), err) } if errors.Is(err, migration.ErrNoChange) { return nil } return err } func (r *Runner) Version() (uint, bool, error) { r.mu.Lock() defer r.mu.Unlock() if r.closed { return 0, false, errors.New("migrate: closed") } version, dirty, err := r.engine.Version() if errors.Is(err, migration.ErrNilVersion) { return 0, false, nil } return version, dirty, err } func (r *Runner) Close() error { r.mu.Lock() defer r.mu.Unlock() if r.closed { return nil } r.closed = true a, b := r.engine.Close() return errors.Join(a, b, r.db.Close()) }