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:
Rogee
2026-07-29 20:58:10 +08:00
parent 851ca7e372
commit 92f0d51375
46 changed files with 185 additions and 1464 deletions
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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"
+2 -12
View File
@@ -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:
+1 -1
View File
@@ -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 {
+2 -3
View File
@@ -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 -39
View File
@@ -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
}
+4 -11
View File
@@ -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)
+14 -48
View File
@@ -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)
+56 -69
View File
@@ -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) {
+15 -22
View File
@@ -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},
+17 -13
View File
@@ -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
+21 -5
View File
@@ -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})"
+7 -8
View File
@@ -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)
+7 -2
View File
@@ -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,