feat: build vehicle data platform and production pipeline
This commit is contained in:
129
vehicle-data-platform/apps/api/cmd/platform-migrate/main.go
Normal file
129
vehicle-data-platform/apps/api/cmd/platform-migrate/main.go
Normal file
@@ -0,0 +1,129 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user