feat(1e): config PANGOLIN_ prefix + store.Open UTC DSN + go:embed migrations (tsk_zRA6fGU1JuHj)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <up|down|version>")
|
||||
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)")
|
||||
}
|
||||
|
||||
+28
-14
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user