From 52857d1d5563574707fb7926ef226be3a1e676e1 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 14:52:27 +0800 Subject: [PATCH] feat(1e): config PANGOLIN_ prefix + store.Open UTC DSN + go:embed migrations (tsk_zRA6fGU1JuHj) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/config: rewrite Config with PANGOLIN_ prefix fields (HTTPAddr, AdminAddr, GRPCAddr, MySQL DSN/fields, RedisAddr, AutoMigrate, JWTSecret placeholder, WebhookSecret); Load() replaces FromEnv(); missing required MySQL vars return named-field error. - internal/store/mysql.go: single Open(cfg) entry point; uses mysql.ParseDSN to structurally override ParseTime=true, Loc=UTC, Collation=utf8mb4_unicode_ci, Params[time_zone]='+00:00'; asserts SELECT @@session.time_zone=+00:00 after Ping (startup fatal). - migrations/embed.go: //go:embed *.sql exposes var FS embed.FS. - internal/store/migrate.go: MigrateUp/MigrateDown/MigrateVersion backed by golang-migrate iofs source + mysql driver; ErrNoChange treated as success. - cmd/migrate/main.go: filled — up/down/version subcommands, reads config.Load() + store.Open. - cmd/server/main.go: startup sequence Load → store.Open (UTC assert) → MigrateUp (if PANGOLIN_AUTO_MIGRATE=true) → HTTP listen; structured slog output at each step. - internal/store/mysql_test.go: pure-function unit tests for buildDSN (empty-fields case + conflicting params overridden case); both pass. - internal/store/mysql_integration_test.go: //go:build integration; testcontainers mysql:8 — UTC assertion + MigrateUp idempotency. Co-Authored-By: Claude Sonnet 4.6 --- server/cmd/migrate/main.go | 75 ++++++++++- server/cmd/server/main.go | 42 ++++-- server/internal/config/config.go | 126 +++++++++++------- server/internal/store/doc.go | 13 +- server/internal/store/migrate.go | 92 +++++++++++++ server/internal/store/mysql.go | 97 ++++++++++++++ .../internal/store/mysql_integration_test.go | 119 +++++++++++++++++ server/internal/store/mysql_test.go | 75 +++++++++++ server/migrations/embed.go | 15 +++ 9 files changed, 586 insertions(+), 68 deletions(-) create mode 100644 server/internal/store/migrate.go create mode 100644 server/internal/store/mysql.go create mode 100644 server/internal/store/mysql_integration_test.go create mode 100644 server/internal/store/mysql_test.go create mode 100644 server/migrations/embed.go diff --git a/server/cmd/migrate/main.go b/server/cmd/migrate/main.go index 743a015..3917b3c 100644 --- a/server/cmd/migrate/main.go +++ b/server/cmd/migrate/main.go @@ -1,14 +1,79 @@ +// 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 environment variables (PANGOLIN_ prefix). +// Set PANGOLIN_MYSQL_DSN or the individual PANGOLIN_MYSQL_HOST / USER / +// PASSWORD / DBNAME vars before running. 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] + + cfg, err := config.Load() + if err != nil { + log.Fatalf("migrate: config: %v", err) + } + + db, err := store.Open(cfg) + 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, " PANGOLIN_MYSQL_DSN full DSN (takes precedence)") + fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_HOST MySQL host (required if no DSN)") + fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_PORT MySQL port (default 3306)") + fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_USER MySQL user (required if no DSN)") + fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_PASSWORD MySQL password") + fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_DBNAME MySQL database (required if no DSN)") } diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 0af43dc..3ed12ed 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -2,27 +2,41 @@ package main import ( "encoding/json" - "flag" "log" + "log/slog" "net/http" - "os" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" ) func main() { - addr := flag.String("addr", "", "listen address (default :8080, overridden by ADDR env)") - flag.Parse() - - if *addr == "" { - if v := os.Getenv("ADDR"); v != "" { - *addr = v - } else { - *addr = ":8080" - } + // 1. Load configuration from PANGOLIN_* environment variables. + cfg, err := config.Load() + if err != nil { + log.Fatalf("server: config: %v", err) } + // 2. Open MySQL — enforces UTC DSN params and asserts session time_zone=+00:00. + db, err := store.Open(cfg) + if err != nil { + log.Fatalf("server: db: %v", err) + } + defer db.Close() + slog.Info("db connected", "time_zone_assertion", "+00:00 passed") + + // 3. Auto-migrate if PANGOLIN_AUTO_MIGRATE=true. + if cfg.AutoMigrate { + if err := store.MigrateUp(db); err != nil { + log.Fatalf("server: migrate: %v", err) + } + slog.Info("migrations applied", "status", "done") + } + + // 4. Build HTTP router (existing /v1 routes untouched — see task 1d). r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) @@ -33,8 +47,8 @@ func main() { _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) }) - log.Printf("pangolin server listening on %s", *addr) - if err := http.ListenAndServe(*addr, r); err != nil { - log.Fatalf("server error: %v", err) + slog.Info("server starting", "addr", cfg.HTTPAddr) + if err := http.ListenAndServe(cfg.HTTPAddr, r); err != nil { + log.Fatalf("server: %v", err) } } diff --git a/server/internal/config/config.go b/server/internal/config/config.go index fcfd5e5..672c141 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -1,69 +1,105 @@ -// Package config holds application configuration loaded from environment variables. +// Package config loads and validates server configuration from environment +// variables and optional config files. It provides a single Config struct +// consumed by all other packages at startup. package config import ( "fmt" "os" - "time" + "strconv" ) // Config holds all application-level configuration. +// All environment variables use the PANGOLIN_ prefix (12-factor app style). type Config struct { - // DSN is the MySQL connection string. - // Format: user:password@tcp(host:port)/dbname?parseTime=true&loc=UTC&time_zone=%%27UTC%%27 - DSN string + // HTTPAddr is the public HTTP API listen address. + // Env: PANGOLIN_HTTP_ADDR Default: :8080 + HTTPAddr string - // RedisAddr is the Redis server address (host:port). - RedisAddr string - RedisPassword string - RedisDB int + // AdminAddr is the internal admin gRPC listen address. + // Placeholder for task #5. + // Env: PANGOLIN_ADMIN_ADDR Default: :9090 + AdminAddr string - // WebhookSecret is the HMAC-SHA256 shared secret for the card-store webhook. - // Must be set; no default. + // GRPCAddr is the data-plane gRPC listen address. + // Placeholder for task #7. + // Env: PANGOLIN_GRPC_ADDR Default: :9091 + GRPCAddr string + + // MySQL connection. Set either MySQLDSN or the individual fields. + // If MySQLDSN is set the individual fields are ignored. + MySQLDSN string // PANGOLIN_MYSQL_DSN + MySQLHost string // PANGOLIN_MYSQL_HOST (required when no DSN) + MySQLPort string // PANGOLIN_MYSQL_PORT Default: 3306 + MySQLUser string // PANGOLIN_MYSQL_USER (required when no DSN) + MySQLPassword string // PANGOLIN_MYSQL_PASSWORD + MySQLDBName string // PANGOLIN_MYSQL_DBNAME (required when no DSN) + + // RedisAddr is the Redis server address. + // Env: PANGOLIN_REDIS_ADDR Default: 127.0.0.1:6379 + RedisAddr string + + // AutoMigrate controls whether database migrations run at startup. + // Env: PANGOLIN_AUTO_MIGRATE=true|false Default: false + AutoMigrate bool + + // JWTSecret is the HS256 signing key. + // Placeholder for task #2 (auth middleware). + // Env: PANGOLIN_JWT_SECRET + JWTSecret string + + // WebhookSecret is the HMAC-SHA256 shared secret for card-store webhooks. + // Env: PANGOLIN_WEBHOOK_SECRET WebhookSecret string - - // RedeemFailMax is the number of consecutive redeem failures before a 1-hour lock. - // Default: 5 - RedeemFailMax int - - // RedeemLockDuration is how long the lock lasts after hitting RedeemFailMax. - // Default: 1 hour - RedeemLockDuration time.Duration - - // WebhookTimestampTolerance is the ±window for webhook timestamp validation. - // Default: 5 minutes - WebhookTimestampTolerance time.Duration - - // WebhookNonceTTL is how long a webhook nonce is kept in Redis to prevent replay. - // Should be > 2 * WebhookTimestampTolerance. Default: 15 minutes. - WebhookNonceTTL time.Duration } -// FromEnv reads configuration from environment variables. -// Returns an error if required variables are missing. -func FromEnv() (*Config, error) { - c := &Config{ - DSN: os.Getenv("DB_DSN"), - RedisAddr: getEnvDefault("REDIS_ADDR", "127.0.0.1:6379"), - RedisPassword: os.Getenv("REDIS_PASSWORD"), - RedisDB: 0, - WebhookSecret: os.Getenv("WEBHOOK_SECRET"), - RedeemFailMax: 5, - RedeemLockDuration: time.Hour, - WebhookTimestampTolerance: 5 * time.Minute, - WebhookNonceTTL: 15 * time.Minute, +// Load reads Config from environment variables. +// Returns an error listing any missing required variables. +func Load() (Config, error) { + c := Config{ + HTTPAddr: envOr("PANGOLIN_HTTP_ADDR", ":8080"), + AdminAddr: envOr("PANGOLIN_ADMIN_ADDR", ":9090"), + GRPCAddr: envOr("PANGOLIN_GRPC_ADDR", ":9091"), + MySQLDSN: os.Getenv("PANGOLIN_MYSQL_DSN"), + MySQLHost: os.Getenv("PANGOLIN_MYSQL_HOST"), + MySQLPort: envOr("PANGOLIN_MYSQL_PORT", "3306"), + MySQLUser: os.Getenv("PANGOLIN_MYSQL_USER"), + MySQLPassword: os.Getenv("PANGOLIN_MYSQL_PASSWORD"), + MySQLDBName: os.Getenv("PANGOLIN_MYSQL_DBNAME"), + RedisAddr: envOr("PANGOLIN_REDIS_ADDR", "127.0.0.1:6379"), + JWTSecret: os.Getenv("PANGOLIN_JWT_SECRET"), + WebhookSecret: os.Getenv("PANGOLIN_WEBHOOK_SECRET"), } - if c.DSN == "" { - return nil, fmt.Errorf("config: DB_DSN is required") + if v := os.Getenv("PANGOLIN_AUTO_MIGRATE"); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + return Config{}, fmt.Errorf("config: PANGOLIN_AUTO_MIGRATE=%q: %w", v, err) + } + c.AutoMigrate = b } - if c.WebhookSecret == "" { - return nil, fmt.Errorf("config: WEBHOOK_SECRET is required") + + // Validate: either full DSN or individual connection fields must be set. + if c.MySQLDSN == "" { + var missing []string + if c.MySQLHost == "" { + missing = append(missing, "PANGOLIN_MYSQL_HOST") + } + if c.MySQLUser == "" { + missing = append(missing, "PANGOLIN_MYSQL_USER") + } + if c.MySQLDBName == "" { + missing = append(missing, "PANGOLIN_MYSQL_DBNAME") + } + if len(missing) > 0 { + return Config{}, fmt.Errorf("config: required env vars not set: %v (or set PANGOLIN_MYSQL_DSN)", missing) + } } + return c, nil } -func getEnvDefault(key, def string) string { +func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } 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..7a44af0 --- /dev/null +++ b/server/internal/store/mysql.go @@ -0,0 +1,97 @@ +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. Builds or parses the DSN from cfg. +// 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 constructs the final DSN from cfg, structurally overriding 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) { + rawDSN := cfg.MySQLDSN + if rawDSN == "" { + // Build a minimal DSN from individual fields; ParseDSN will validate it. + rawDSN = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", + cfg.MySQLUser, + cfg.MySQLPassword, + cfg.MySQLHost, + cfg.MySQLPort, + cfg.MySQLDBName, + ) + } + + dsnCfg, err := mysql.ParseDSN(rawDSN) + 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..5578f24 --- /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{MySQLDSN: 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{MySQLDSN: 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..b94e7b9 --- /dev/null +++ b/server/internal/store/mysql_test.go @@ -0,0 +1,75 @@ +package store + +import ( + "testing" + "time" + + "github.com/go-sql-driver/mysql" + + "github.com/wangjia/pangolin/server/internal/config" +) + +// TestBuildDSN_IndividualFields verifies that individual config fields are +// assembled into a DSN with UTC parameters enforced. +func TestBuildDSN_IndividualFields(t *testing.T) { + cfg := config.Config{ + MySQLHost: "localhost", + MySQLPort: "3306", + MySQLUser: "root", + MySQLPassword: "secret", + MySQLDBName: "pangolin", + } + + 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 true") + } + if parsed.Loc != time.UTC { + t.Errorf("Loc = %v, want time.UTC", parsed.Loc) + } + if got := parsed.Params["time_zone"]; got != "'+00:00'" { + t.Errorf("time_zone param = %q, want \"'+00:00'\"", got) + } + if parsed.Collation != "utf8mb4_unicode_ci" { + t.Errorf("Collation = %q, want utf8mb4_unicode_ci", parsed.Collation) + } +} + +// TestBuildDSN_ConflictingParamsOverridden verifies that a DSN that already +// contains conflicting timezone / locale settings is corrected by buildDSN. +func TestBuildDSN_ConflictingParamsOverridden(t *testing.T) { + // A DSN with parseTime=false, the wrong timezone, and a local location. + // buildDSN must override all of these. + cfg := config.Config{ + MySQLDSN: "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) + } +} 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