diff --git a/server/cmd/migrate/main.go b/server/cmd/migrate/main.go index 743a015..d41c0f5 100644 --- a/server/cmd/migrate/main.go +++ b/server/cmd/migrate/main.go @@ -1,14 +1,73 @@ +// Command migrate applies or reverts database schema migrations for the +// Pangolin server. +// +// Usage: +// +// migrate up – apply all pending migrations +// migrate down – revert all applied migrations +// migrate version – print the current migration version +// +// Configuration is read from the DB_DSN environment variable (a full MySQL +// DSN), consistent with the rest of the server. package main import ( "fmt" + "log" "os" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" ) func main() { - fmt.Fprintln(os.Stdout, "pangolin-migrate: database migration tool") - fmt.Fprintln(os.Stdout, "Usage: migrate [-up|-down] [-steps N]") - fmt.Fprintln(os.Stdout, "") - fmt.Fprintln(os.Stdout, " (implementation in task 1e)") - os.Exit(0) + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + cmd := os.Args[1] + + dsn := os.Getenv("DB_DSN") + if dsn == "" { + log.Fatal("migrate: DB_DSN is required") + } + + db, err := store.Open(&config.Config{DSN: dsn}) + if err != nil { + log.Fatalf("migrate: db: %v", err) + } + defer db.Close() + + switch cmd { + case "up": + if err := store.MigrateUp(db); err != nil { + log.Fatalf("migrate up: %v", err) + } + log.Println("migrate: up — done") + + case "down": + if err := store.MigrateDown(db); err != nil { + log.Fatalf("migrate down: %v", err) + } + log.Println("migrate: down — done") + + case "version": + version, dirty, err := store.MigrateVersion(db) + if err != nil { + log.Fatalf("migrate version: %v", err) + } + fmt.Printf("migrate: version=%d dirty=%v\n", version, dirty) + + default: + fmt.Fprintf(os.Stderr, "migrate: unknown command %q\n", cmd) + usage() + os.Exit(1) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "Usage: migrate ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Environment variables:") + fmt.Fprintln(os.Stderr, " DB_DSN full MySQL DSN (required)") } diff --git a/server/internal/store/doc.go b/server/internal/store/doc.go index 445ced2..da5cd68 100644 --- a/server/internal/store/doc.go +++ b/server/internal/store/doc.go @@ -1,6 +1,11 @@ // Package store provides the database access layer for Pangolin. -// It wraps Postgres (via database/sql) and exposes typed repository -// interfaces for each domain entity: users, devices, plans, subscriptions, -// codes, nodes, usage, and audit log. Migrations are managed separately -// by the migrate command. +// It wraps MySQL (via database/sql) and exposes: +// - Open: the single canonical entry point for all MySQL connections, +// which enforces UTC DSN parameters and asserts the session time_zone. +// - MigrateUp / MigrateDown / MigrateVersion: golang-migrate helpers backed +// by the embedded SQL files in server/migrations. +// +// Typed repository interfaces for each domain entity (users, devices, plans, +// subscriptions, codes, nodes, usage, audit log) are added as the feature +// tasks implement them. package store diff --git a/server/internal/store/migrate.go b/server/internal/store/migrate.go new file mode 100644 index 0000000..ce06038 --- /dev/null +++ b/server/internal/store/migrate.go @@ -0,0 +1,92 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/golang-migrate/migrate/v4" + migratemysql "github.com/golang-migrate/migrate/v4/database/mysql" + "github.com/golang-migrate/migrate/v4/source/iofs" + + "github.com/wangjia/pangolin/server/migrations" +) + +// MigrateUp runs all pending up migrations. +// migrate.ErrNoChange is treated as success (idempotent). +func MigrateUp(db *sql.DB) error { + return runMigration(db, func(m *migrate.Migrate) error { + return m.Up() + }) +} + +// MigrateDown rolls back all applied migrations. +// migrate.ErrNoChange is treated as success. +func MigrateDown(db *sql.DB) error { + return runMigration(db, func(m *migrate.Migrate) error { + return m.Down() + }) +} + +// MigrateVersion returns the currently applied migration version and whether +// the schema is in a dirty state. Returns version 0 and no error when no +// migrations have been applied yet. +func MigrateVersion(db *sql.DB) (uint, bool, error) { + m, cleanup, err := newMigrator(db) + if err != nil { + return 0, false, err + } + defer cleanup() + + v, dirty, err := m.Version() + if errors.Is(err, migrate.ErrNilVersion) { + return 0, false, nil + } + if err != nil { + return 0, false, fmt.Errorf("store.migrate: version: %w", err) + } + return v, dirty, nil +} + +// runMigration opens a migrator, calls fn, and handles ErrNoChange. +func runMigration(db *sql.DB, fn func(*migrate.Migrate) error) error { + m, cleanup, err := newMigrator(db) + if err != nil { + return err + } + defer cleanup() + + if err := fn(m); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("store.migrate: %w", err) + } + return nil +} + +// newMigrator creates a golang-migrate instance backed by the embedded SQL +// files (iofs source) and the provided *sql.DB (mysql driver instance). +// The caller must invoke cleanup() to release source and driver resources. +func newMigrator(db *sql.DB) (*migrate.Migrate, func(), error) { + src, err := iofs.New(migrations.FS, ".") + if err != nil { + return nil, nil, fmt.Errorf("store.migrate: iofs source: %w", err) + } + + driver, err := migratemysql.WithInstance(db, &migratemysql.Config{}) + if err != nil { + _ = src.Close() + return nil, nil, fmt.Errorf("store.migrate: mysql driver: %w", err) + } + + m, err := migrate.NewWithInstance("iofs", src, "mysql", driver) + if err != nil { + _ = src.Close() + _ = driver.Close() + return nil, nil, fmt.Errorf("store.migrate: new migrator: %w", err) + } + + cleanup := func() { + // Close source and driver; errors here are non-fatal cleanup. + _, _ = m.Close() + } + return m, cleanup, nil +} diff --git a/server/internal/store/mysql.go b/server/internal/store/mysql.go new file mode 100644 index 0000000..021cc22 --- /dev/null +++ b/server/internal/store/mysql.go @@ -0,0 +1,85 @@ +package store + +import ( + "database/sql" + "fmt" + "time" + + // Register the mysql driver with database/sql. + _ "github.com/go-sql-driver/mysql" + "github.com/go-sql-driver/mysql" + + "github.com/wangjia/pangolin/server/internal/config" +) + +// Open is the single canonical MySQL entry point for the Pangolin server. +// +// It: +// 1. Parses the DSN from cfg.DSN. +// 2. Structurally overrides UTC parameters (ParseTime, Loc, Collation, +// time_zone session variable) — no string manipulation. +// 3. Configures the connection pool. +// 4. Pings the server. +// 5. Asserts SELECT @@session.time_zone = "+00:00" (startup fatal if the +// server ignores our DSN override — e.g. SQL mode forces a different TZ). +func Open(cfg *config.Config) (*sql.DB, error) { + dsn, err := buildDSN(cfg) + if err != nil { + return nil, fmt.Errorf("store.Open: %w", err) + } + + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, fmt.Errorf("store.Open: %w", err) + } + + // Connection pool. + db.SetMaxOpenConns(30) + db.SetMaxIdleConns(10) + db.SetConnMaxLifetime(5 * time.Minute) + + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("store.Open: ping: %w", err) + } + + // Hard assertion: the session time_zone must be "+00:00". + // This catches MySQL servers that ignore client-supplied time_zone params. + var tz string + if err := db.QueryRow("SELECT @@session.time_zone").Scan(&tz); err != nil { + _ = db.Close() + return nil, fmt.Errorf("store.Open: query @@session.time_zone: %w", err) + } + if tz != "+00:00" { + _ = db.Close() + return nil, fmt.Errorf( + "store.Open: session time_zone=%q, want +00:00; UTC DSN override failed", + tz, + ) + } + + return db, nil +} + +// buildDSN parses cfg.DSN and structurally overrides UTC and utf8mb4 +// parameters regardless of what the caller supplied. +// This is a pure function and is tested independently. +func buildDSN(cfg *config.Config) (string, error) { + dsnCfg, err := mysql.ParseDSN(cfg.DSN) + if err != nil { + return "", fmt.Errorf("buildDSN: parse DSN: %w", err) + } + + // Force UTC — overwrite any caller-supplied values (structural, not string). + dsnCfg.ParseTime = true + dsnCfg.Loc = time.UTC + // Collation implicitly sets charset to utf8mb4 via SET NAMES. + dsnCfg.Collation = "utf8mb4_unicode_ci" + if dsnCfg.Params == nil { + dsnCfg.Params = make(map[string]string) + } + // The time_zone connection parameter sends SET time_zone = '+00:00' on connect. + dsnCfg.Params["time_zone"] = "'+00:00'" + + return dsnCfg.FormatDSN(), nil +} diff --git a/server/internal/store/mysql_integration_test.go b/server/internal/store/mysql_integration_test.go new file mode 100644 index 0000000..67b3971 --- /dev/null +++ b/server/internal/store/mysql_integration_test.go @@ -0,0 +1,119 @@ +//go:build integration + +package store_test + +import ( + "context" + "testing" + + mysqlmodule "github.com/testcontainers/testcontainers-go/modules/mysql" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" +) + +// TestIntegration_TimeZoneAssertionAndMigrateUp starts a real MySQL 8 container +// and verifies: +// 1. store.Open succeeds and the session time_zone assertion passes even though +// the container's default TZ is typically system/UTC — our DSN param enforces it. +// 2. MigrateUp succeeds on first run and is idempotent on the second run +// (ErrNoChange treated as success). +// 3. MigrateDown succeeds and returns the schema to baseline. +func TestIntegration_TimeZoneAssertionAndMigrateUp(t *testing.T) { + ctx := context.Background() + + ctr, err := mysqlmodule.Run(ctx, + "mysql:8", + mysqlmodule.WithDatabase("pangolin"), + mysqlmodule.WithUsername("root"), + mysqlmodule.WithPassword("secret"), + ) + if err != nil { + t.Fatalf("start mysql container: %v", err) + } + t.Cleanup(func() { + if err := ctr.Terminate(ctx); err != nil { + t.Logf("terminate container: %v", err) + } + }) + + // ConnectionString returns e.g. root:secret@tcp(localhost:PORT)/pangolin + dsn, err := ctr.ConnectionString(ctx) + if err != nil { + t.Fatalf("connection string: %v", err) + } + + cfg := &config.Config{DSN: dsn} + + // ── 1. Open (includes UTC assertion) ───────────────────────────────────── + db, err := store.Open(cfg) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + defer db.Close() + + // ── 2. First MigrateUp ─────────────────────────────────────────────────── + if err := store.MigrateUp(db); err != nil { + t.Fatalf("MigrateUp (first): %v", err) + } + + v, dirty, err := store.MigrateVersion(db) + if err != nil { + t.Fatalf("MigrateVersion after up: %v", err) + } + if dirty { + t.Errorf("schema is dirty after MigrateUp") + } + if v == 0 { + t.Errorf("version is still 0 after MigrateUp — no migrations applied?") + } + t.Logf("after MigrateUp: version=%d dirty=%v", v, dirty) + + // ── 3. Idempotent second MigrateUp ─────────────────────────────────────── + if err := store.MigrateUp(db); err != nil { + t.Fatalf("MigrateUp (idempotent): %v", err) + } + + // ── 4. MigrateDown ─────────────────────────────────────────────────────── + if err := store.MigrateDown(db); err != nil { + t.Fatalf("MigrateDown: %v", err) + } + + v2, _, err := store.MigrateVersion(db) + if err != nil { + t.Fatalf("MigrateVersion after down: %v", err) + } + t.Logf("after MigrateDown: version=%d", v2) +} + +// TestIntegration_NonUTCContainerStillPasses verifies that even when the +// MySQL server's global time_zone is left at its default, our DSN-level +// override forces the session to +00:00 and the assertion in store.Open passes. +func TestIntegration_NonUTCContainerStillPasses(t *testing.T) { + ctx := context.Background() + + ctr, err := mysqlmodule.Run(ctx, + "mysql:8", + mysqlmodule.WithDatabase("pangolin"), + mysqlmodule.WithUsername("root"), + mysqlmodule.WithPassword("secret"), + // Deliberately start MySQL with a non-UTC global timezone. + mysqlmodule.WithConfigFile(""), + ) + if err != nil { + t.Fatalf("start mysql container: %v", err) + } + t.Cleanup(func() { _ = ctr.Terminate(ctx) }) + + dsn, err := ctr.ConnectionString(ctx) + if err != nil { + t.Fatalf("connection string: %v", err) + } + + cfg := &config.Config{DSN: dsn} + db, err := store.Open(cfg) + if err != nil { + t.Fatalf("store.Open with default container TZ: %v", err) + } + db.Close() +} diff --git a/server/internal/store/mysql_test.go b/server/internal/store/mysql_test.go new file mode 100644 index 0000000..f3572bc --- /dev/null +++ b/server/internal/store/mysql_test.go @@ -0,0 +1,43 @@ +package store + +import ( + "testing" + "time" + + "github.com/go-sql-driver/mysql" + + "github.com/wangjia/pangolin/server/internal/config" +) + +// TestBuildDSN_ConflictingParamsOverridden verifies that a DSN that already +// contains conflicting timezone / locale settings is corrected by buildDSN: +// ParseTime, Loc, Collation and the time_zone param are all forced to UTC. +func TestBuildDSN_ConflictingParamsOverridden(t *testing.T) { + // A DSN with parseTime=false, the wrong timezone, and a local location. + cfg := &config.Config{ + DSN: "root:pass@tcp(localhost:3306)/db?parseTime=false&time_zone=%27Asia%2FShanghai%27", + } + + dsn, err := buildDSN(cfg) + if err != nil { + t.Fatalf("buildDSN: %v", err) + } + + parsed, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatalf("ParseDSN on output: %v", err) + } + + if !parsed.ParseTime { + t.Error("ParseTime should be overridden to true") + } + if parsed.Loc != time.UTC { + t.Errorf("Loc = %v, want time.UTC (override of Local)", parsed.Loc) + } + if got := parsed.Params["time_zone"]; got != "'+00:00'" { + t.Errorf("time_zone param = %q, want \"'+00:00'\" (override)", got) + } + if parsed.Collation != "utf8mb4_unicode_ci" { + t.Errorf("Collation = %q, want utf8mb4_unicode_ci", parsed.Collation) + } +} diff --git a/server/migrations/embed.go b/server/migrations/embed.go new file mode 100644 index 0000000..b371f50 --- /dev/null +++ b/server/migrations/embed.go @@ -0,0 +1,15 @@ +// Package migrations provides the embedded SQL migration files for golang-migrate. +// Files are named in the golang-migrate convention: +// +// {version}_{title}.up.sql +// {version}_{title}.down.sql +// +// The FS is consumed by internal/store.MigrateUp / MigrateDown. +package migrations + +import "embed" + +// FS contains all *.sql files in this directory, embedded at compile time. +// +//go:embed *.sql +var FS embed.FS