package main import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "errors" "fmt" "os" "path/filepath" "strings" "time" "github.com/go-sql-driver/mysql" ) func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "platform migration failed:", err) os.Exit(1) } } func run() error { dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN")) if dsn == "" { return fmt.Errorf("MYSQL_DSN is required") } if len(os.Args) < 2 { return fmt.Errorf("usage: platform-migrate migration.sql [migration.sql ...]") } db, err := sql.Open("mysql", dsn) if err != nil { return err } defer db.Close() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() if err := db.PingContext(ctx); err != nil { return fmt.Errorf("connect mysql: %w", err) } if len(os.Args) == 2 && os.Args[1] == "--server-version" { var version string if err := db.QueryRowContext(ctx, "SELECT VERSION()").Scan(&version); err != nil { return err } fmt.Println(version) return nil } if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS vehicle_platform_schema_migration ( version VARCHAR(255) NOT NULL PRIMARY KEY, checksum CHAR(64) NOT NULL, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) )`); err != nil { return fmt.Errorf("ensure migration journal: %w", err) } for _, path := range os.Args[1:] { contents, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read %s: %w", path, err) } version := filepath.Base(path) sum := sha256.Sum256(contents) checksum := hex.EncodeToString(sum[:]) var recorded string err = db.QueryRowContext(ctx, `SELECT checksum FROM vehicle_platform_schema_migration WHERE version=?`, version).Scan(&recorded) if err == nil { if recorded != checksum { return fmt.Errorf("migration %s was changed after being applied", version) } fmt.Printf("skipped %s (already applied)\n", path) continue } if err != sql.ErrNoRows { return fmt.Errorf("check migration %s: %w", version, err) } statements := splitSQL(string(contents)) for index, statement := range statements { if _, err := db.ExecContext(ctx, statement); err != nil { if isResumableMigrationDDL(err, statement) { fmt.Printf("skipped %s statement %d (schema object already exists)\n", path, index+1) continue } return fmt.Errorf("apply %s statement %d: %w", path, index+1, err) } } if _, err := db.ExecContext(ctx, `INSERT INTO vehicle_platform_schema_migration(version,checksum) VALUES(?,?)`, version, checksum); err != nil { return fmt.Errorf("record migration %s: %w", version, err) } fmt.Printf("applied %s (%d statements)\n", path, len(statements)) } return nil } func isResumableMigrationDDL(err error, statement string) bool { var mysqlErr *mysql.MySQLError if !errors.As(err, &mysqlErr) { return false } upper := strings.ToUpper(strings.TrimSpace(statement)) duplicateColumn := mysqlErr.Number == 1060 && strings.Contains(upper, "ALTER TABLE") && strings.Contains(upper, "ADD COLUMN") duplicateIndex := mysqlErr.Number == 1061 && strings.HasPrefix(upper, "CREATE INDEX") return duplicateColumn || duplicateIndex } // splitSQL intentionally supports the platform's forward-only DDL files. Those // files contain no procedures or quoted semicolons; rejecting empty fragments // keeps deployment output deterministic without enabling multiStatements in DSN. func splitSQL(contents string) []string { lines := strings.Split(contents, "\n") withoutComments := make([]string, 0, len(lines)) for _, line := range lines { if strings.HasPrefix(strings.TrimSpace(line), "--") { continue } withoutComments = append(withoutComments, line) } contents = strings.Join(withoutComments, "\n") fragments := strings.Split(contents, ";") statements := make([]string, 0, len(fragments)) for _, fragment := range fragments { statement := strings.TrimSpace(fragment) if statement != "" { statements = append(statements, statement) } } return statements }