refactor: 统一 DB/Redis 配置为 DSN 模式 + 移除 Helm/K8s 部署
- DatabaseConfig: Host/Port/User/Password/Name/DBName/SSLMode → 单个 DSN 字段 - RedisConfig: Host/Port/Password/DB/URL → 单个 DSN 字段 - 环境变量: GOCHAT_DATABASE_* (7个) → GOCHAT_DATABASE_DSN, GOCHAT_REDIS_* (5个) → GOCHAT_REDIS_DSN - validator.go: DSN URL 解析校验 (scheme + host) - redis.go: redis.ParseURL(cfg.DSN) 直连 - 所有 docker-compose / CI / shell 脚本 / .env 同步更新 - 删除 deploy/helm/ 整个目录 (20个文件) - CI 删除 helm-validate / deploy-staging / deploy-production 三个 job - 文档同步更新 (README, 架构设计, PRD, 滚动升级)
This commit is contained in:
@@ -5,7 +5,7 @@ server:
|
||||
mode: "debug"
|
||||
|
||||
database:
|
||||
name: "gochat_dev"
|
||||
dsn: "postgres://postgres:xiha02@localhost:5444/gochat_dev?sslmode=disable"
|
||||
log_level: "info"
|
||||
|
||||
log:
|
||||
|
||||
@@ -14,7 +14,7 @@ server:
|
||||
max_age: 86400
|
||||
|
||||
database:
|
||||
name: "gochat_production"
|
||||
dsn: "postgres://gochat:CHANGE_ME@localhost:5432/gochat_production?sslmode=require"
|
||||
pool_max: 20
|
||||
log_level: "warn"
|
||||
|
||||
|
||||
@@ -14,13 +14,7 @@ server:
|
||||
max_age: 86400 # preflight cache duration in seconds
|
||||
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 5444
|
||||
user: "postgres"
|
||||
password: "xiha02"
|
||||
name: "gochat_dev"
|
||||
dbname: "gochat_dev"
|
||||
sslmode: "disable"
|
||||
dsn: "postgres://postgres:xiha02@localhost:5444/gochat_dev?sslmode=disable"
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
conn_max_lifetime: 3600 # seconds
|
||||
@@ -28,11 +22,7 @@ database:
|
||||
migrations_path: "migrations"
|
||||
|
||||
redis:
|
||||
url: "redis://:xiha02@localhost:6397/0"
|
||||
host: "localhost"
|
||||
port: 6397
|
||||
password: "xiha02"
|
||||
db: 0
|
||||
dsn: "redis://:xiha02@localhost:6397/0"
|
||||
pool_size: 50
|
||||
|
||||
jwt:
|
||||
|
||||
@@ -142,7 +142,7 @@ func initDB(cfg *config.Config) (*gorm.DB, error) {
|
||||
logger.Config{LogLevel: logger.Info},
|
||||
)
|
||||
|
||||
db, err := gorm.Open(postgres.Open(cfg.Database.DSN()), &gorm.Config{
|
||||
db, err := gorm.Open(postgres.Open(cfg.Database.DSN), &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// NewDatabase creates a GORM database connection.
|
||||
// Pattern follows Chatwoot's ActiveRecord PostgreSQL setup in config/database.yml
|
||||
func NewDatabase(cfg *config.DatabaseConfig, logLevel string) (*gorm.DB, error) {
|
||||
dsn := cfg.DSN()
|
||||
dsn := cfg.DSN
|
||||
|
||||
// Map log level to GORM logger level
|
||||
var gormLogLevel gormlogger.LogLevel
|
||||
@@ -54,8 +54,7 @@ func NewDatabase(cfg *config.DatabaseConfig, logLevel string) (*gorm.DB, error)
|
||||
// Set default statement timeout (ref: Chatwoot POSTGRES_STATEMENT_TIMEOUT)
|
||||
db.Set("gorm:statement_timeout", "5000ms")
|
||||
|
||||
applogger.L().Infof("Connected to PostgreSQL: %s@%s:%d/%s (pool=%d)",
|
||||
cfg.User, cfg.Host, cfg.Port, cfg.DBName, cfg.MaxOpenConns)
|
||||
applogger.L().Infof("Connected to PostgreSQL (pool=%d)", cfg.MaxOpenConns)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
@@ -15,24 +13,11 @@ import (
|
||||
// NewRedisClient creates a Redis client for caching, Pub/Sub, and sessions.
|
||||
// Reference: Chatwoot config/cable.yml Redis adapter configuration
|
||||
func NewRedisClient(cfg *config.RedisConfig) (*redis.Client, error) {
|
||||
var opts *redis.Options
|
||||
var err error
|
||||
if cfg.Host != "" && cfg.Port > 0 {
|
||||
opts = &redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
}
|
||||
} else {
|
||||
opts, err = parseRedisURL(cfg.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Redis URL: %w", err)
|
||||
}
|
||||
opts, err := redis.ParseURL(cfg.DSN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Redis DSN: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Password != "" {
|
||||
opts.Password = cfg.Password
|
||||
}
|
||||
poolSize := cfg.PoolSize
|
||||
if poolSize <= 0 {
|
||||
poolSize = 50
|
||||
@@ -53,24 +38,3 @@ func NewRedisClient(cfg *config.RedisConfig) (*redis.Client, error) {
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// parseRedisURL parses a Redis URL into redis.Options.
|
||||
// Handles both "redis://host:port" and "redis://user:password@host:port/db" formats.
|
||||
func parseRedisURL(urlStr string) (*redis.Options, error) {
|
||||
opts, err := redis.ParseURL(urlStr)
|
||||
if err != nil {
|
||||
// Fallback: simple host:port parsing
|
||||
parts := strings.Split(strings.TrimPrefix(urlStr, "redis://"), ":")
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("invalid Redis URL format: %s", urlStr)
|
||||
}
|
||||
port, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid Redis port: %s", parts[1])
|
||||
}
|
||||
opts = &redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", parts[0], port),
|
||||
}
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
@@ -10,18 +9,12 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewRedisClientPrefersHostAndPortOverURL(t *testing.T) {
|
||||
func TestNewRedisClientFromDSN(t *testing.T) {
|
||||
server := miniredis.RunT(t)
|
||||
host, portText, err := net.SplitHostPort(server.Addr())
|
||||
require.NoError(t, err)
|
||||
port, err := strconv.Atoi(portText)
|
||||
require.NoError(t, err)
|
||||
|
||||
dsn := fmt.Sprintf("redis://%s/0", server.Addr())
|
||||
client, err := NewRedisClient(&config.RedisConfig{
|
||||
URL: "redis://localhost:6397/0",
|
||||
Host: host,
|
||||
Port: port,
|
||||
DB: 0,
|
||||
DSN: dsn,
|
||||
PoolSize: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -95,13 +95,9 @@ type CORSConfig struct {
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
User string `mapstructure:"user"`
|
||||
Password string `mapstructure:"password"`
|
||||
Name string `mapstructure:"name"`
|
||||
DBName string `mapstructure:"dbname"`
|
||||
SSLMode string `mapstructure:"sslmode"`
|
||||
// DSN is the full PostgreSQL connection string, e.g.
|
||||
// "postgres://user:password@host:port/dbname?sslmode=disable"
|
||||
DSN string `mapstructure:"dsn"`
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"` // seconds
|
||||
@@ -109,24 +105,11 @@ type DatabaseConfig struct {
|
||||
MigrationsPath string `mapstructure:"migrations_path"` // path to migration files (default: "migrations")
|
||||
}
|
||||
|
||||
func (d DatabaseConfig) DSN() string {
|
||||
dbname := d.DBName
|
||||
if dbname == "" {
|
||||
dbname = d.Name
|
||||
}
|
||||
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||
d.Host, d.Port, d.User, d.Password, dbname, d.SSLMode)
|
||||
}
|
||||
|
||||
// MigrateDSN returns a PostgreSQL connection URL in the format
|
||||
// expected by golang-migrate: postgres://user:password@host:port/dbname?sslmode=mode
|
||||
// MigrateDSN returns the connection string for golang-migrate.
|
||||
// Same as DSN — both GORM (pgx) and golang-migrate accept the
|
||||
// "postgres://user:password@host:port/dbname?sslmode=mode" URL format.
|
||||
func (d DatabaseConfig) MigrateDSN() string {
|
||||
dbname := d.DBName
|
||||
if dbname == "" {
|
||||
dbname = d.Name
|
||||
}
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
||||
d.User, d.Password, d.Host, d.Port, dbname, d.SSLMode)
|
||||
return d.DSN
|
||||
}
|
||||
|
||||
// GetMigrationsPath returns the migrations path, defaulting to "migrations" if not set.
|
||||
@@ -138,11 +121,10 @@ func (d DatabaseConfig) GetMigrationsPath() string {
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Password string `mapstructure:"password"`
|
||||
DB int `mapstructure:"db"`
|
||||
URL string `mapstructure:"url"`
|
||||
// DSN is the full Redis connection string, e.g.
|
||||
// "redis://:password@host:port/db"
|
||||
// "rediss://host:port" (TLS)
|
||||
DSN string `mapstructure:"dsn"`
|
||||
PoolSize int `mapstructure:"pool_size"`
|
||||
}
|
||||
|
||||
@@ -469,7 +451,7 @@ func (r *ConfigReloader) Stop() {
|
||||
func LoadWithEnv(env string) (*Config, error) {
|
||||
v := viper.New()
|
||||
|
||||
// Env key replacer: GOCHAT_DATABASE_HOST → database.host
|
||||
// Env key replacer: GOCHAT_DATABASE_DSN → database.dsn
|
||||
v.SetEnvPrefix("GOCHAT")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
@@ -480,23 +462,13 @@ func LoadWithEnv(env string) (*Config, error) {
|
||||
"GOCHAT_SERVER_HOST": "server.host",
|
||||
"GOCHAT_SERVER_PORT": "server.port",
|
||||
"GOCHAT_SERVER_MODE": "server.mode",
|
||||
"GOCHAT_DATABASE_HOST": "database.host",
|
||||
"GOCHAT_DATABASE_PORT": "database.port",
|
||||
"GOCHAT_DATABASE_USER": "database.user",
|
||||
"GOCHAT_DATABASE_PASSWORD": "database.password",
|
||||
"GOCHAT_DATABASE_NAME": "database.name",
|
||||
"GOCHAT_DATABASE_DBNAME": "database.dbname",
|
||||
"GOCHAT_DATABASE_SSLMODE": "database.sslmode",
|
||||
"GOCHAT_DATABASE_DSN": "database.dsn",
|
||||
"GOCHAT_DATABASE_MAX_IDLE_CONNS": "database.max_idle_conns",
|
||||
"GOCHAT_DATABASE_MAX_OPEN_CONNS": "database.max_open_conns",
|
||||
"GOCHAT_DATABASE_CONN_MAX_LIFETIME": "database.conn_max_lifetime",
|
||||
"GOCHAT_DATABASE_RUN_MIGRATIONS": "database.run_migrations",
|
||||
"GOCHAT_DATABASE_MIGRATIONS_PATH": "database.migrations_path",
|
||||
"GOCHAT_REDIS_URL": "redis.url",
|
||||
"GOCHAT_REDIS_HOST": "redis.host",
|
||||
"GOCHAT_REDIS_PORT": "redis.port",
|
||||
"GOCHAT_REDIS_PASSWORD": "redis.password",
|
||||
"GOCHAT_REDIS_DB": "redis.db",
|
||||
"GOCHAT_REDIS_DSN": "redis.dsn",
|
||||
"GOCHAT_REDIS_POOL_SIZE": "redis.pool_size",
|
||||
"GOCHAT_JWT_SECRET": "jwt.secret",
|
||||
"JWT_SECRET": "jwt.secret", // Alias for compatibility (no prefix)
|
||||
@@ -672,18 +644,12 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("server.port", 3000)
|
||||
v.SetDefault("server.mode", "debug")
|
||||
|
||||
v.SetDefault("database.host", "localhost")
|
||||
v.SetDefault("database.port", 5432)
|
||||
v.SetDefault("database.sslmode", "disable")
|
||||
v.SetDefault("database.max_idle_conns", 10)
|
||||
v.SetDefault("database.max_open_conns", 100)
|
||||
v.SetDefault("database.conn_max_lifetime", 3600)
|
||||
v.SetDefault("database.run_migrations", false)
|
||||
v.SetDefault("database.migrations_path", "migrations")
|
||||
|
||||
v.SetDefault("redis.host", "localhost")
|
||||
v.SetDefault("redis.port", 6379)
|
||||
v.SetDefault("redis.db", 0)
|
||||
v.SetDefault("redis.pool_size", 50)
|
||||
|
||||
v.SetDefault("jwt.expiry_hours", 72)
|
||||
|
||||
@@ -12,24 +12,20 @@ func TestValidate_ValidConfig(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "gochat",
|
||||
Password: "secret",
|
||||
Name: "gochat_db",
|
||||
DSN: "postgres://gochat:secret@localhost:5432/gochat_db?sslmode=disable",
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
URL: "redis://localhost:6379",
|
||||
DSN: "redis://localhost:6379",
|
||||
},
|
||||
JWT: JWTConfig{
|
||||
Secret: "test-secret-key-min-32-chars!!",
|
||||
ExpiryHours: 24,
|
||||
RefreshExpiryHours: 168,
|
||||
},
|
||||
Log: LogConfig{Level: "info", Format: "json"},
|
||||
Worker: WorkerConfig{Concurrency: 4, BlockTimeoutS: 5, SweepIntervalS: 30},
|
||||
OAuth: OAuthConfig{},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700", IndexPrefix: "gochat_", TimeoutSeconds: 5},
|
||||
Log: LogConfig{Level: "info", Format: "json"},
|
||||
Worker: WorkerConfig{Concurrency: 4, BlockTimeoutS: 5, SweepIntervalS: 30},
|
||||
OAuth: OAuthConfig{},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700", IndexPrefix: "gochat_", TimeoutSeconds: 5},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
@@ -39,8 +35,8 @@ func TestValidate_ValidConfig(t *testing.T) {
|
||||
func TestValidate_InvalidPort(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 0, Mode: "debug"},
|
||||
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"},
|
||||
Redis: RedisConfig{DSN: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
}
|
||||
|
||||
@@ -52,8 +48,8 @@ func TestValidate_InvalidPort(t *testing.T) {
|
||||
func TestValidate_InvalidMode(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "invalid"},
|
||||
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"},
|
||||
Redis: RedisConfig{DSN: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
}
|
||||
|
||||
@@ -62,62 +58,62 @@ func TestValidate_InvalidMode(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "invalid server mode")
|
||||
}
|
||||
|
||||
func TestValidate_MissingDBHost(t *testing.T) {
|
||||
func TestValidate_MissingDBDSN(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{Name: "db", User: "user"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
Database: DatabaseConfig{},
|
||||
Redis: RedisConfig{DSN: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "database host is required")
|
||||
assert.Contains(t, err.Error(), "database DSN is required")
|
||||
}
|
||||
|
||||
func TestValidate_MissingDBName(t *testing.T) {
|
||||
func TestValidate_InvalidDBDSN(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{Host: "localhost", User: "user"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
Database: DatabaseConfig{DSN: "mysql://user@localhost/db"},
|
||||
Redis: RedisConfig{DSN: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "database name is required")
|
||||
assert.Contains(t, err.Error(), "invalid database DSN")
|
||||
}
|
||||
|
||||
func TestValidate_MissingRedisURL(t *testing.T) {
|
||||
func TestValidate_MissingRedisDSN(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
|
||||
Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "redis url is required")
|
||||
assert.Contains(t, err.Error(), "redis DSN is required")
|
||||
}
|
||||
|
||||
func TestValidate_InvalidRedisURL(t *testing.T) {
|
||||
func TestValidate_InvalidRedisDSN(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
|
||||
Redis: RedisConfig{URL: "not-a-valid-url://::"},
|
||||
Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"},
|
||||
Redis: RedisConfig{DSN: "not-a-valid-url://::"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid redis URL")
|
||||
assert.Contains(t, err.Error(), "invalid redis DSN")
|
||||
}
|
||||
|
||||
func TestValidate_JWTSecretInProduction(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release"},
|
||||
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"},
|
||||
Redis: RedisConfig{DSN: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "change-me-in-production"},
|
||||
}
|
||||
|
||||
@@ -129,8 +125,8 @@ func TestValidate_JWTSecretInProduction(t *testing.T) {
|
||||
func TestValidate_InvalidLogLevel(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"},
|
||||
Redis: RedisConfig{DSN: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
Log: LogConfig{Level: "invalid"},
|
||||
}
|
||||
@@ -143,8 +139,8 @@ func TestValidate_InvalidLogLevel(t *testing.T) {
|
||||
func TestValidate_InvalidWorkerConcurrency(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"},
|
||||
Redis: RedisConfig{DSN: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 0},
|
||||
@@ -157,13 +153,13 @@ func TestValidate_InvalidWorkerConcurrency(t *testing.T) {
|
||||
|
||||
func TestValidate_SearchMeilisearchRequiresValidHost(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "not a url", TimeoutSeconds: 5},
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"},
|
||||
Redis: RedisConfig{DSN: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "not a url", TimeoutSeconds: 5},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
@@ -173,13 +169,13 @@ func TestValidate_SearchMeilisearchRequiresValidHost(t *testing.T) {
|
||||
|
||||
func TestValidate_SearchDBFallbackAllowed(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
|
||||
Search: SearchConfig{Engine: "db"},
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"},
|
||||
Redis: RedisConfig{DSN: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
|
||||
Search: SearchConfig{Engine: "db"},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
@@ -188,13 +184,13 @@ func TestValidate_SearchDBFallbackAllowed(t *testing.T) {
|
||||
|
||||
func TestValidate_SearchDBFallbackRejectedInRelease(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release"},
|
||||
Database: DatabaseConfig{Host: "localhost", Name: "db", User: "user"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
|
||||
Search: SearchConfig{Engine: "db"},
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release"},
|
||||
Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"},
|
||||
Redis: RedisConfig{DSN: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
|
||||
Search: SearchConfig{Engine: "db"},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
@@ -202,22 +198,13 @@ func TestValidate_SearchDBFallbackRejectedInRelease(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "release mode requires meilisearch")
|
||||
}
|
||||
|
||||
func TestDatabaseConfig_DSN(t *testing.T) {
|
||||
func TestDatabaseConfig_MigrateDSN(t *testing.T) {
|
||||
cfg := DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "gochat",
|
||||
Password: "secret",
|
||||
DBName: "gochat_db",
|
||||
SSLMode: "disable",
|
||||
DSN: "postgres://gochat:secret@localhost:5432/gochat_db?sslmode=disable",
|
||||
}
|
||||
|
||||
dsn := cfg.DSN()
|
||||
assert.Contains(t, dsn, "host=localhost")
|
||||
assert.Contains(t, dsn, "port=5432")
|
||||
assert.Contains(t, dsn, "user=gochat")
|
||||
assert.Contains(t, dsn, "dbname=gochat_db")
|
||||
assert.Contains(t, dsn, "sslmode=disable")
|
||||
dsn := cfg.MigrateDSN()
|
||||
assert.Equal(t, "postgres://gochat:secret@localhost:5432/gochat_db?sslmode=disable", dsn)
|
||||
}
|
||||
|
||||
func TestJWTConfig_ExpiryDuration(t *testing.T) {
|
||||
|
||||
@@ -74,12 +74,12 @@ func TestConfigReloader_ApplyReloadableFields(t *testing.T) {
|
||||
oldCfg := validTestConfig()
|
||||
oldCfg.Log.Level = "debug"
|
||||
oldCfg.Worker.Concurrency = 4
|
||||
oldCfg.Database.Host = "original-host" // immutable field
|
||||
oldCfg.Database.DSN = "postgres://user@original-host:5432/db?sslmode=disable" // immutable field
|
||||
|
||||
newCfg := validTestConfig()
|
||||
newCfg.Log.Level = "info"
|
||||
newCfg.Worker.Concurrency = 8
|
||||
newCfg.Database.Host = "changed-host" // should NOT be applied
|
||||
newCfg.Database.DSN = "postgres://user@changed-host:5432/db?sslmode=disable" // should NOT be applied
|
||||
|
||||
r := &ConfigReloader{cfg: oldCfg}
|
||||
r.applyReloadableFields(newCfg)
|
||||
@@ -89,7 +89,7 @@ func TestConfigReloader_ApplyReloadableFields(t *testing.T) {
|
||||
assert.Equal(t, 8, r.cfg.Worker.Concurrency)
|
||||
|
||||
// Immutable fields should NOT be updated
|
||||
assert.Equal(t, "original-host", r.cfg.Database.Host)
|
||||
assert.Equal(t, "postgres://user@original-host:5432/db?sslmode=disable", r.cfg.Database.DSN)
|
||||
}
|
||||
|
||||
func TestConfigReloader_Stop(t *testing.T) {
|
||||
@@ -128,14 +128,9 @@ server:
|
||||
port: 3000
|
||||
mode: "debug"
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 5432
|
||||
user: "gochat"
|
||||
password: "secret"
|
||||
dbname: "gochat_db"
|
||||
sslmode: "disable"
|
||||
dsn: "postgres://gochat:secret@localhost:5432/gochat_db?sslmode=disable"
|
||||
redis:
|
||||
url: "redis://localhost:6379"
|
||||
dsn: "redis://localhost:6379"
|
||||
jwt:
|
||||
secret: "test-secret-key-min-32-chars!!"
|
||||
expiry_hours: 72
|
||||
@@ -185,7 +180,7 @@ func TestLoadDotEnv_QuotedValues(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "my-super-secret-key-with-quotes", value)
|
||||
|
||||
_, value, ok = parseDotEnvLine("GOCHAT_REDIS_URL='redis://special-host:6379'")
|
||||
_, value, ok = parseDotEnvLine("GOCHAT_REDIS_DSN='redis://special-host:6379'")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "redis://special-host:6379", value)
|
||||
}
|
||||
@@ -199,11 +194,11 @@ func TestEnvBindingsComplete(t *testing.T) {
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
// GOCHAT_DATABASE_HOST → viper key "database.host" via env prefix + replacer
|
||||
// GOCHAT_DATABASE_DSN → viper key "database.dsn" via env prefix + replacer
|
||||
// The replacer replaces . with _ in viper keys when looking up env vars.
|
||||
// So viper key "database.host" → env key "DATABASE_HOST" → with prefix "GOCHAT_DATABASE_HOST"
|
||||
lowerKey := strings.ToLower(strings.ReplaceAll("database.host", ".", "_"))
|
||||
assert.Equal(t, "database_host", lowerKey)
|
||||
// So viper key "database.dsn" → env key "DATABASE_DSN" → with prefix "GOCHAT_DATABASE_DSN"
|
||||
lowerKey := strings.ToLower(strings.ReplaceAll("database.dsn", ".", "_"))
|
||||
assert.Equal(t, "database_dsn", lowerKey)
|
||||
}
|
||||
|
||||
// --- Defaults Tests ---
|
||||
@@ -215,11 +210,9 @@ func TestSetDefaults(t *testing.T) {
|
||||
assert.Equal(t, "0.0.0.0", v.GetString("server.host"))
|
||||
assert.Equal(t, 3000, v.GetInt("server.port"))
|
||||
assert.Equal(t, "debug", v.GetString("server.mode"))
|
||||
assert.Equal(t, "localhost", v.GetString("database.host"))
|
||||
assert.Equal(t, 5432, v.GetInt("database.port"))
|
||||
assert.Equal(t, "disable", v.GetString("database.sslmode"))
|
||||
assert.Equal(t, "localhost", v.GetString("redis.host"))
|
||||
assert.Equal(t, 6379, v.GetInt("redis.port"))
|
||||
assert.Equal(t, 10, v.GetInt("database.max_idle_conns"))
|
||||
assert.Equal(t, 100, v.GetInt("database.max_open_conns"))
|
||||
assert.Equal(t, 50, v.GetInt("redis.pool_size"))
|
||||
assert.Equal(t, "debug", v.GetString("log.level"))
|
||||
assert.Equal(t, "json", v.GetString("log.format"))
|
||||
assert.Equal(t, 4, v.GetInt("worker.concurrency"))
|
||||
@@ -244,8 +237,8 @@ func TestApplyZeroDefaults(t *testing.T) {
|
||||
func validTestConfig() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
|
||||
Database: DatabaseConfig{Host: "localhost", Port: 5432, User: "gochat", Password: "secret", Name: "gochat_db", DBName: "gochat_db"},
|
||||
Redis: RedisConfig{URL: "redis://localhost:6379"},
|
||||
Database: DatabaseConfig{DSN: "postgres://gochat:secret@localhost:5432/gochat_db?sslmode=disable"},
|
||||
Redis: RedisConfig{DSN: "redis://localhost:6379"},
|
||||
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!", ExpiryHours: 24, RefreshExpiryHours: 168},
|
||||
Log: LogConfig{Level: "info", Format: "json"},
|
||||
Worker: WorkerConfig{Concurrency: 4},
|
||||
|
||||
@@ -20,29 +20,33 @@ func Validate(cfg *Config) error {
|
||||
}
|
||||
|
||||
// Database validation (ref: Chatwoot requires POSTGRES_* env vars)
|
||||
if cfg.Database.Host == "" {
|
||||
return fmt.Errorf("database host is required")
|
||||
if cfg.Database.DSN == "" {
|
||||
return fmt.Errorf("database DSN is required")
|
||||
}
|
||||
if cfg.Database.Name == "" {
|
||||
return fmt.Errorf("database name is required")
|
||||
dbURL, err := url.Parse(cfg.Database.DSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid database DSN: %w", err)
|
||||
}
|
||||
if cfg.Database.User == "" {
|
||||
return fmt.Errorf("database user is required")
|
||||
if dbURL.Scheme != "postgres" && dbURL.Scheme != "postgresql" {
|
||||
return fmt.Errorf("invalid database DSN: scheme must be postgres or postgresql, got %s", dbURL.Scheme)
|
||||
}
|
||||
if dbURL.Host == "" {
|
||||
return fmt.Errorf("invalid database DSN: host is required")
|
||||
}
|
||||
|
||||
// Redis validation (ref: Chatwoot config/cable.yml requires REDIS_URL)
|
||||
if cfg.Redis.URL == "" {
|
||||
return fmt.Errorf("redis url is required")
|
||||
// Redis validation (ref: Chatwoot config/cable.yml requires Redis connection)
|
||||
if cfg.Redis.DSN == "" {
|
||||
return fmt.Errorf("redis DSN is required")
|
||||
}
|
||||
redisURL, err := url.Parse(cfg.Redis.URL)
|
||||
redisURL, err := url.Parse(cfg.Redis.DSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid redis URL: %w", err)
|
||||
return fmt.Errorf("invalid redis DSN: %w", err)
|
||||
}
|
||||
if redisURL.Scheme != "redis" && redisURL.Scheme != "rediss" {
|
||||
return fmt.Errorf("invalid redis URL: scheme must be redis or rediss, got %s", redisURL.Scheme)
|
||||
return fmt.Errorf("invalid redis DSN: scheme must be redis or rediss, got %s", redisURL.Scheme)
|
||||
}
|
||||
if redisURL.Host == "" {
|
||||
return fmt.Errorf("invalid redis URL: host is required")
|
||||
return fmt.Errorf("invalid redis DSN: host is required")
|
||||
}
|
||||
|
||||
// JWT validation
|
||||
|
||||
@@ -8,12 +8,21 @@ set -euo pipefail
|
||||
ENV="${1:-production}"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="${GOCHAT_BACKUP_DIR:-/var/backups/gochat}"
|
||||
DB_NAME="${POSTGRES_DB:-gochat_production}"
|
||||
DB_USER="${POSTGRES_USER:-gochat}"
|
||||
DB_HOST="${POSTGRES_HOST:-localhost}"
|
||||
DB_PORT="${POSTGRES_PORT:-5432}"
|
||||
DSN="${GOCHAT_DATABASE_DSN:-}"
|
||||
RETENTION_DAYS="${GOCHAT_BACKUP_RETENTION_DAYS:-30}"
|
||||
|
||||
if [[ -z "$DSN" ]]; then
|
||||
echo "[$(date)] ERROR: GOCHAT_DATABASE_DSN is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse DSN: postgres://user:password@host:port/dbname?sslmode=...
|
||||
DB_USER=$(echo "$DSN" | sed 's|.*://||; s|:.*||')
|
||||
DB_PASS=$(echo "$DSN" | sed 's|.*://[^:]*:||; s|@.*||')
|
||||
DB_HOST=$(echo "$DSN" | sed 's|.*@||; s|:.*||')
|
||||
DB_PORT=$(echo "$DSN" | sed 's|.*@.*:||; s|/.*||')
|
||||
DB_NAME=$(echo "$DSN" | sed 's|.*/||; s|\?.*||')
|
||||
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
|
||||
BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"
|
||||
@@ -21,7 +30,14 @@ BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"
|
||||
echo "[$(date)] Starting backup of ${DB_NAME} on ${DB_HOST}:${DB_PORT}"
|
||||
|
||||
# pg_dump with compression — mirrors Chatwoot backup approach
|
||||
PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump -h "${DB_HOST}" -p "${DB_PORT}" -U "${DB_USER}" -d "${DB_NAME}" --format=custom --compress=9 | gzip > "${BACKUP_FILE}"
|
||||
PGPASSWORD="${DB_PASS}" pg_dump \
|
||||
-h "${DB_HOST}" \
|
||||
-p "${DB_PORT}" \
|
||||
-U "${DB_USER}" \
|
||||
-d "${DB_NAME}" \
|
||||
--format=custom \
|
||||
--compress=9 \
|
||||
| gzip > "${BACKUP_FILE}"
|
||||
|
||||
BACKUP_SIZE=$(du -h "${BACKUP_FILE}" | cut -f1)
|
||||
echo "[$(date)] Backup complete: ${BACKUP_FILE} (${BACKUP_SIZE})"
|
||||
|
||||
@@ -6,17 +6,16 @@
|
||||
set -e
|
||||
|
||||
MIGRATIONS_DIR="./migrations"
|
||||
DB_HOST="${POSTGRES_HOST:-localhost}"
|
||||
DB_PORT="${POSTGRES_PORT:-5432}"
|
||||
DB_NAME="${POSTGRES_DATABASE:-gochat_dev}"
|
||||
DB_USER="${POSTGRES_USERNAME:-postgres}"
|
||||
DB_PASS="${POSTGRES_PASSWORD:-}"
|
||||
|
||||
DSN="postgres://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable"
|
||||
DSN="${GOCHAT_DATABASE_DSN:-postgres://postgres:postgres@localhost:5432/gochat_dev?sslmode=disable}"
|
||||
|
||||
case "$1" in
|
||||
create)
|
||||
echo "Creating database ${DB_NAME}..."
|
||||
echo "Creating database from DSN..."
|
||||
# Extract dbname from DSN for createdb
|
||||
DB_NAME=$(echo "$DSN" | sed 's|.*/||; s|\?.*||')
|
||||
DB_HOST=$(echo "$DSN" | sed 's|.*@||; s|:.*||')
|
||||
DB_PORT=$(echo "$DSN" | sed 's|.*@.*:||; s|/.*||')
|
||||
DB_USER=$(echo "$DSN" | sed 's|.*://||; s|:.*||')
|
||||
createdb -h "${DB_HOST}" -p "${DB_PORT}" -U "${DB_USER}" "${DB_NAME}" 2>/dev/null || echo "Database already exists"
|
||||
;;
|
||||
up)
|
||||
|
||||
@@ -43,8 +43,13 @@ if [[ "${1:-}" == "--clean" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
log_warn "Cleaning all data before seeding..."
|
||||
# Truncate tables in safe order (preserve migrations/schema)
|
||||
psql_cmd="psql -h ${POSTGRES_HOST:-localhost} -p ${POSTGRES_PORT:-5432} -U ${POSTGRES_USER:-gochat} -d ${POSTGRES_DB:-gochat_dev}"
|
||||
# Parse DSN for psql connection
|
||||
DB_HOST=$(echo "$GOCHAT_DATABASE_DSN" | sed 's|.*@||; s|:.*||')
|
||||
DB_PORT=$(echo "$GOCHAT_DATABASE_DSN" | sed 's|.*@.*:||; s|/.*||')
|
||||
DB_USER=$(echo "$GOCHAT_DATABASE_DSN" | sed 's|.*://||; s|:.*||')
|
||||
DB_PASS=$(echo "$GOCHAT_DATABASE_DSN" | sed 's|.*://[^:]*:||; s|@.*||')
|
||||
DB_NAME=$(echo "$GOCHAT_DATABASE_DSN" | sed 's|.*/||; s|\?.*||')
|
||||
psql_cmd="PGPASSWORD=${DB_PASS} psql -h ${DB_HOST} -p ${DB_PORT} -U ${DB_USER} -d ${DB_NAME}"
|
||||
$psql_cmd -c "
|
||||
TRUNCATE TABLE
|
||||
messages,
|
||||
|
||||
Reference in New Issue
Block a user