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,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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user