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
+2 -7
View File
@@ -12,18 +12,13 @@ GOCHAT_SERVER_PORT=3000
GOCHAT_SERVER_MODE=release # debug, release, test
# ---- Database ----
GOCHAT_DATABASE_HOST=localhost
GOCHAT_DATABASE_PORT=5432
GOCHAT_DATABASE_USER=gochat
GOCHAT_DATABASE_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD
GOCHAT_DATABASE_DBNAME=gochat_production
GOCHAT_DATABASE_SSLMODE=disable
GOCHAT_DATABASE_DSN=postgres://gochat:CHANGE_ME_TO_A_STRONG_PASSWORD@localhost:5432/gochat_production?sslmode=disable
GOCHAT_DATABASE_MAX_IDLE_CONNS=10
GOCHAT_DATABASE_MAX_OPEN_CONNS=100
GOCHAT_DATABASE_CONN_MAX_LIFETIME=3600
# ---- Redis ----
GOCHAT_REDIS_URL=redis://localhost:6379
GOCHAT_REDIS_DSN=redis://localhost:6379
GOCHAT_REDIS_POOL_SIZE=50
# ---- JWT ----
+6 -108
View File
@@ -68,13 +68,8 @@ jobs:
env:
GOCHAT_ENV: test
GOCHAT_TEST_DB: ${{ matrix.db-mode }}
POSTGRES_HOST: localhost
POSTGRES_PORT: 5432
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gochat_test
REDIS_HOST: localhost
REDIS_PORT: 6379
GOCHAT_DATABASE_DSN: postgres://postgres:postgres@localhost:5432/gochat_test?sslmode=disable
GOCHAT_REDIS_DSN: redis://localhost:6379
GOPROXY: https://goproxy.cn,direct
run: go test -v -race -coverprofile=coverage.out -timeout 180s ./internal/... ./pkg/... ./cmd/...
@@ -84,13 +79,8 @@ jobs:
working-directory: backend
env:
GOCHAT_ENV: test
POSTGRES_HOST: localhost
POSTGRES_PORT: 5432
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gochat_test
REDIS_HOST: localhost
REDIS_PORT: 6379
GOCHAT_DATABASE_DSN: postgres://postgres:postgres@localhost:5432/gochat_test?sslmode=disable
GOCHAT_REDIS_DSN: redis://localhost:6379
run: go test -v -timeout 120s ./tests/e2e/...
# Benchmark (quick sanity check, not full bench)
@@ -99,11 +89,7 @@ jobs:
env:
GOCHAT_ENV: test
GOCHAT_TEST_DB: ${{ matrix.db-mode }}
POSTGRES_HOST: localhost
POSTGRES_PORT: 5432
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gochat_test
GOCHAT_DATABASE_DSN: postgres://postgres:postgres@localhost:5432/gochat_test?sslmode=disable
run: go test -bench=. -benchtime=1s -run=^$ -timeout 60s ./internal/service/... ./pkg/crypto/...
# Coverage report
@@ -210,92 +196,4 @@ jobs:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
format: 'table'
exit-code: '1'
severity: 'CRITICAL,HIGH'
# ---- Stage 4: Validate Helm Chart ----
helm-validate:
name: Validate Helm Chart
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Helm
uses: azure/setup-helm@v3
with:
version: 'v3.14.0'
- name: Set up kubeval
run: |
curl -sfL https://github.com/yannh/kubeconform/releases/download/v0.6.4/kubeconform-linux-amd64.tar.gz | tar -xz
chmod +x kubeconform && mv kubeconform /usr/local/bin/
- name: Lint Helm chart
run: helm lint deploy/helm/gochat/
- name: Template and validate Helm chart
run: |
helm template gochat deploy/helm/gochat/ \
--values deploy/helm/gochat/values.yaml \
--values deploy/helm/gochat/values-production.yaml \
| kubeconform -summary -kubernetes-version 1.29.0
# ---- Stage 5: Deploy to Staging ----
deploy-staging:
name: Deploy to Staging
needs: [build, helm-validate]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/develop'
environment: staging
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
uses: stefanprodan/helm-gh-action@v1.7.0
with:
context: ./deploy/helm/gochat
chart-ref: ./deploy/helm/gochat
cluster-config: staging-k8s-config
kube-config: ${{ secrets.KUBE_CONFIG_STAGING }}
namespace: gochat-staging
values: values.yaml
value-files: >-
values-staging.yaml
release-name: gochat-staging
atomic: true
# Post-deploy health check
- name: Health check
run: |
chmod +x backend/scripts/health_check.sh
GOCHAT_HOST=gochat-staging GOCHAT_PORT=3000 ./backend/scripts/health_check.sh --full --timeout 30
# ---- Stage 6: Deploy to Production ----
deploy-production:
name: Deploy to Production
needs: [build, helm-validate]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/heads/release/')
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy to production
uses: stefanprodan/helm-gh-action@v1.7.0
with:
context: ./deploy/helm/gochat
chart-ref: ./deploy/helm/gochat
cluster-config: production-k8s-config
kube-config: ${{ secrets.KUBE_CONFIG_PRODUCTION }}
namespace: gochat-production
values: values.yaml
value-files: >-
values-production.yaml
release-name: gochat-production
atomic: true
# Post-deploy health check
- name: Health check
run: |
chmod +x backend/scripts/health_check.sh
GOCHAT_HOST=gochat-production GOCHAT_PORT=3000 ./backend/scripts/health_check.sh --full --timeout 30
severity: 'CRITICAL,HIGH'
+2 -4
View File
@@ -185,13 +185,11 @@ PG-only功能(vector搜索等)使用 `skipIfSQLite` 自动跳过。
## CI/CD
GitHub Actions 5阶段流水线:
GitHub Actions 3阶段流水线:
1. **Test & Lint**: lint + vet + 单元/集成/e2e/benchmarkPG+SQLite矩阵)
2. **Security Scan**: gosec + govulncheck + Trivy
3. **Build**: Docker多平台镜像 + GHCR推送
4. **Helm Validate**: K8s Helm chart lint + kubeconform
5. **Deploy**: Stagingdevelop分支)+ Productionrelease分支)
详见 [.github/workflows/ci.yml](.github/workflows/ci.yml)
@@ -211,7 +209,7 @@ GitHub Actions 5阶段流水线:
| 限流 | golang.org/x/time/rate |
| LLM | OpenAI / Ollama / volcengine |
| 容器 | Docker + Buildx |
| K8s | Helm chart |
| 部署 | Docker Compose |
## 文档
+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,
+4 -14
View File
@@ -60,13 +60,8 @@ services:
env_file: ../../.env
environment:
- GOCHAT_ENV=development
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- POSTGRES_DATABASE=gochat_dev
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- REDIS_HOST=redis
- REDIS_PORT=6379
- GOCHAT_DATABASE_DSN=postgres://postgres:postgres@postgres:5432/gochat_dev?sslmode=disable
- GOCHAT_REDIS_DSN=redis://redis:6379
- SMTP_HOST=mailhog
- SMTP_PORT=1025
@@ -86,13 +81,8 @@ services:
env_file: ../../.env
environment:
- GOCHAT_ENV=development
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- POSTGRES_DATABASE=gochat_dev
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- REDIS_HOST=redis
- REDIS_PORT=6379
- GOCHAT_DATABASE_DSN=postgres://postgres:postgres@postgres:5432/gochat_dev?sslmode=disable
- GOCHAT_REDIS_DSN=redis://redis:6379
volumes:
postgres_data:
+4 -8
View File
@@ -60,10 +60,8 @@ services:
env_file: ../../.env
environment:
- GOCHAT_ENV=production
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- REDIS_HOST=redis
- REDIS_PORT=6379
- GOCHAT_DATABASE_DSN=postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gochat_production}?sslmode=disable
- GOCHAT_REDIS_DSN=redis://:${REDIS_PASSWORD}@redis:6379
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
@@ -92,10 +90,8 @@ services:
env_file: ../../.env
environment:
- GOCHAT_ENV=production
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- REDIS_HOST=redis
- REDIS_PORT=6379
- GOCHAT_DATABASE_DSN=postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gochat_production}?sslmode=disable
- GOCHAT_REDIS_DSN=redis://:${REDIS_PASSWORD}@redis:6379
deploy:
resources:
limits:
+2 -7
View File
@@ -40,12 +40,7 @@ services:
condition: service_healthy
environment:
- GOCHAT_ENV=test
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DATABASE=gochat_test
- REDIS_HOST=redis
- REDIS_PORT=6379
- GOCHAT_DATABASE_DSN=postgres://postgres:postgres@postgres:5432/gochat_test?sslmode=disable
- GOCHAT_REDIS_DSN=redis://redis:6379
- GOCHAT_JWT_SECRET=test_secret
command: ["test"]
+2 -6
View File
@@ -58,12 +58,8 @@ services:
env_file: ../../.env
environment:
- GOCHAT_ENV=development
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- POSTGRES_DATABASE=gochat_dev
- POSTGRES_USERNAME=postgres
- POSTGRES_PASSWORD=postgres
- REDIS_URL=redis://redis:6379
- GOCHAT_DATABASE_DSN=postgres://postgres:postgres@postgres:5432/gochat_dev?sslmode=disable
- GOCHAT_REDIS_DSN=redis://redis:6379
volumes:
- ../../backend:/app:delegated
-19
View File
@@ -1,19 +0,0 @@
apiVersion: v2
name: gochat
description: GoChat — Open-source customer engagement platform (Go port of Chatwoot)
type: application
version: 0.1.0
appVersion: "1.0.0"
home: https://github.com/gochat/gochat
icon: https://gochat.io/logo.png
maintainers:
- name: gochat-team
email: team@gochat.io
sources:
- https://github.com/gochat/gochat
keywords:
- chat
- customer-engagement
- live-chat
- omnichannel
- chatwoot
-49
View File
@@ -1,49 +0,0 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "gochat.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "gochat.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "gochat.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "gochat.labels" -}}
helm.sh/chart: {{ include "gochat.chart" . }}
{{ include "gochat.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "gochat.selectorLabels" -}}
app.kubernetes.io/name: {{ include "gochat.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
@@ -1,15 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "gochat.fullname" . }}-config
labels:
{{- include "gochat.labels" . | nindent 4 }}
data:
{{- range $key, $value := .Values.configMap.data }}
{{ $key }}: {{ $value | quote }}
{{- end }}
POSTGRES_HOST: {{ if .Values.postgresql.enabled }}{{ include "gochat.fullname" . }}-postgresql{{ else }}{{ .Values.configMap.data.POSTGRES_HOST | default "localhost" }}{{ end }}
POSTGRES_PORT: "5432"
POSTGRES_DATABASE: {{ .Values.postgresql.auth.database | quote }}
REDIS_HOST: {{ if .Values.redis.enabled }}{{ include "gochat.fullname" . }}-redis-master{{ else }}{{ .Values.configMap.data.REDIS_HOST | default "localhost" }}{{ end }}
REDIS_PORT: "6379"
@@ -1,82 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "gochat.fullname" . }}
labels:
{{- include "gochat.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.app.replicaCount }}
selector:
matchLabels:
{{- include "gochat.selectorLabels" . | nindent 6 }}
strategy:
{{- toYaml .Values.app.strategy | nindent 4 }}
template:
metadata:
annotations:
{{- toYaml .Values.app.podAnnotations | nindent 8 }}
labels:
{{- include "gochat.selectorLabels" . | nindent 8 }}
spec:
terminationGracePeriodSeconds: {{ .Values.app.terminationGracePeriodSeconds }}
containers:
- name: gochat
image: "{{ .Values.app.image.repository }}:{{ .Values.app.image.tag }}"
imagePullPolicy: {{ .Values.app.image.pullPolicy }}
ports:
- name: http
containerPort: 3000
protocol: TCP
{{- if .Values.metrics.enabled }}
- name: metrics
containerPort: {{ .Values.metrics.service.port }}
protocol: TCP
{{- end }}
envFrom:
- configMapRef:
name: {{ include "gochat.fullname" . }}-config
- secretRef:
name: {{ include "gochat.fullname" . }}-secret
{{- if .Values.tracing.enabled }}
- configMapRef:
name: {{ include "gochat.fullname" . }}-otel-config
{{- end }}
{{- range $key, $value := .Values.app.extraEnv }}
env:
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep {{ .Values.app.preStopDelaySeconds }}"]
livenessProbe:
{{- toYaml .Values.app.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.app.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.app.resources | nindent 12 }}
volumeMounts:
- name: configs
mountPath: /app/configs
- name: migrations
mountPath: /app/migrations
volumes:
- name: configs
configMap:
name: {{ include "gochat.fullname" . }}-configs
- name: migrations
configMap:
name: {{ include "gochat.fullname" . }}-migrations
{{- with .Values.app.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.app.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.app.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -1,27 +0,0 @@
{{- if and .Values.sealedSecrets.enabled .Values.sealedSecrets.externalSecret.enabled }}
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: {{ include "gochat.fullname" . }}-external-secret
labels:
{{- include "gochat.labels" . | nindent 4 }}
spec:
refreshInterval: {{ .Values.sealedSecrets.externalSecret.refreshInterval }}
secretStoreRef:
name: {{ .Values.sealedSecrets.externalSecret.secretStoreRef.name }}
kind: {{ .Values.sealedSecrets.externalSecret.secretStoreRef.kind }}
target:
name: {{ include "gochat.fullname" . }}-secret
template:
type: Opaque
data:
{{- range $key, $remoteKey := .Values.sealedSecrets.externalSecret.mapping }}
{{ $key }}: "{{ `{{ .` }}{{ $remoteKey }}{{ ` }}` }}"
{{- end }}
data:
{{- range $key, $remoteKey := .Values.sealedSecrets.externalSecret.mapping }}
- secretKey: {{ $key }}
remoteRef:
key: {{ $remoteKey }}
{{- end }}
{{- end }}
-49
View File
@@ -1,49 +0,0 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "gochat.fullname" . }}
labels:
{{- include "gochat.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "gochat.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
behavior:
scaleDown:
stabilizationWindowSeconds: {{ .Values.autoscaling.behavior.scaleDown.stabilizationWindowSeconds }}
policies:
- type: Percent
value: {{ .Values.autoscaling.behavior.scaleDown.percent }}
periodSeconds: {{ .Values.autoscaling.behavior.scaleDown.periodSeconds }}
scaleUp:
stabilizationWindowSeconds: {{ .Values.autoscaling.behavior.scaleUp.stabilizationWindowSeconds }}
policies:
- type: Percent
value: {{ .Values.autoscaling.behavior.scaleUp.percent }}
periodSeconds: {{ .Values.autoscaling.behavior.scaleUp.periodSeconds }}
- type: Pods
value: {{ .Values.autoscaling.behavior.scaleUp.pods }}
periodSeconds: {{ .Values.autoscaling.behavior.scaleUp.podsPeriodSeconds }}
selectPolicy: Max
{{- end }}
-37
View File
@@ -1,37 +0,0 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "gochat.fullname" . }}
labels:
{{- include "gochat.labels" . | nindent 4 }}
annotations:
{{- toYaml .Values.ingress.annotations | nindent 4 }}
spec:
ingressClassName: {{ .Values.ingress.className }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "gochat.fullname" $ }}
port:
number: {{ $.Values.app.service.port }}
{{- end }}
{{- end }}
{{- end }}
-83
View File
@@ -1,83 +0,0 @@
{{- if .Values.tracing.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "gochat.fullname" . }}-jaeger
labels:
{{- include "gochat.labels" . | nindent 4 }}
app.kubernetes.io/component: jaeger
spec:
replicas: 1
selector:
matchLabels:
{{- include "gochat.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: jaeger
template:
metadata:
labels:
{{- include "gochat.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: jaeger
spec:
containers:
- name: jaeger
image: "{{ .Values.tracing.jaeger.image.repository }}:{{ .Values.tracing.jaeger.image.tag }}"
imagePullPolicy: {{ .Values.tracing.jaeger.image.pullPolicy }}
ports:
- name: otlp-grpc
containerPort: 4317
protocol: TCP
- name: otlp-http
containerPort: 4318
protocol: TCP
- name: jaeger-query
containerPort: 16686
protocol: TCP
- name: jaeger-admin
containerPort: 14269
protocol: TCP
env:
- name: COLLECTOR_OTLP_ENABLED
value: "true"
- name: LOG_LEVEL
value: {{ .Values.tracing.jaeger.logLevel | quote }}
resources:
{{- toYaml .Values.tracing.jaeger.resources | nindent 12 }}
livenessProbe:
httpGet:
path: /
port: 14269
initialDelaySeconds: 5
periodSeconds: 15
readinessProbe:
httpGet:
path: /
port: 14269
initialDelaySeconds: 5
periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "gochat.fullname" . }}-jaeger
labels:
{{- include "gochat.labels" . | nindent 4 }}
app.kubernetes.io/component: jaeger
spec:
type: ClusterIP
ports:
- name: otlp-grpc
port: 4317
targetPort: otlp-grpc
protocol: TCP
- name: otlp-http
port: 4318
targetPort: otlp-http
protocol: TCP
- name: query
port: 16686
targetPort: jaeger-query
protocol: TCP
selector:
{{- include "gochat.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: jaeger
{{- end }}
@@ -1,17 +0,0 @@
{{- if .Values.metrics.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "gochat.fullname" . }}-metrics
labels:
{{- include "gochat.labels" . | nindent 4 }}
spec:
type: {{ .Values.metrics.service.type }}
ports:
- port: {{ .Values.metrics.service.port }}
targetPort: metrics
protocol: TCP
name: metrics
selector:
{{- include "gochat.selectorLabels" . | nindent 4 }}
{{- end }}
@@ -1,97 +0,0 @@
{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "gochat.fullname" . }}-allow-ingress
labels:
{{- include "gochat.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
{{- include "gochat.selectorLabels" . | nindent 6 }}
policyTypes:
- Ingress
- Egress
ingress:
# Allow traffic from Ingress controller (nginx)
- from:
- namespaceSelector:
matchLabels:
{{- toYaml .Values.networkPolicy.ingressNamespaceLabels | nindent 12 }}
ports:
- protocol: TCP
port: {{ .Values.app.service.port }}
# Allow Prometheus scraping for metrics
- from:
- namespaceSelector:
matchLabels:
{{- toYaml .Values.networkPolicy.monitoringNamespaceLabels | nindent 12 }}
ports:
- protocol: TCP
port: {{ .Values.metrics.service.port }}
# Allow internal pod-to-pod communication (app ↔ worker)
- from:
- podSelector:
matchLabels:
{{- include "gochat.selectorLabels" . | nindent 12 }}
ports:
- protocol: TCP
port: {{ .Values.app.service.port }}
---
# Egress policy: allow DNS, PostgreSQL, Redis, and outbound HTTPS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "gochat.fullname" . }}-allow-egress
labels:
{{- include "gochat.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
{{- include "gochat.selectorLabels" . | nindent 6 }}
policyTypes:
- Egress
egress:
# Allow DNS resolution (kube-dns)
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
- podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Allow PostgreSQL connection
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: postgresql
ports:
- protocol: TCP
port: 5432
# Allow Redis connection
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: redis
ports:
- protocol: TCP
port: 6379
# Allow outbound HTTPS (LLM APIs, webhook callbacks, SMTP)
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- protocol: TCP
port: 443
- protocol: TCP
port: 587
{{- end }}
@@ -1,21 +0,0 @@
{{- if .Values.tracing.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "gochat.fullname" . }}-otel-config
labels:
{{- include "gochat.labels" . | nindent 4 }}
data:
OTEL_SERVICE_NAME: "{{ include "gochat.fullname" . }}"
OTEL_EXPORTER_OTLP_ENDPOINT: "{{ .Values.tracing.otlp.endpoint }}"
OTEL_EXPORTER_OTLP_PROTOCOL: "{{ .Values.tracing.otlp.protocol }}"
OTEL_TRACES_SAMPLER: "{{ .Values.tracing.sampler.type }}"
OTEL_TRACES_SAMPLER_ARG: "{{ .Values.tracing.sampler.arg }}"
OTEL_PROPAGATORS: "{{ .Values.tracing.propagators }}"
OTEL_RESOURCE_ATTRIBUTES: "service.name={{ include "gochat.fullname" . }},service.version={{ .Values.app.image.tag }},deployment.environment={{ .Values.global.environment }}"
OTEL_LOG_LEVEL: "{{ .Values.tracing.logLevel }}"
OTEL_EXPORTER_OTLP_TIMEOUT: "{{ .Values.tracing.otlp.timeout }}"
OTEL_BSP_SCHEDULE_DELAY: "5000"
OTEL_BSP_MAX_QUEUE_SIZE: "2048"
OTEL_BSP_MAX_EXPORT_BATCH_SIZE: "512"
{{- end }}
-18
View File
@@ -1,18 +0,0 @@
{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "gochat.fullname" . }}
labels:
{{- include "gochat.labels" . | nindent 4 }}
spec:
{{- if .Values.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
{{- end }}
{{- if .Values.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "gochat.selectorLabels" . | nindent 6 }}
{{- end }}
@@ -1,23 +0,0 @@
{{- if .Values.sealedSecrets.enabled }}
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: {{ include "gochat.fullname" . }}-sealed-secret
labels:
{{- include "gochat.labels" . | nindent 4 }}
annotations:
# Sealed Secrets are encrypted with the cluster's public key
# Use kubeseal to encrypt: kubeseal --format yaml < secret.yaml > sealed-secret.yaml
sealedsecrets.bitnami.com/cluster-wide: "true"
spec:
encryptedData:
{{- range $key, $value := .Values.sealedSecrets.encryptedData }}
{{ $key }}: {{ $value }}
{{- end }}
template:
metadata:
name: {{ include "gochat.fullname" . }}-secret
labels:
{{- include "gochat.labels" . | nindent 8 }}
type: Opaque
{{- end }}
-11
View File
@@ -1,11 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ include "gochat.fullname" . }}-secret
labels:
{{- include "gochat.labels" . | nindent 4 }}
type: Opaque
data:
{{- range $key, $value := .Values.secrets.data }}
{{ $key }}: {{ $value | b64enc }}
{{- end }}
-15
View File
@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "gochat.fullname" . }}
labels:
{{- include "gochat.labels" . | nindent 4 }}
spec:
type: {{ .Values.app.service.type }}
ports:
- port: {{ .Values.app.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "gochat.selectorLabels" . | nindent 4 }}
@@ -1,16 +0,0 @@
{{- if and .Values.metrics.enabled .Values.metrics.serviceMonitor.enabled }}
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "gochat.fullname" . }}-metrics
labels:
{{- include "gochat.labels" . | nindent 4 }}
spec:
selector:
matchLabels:
{{- include "gochat.selectorLabels" . | nindent 6 }}
endpoints:
- port: metrics
interval: {{ .Values.metrics.serviceMonitor.interval }}
path: {{ .Values.metrics.serviceMonitor.path }}
{{- end }}
@@ -1,35 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "gochat.fullname" . }}-worker
labels:
{{- include "gochat.labels" . | nindent 4 }}
app.kubernetes.io/component: worker
spec:
replicas: {{ .Values.worker.replicaCount }}
selector:
matchLabels:
{{- include "gochat.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: worker
template:
metadata:
labels:
{{- include "gochat.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: worker
spec:
containers:
- name: worker
image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag }}"
imagePullPolicy: {{ .Values.worker.image.pullPolicy }}
command: {{- toYaml .Values.worker.command | nindent 12 }}
envFrom:
- configMapRef:
name: {{ include "gochat.fullname" . }}-config
- secretRef:
name: {{ include "gochat.fullname" . }}-secret
resources:
{{- toYaml .Values.worker.resources | nindent 12 }}
{{- with .Values.app.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
-89
View File
@@ -1,89 +0,0 @@
# GoChat Production Values Override
global:
environment: production
app:
replicaCount: 3
resources:
limits:
cpu: 2000m
memory: 1Gi
requests:
cpu: 500m
memory: 512Mi
worker:
replicaCount: 3
resources:
limits:
cpu: 2000m
memory: 1Gi
postgresql:
primary:
persistence:
size: 50Gi
resources:
limits:
cpu: 2000m
memory: 2Gi
redis:
master:
persistence:
size: 10Gi
configuration: |
maxmemory 512mb
maxmemory-policy allkeys-lru
appendonly yes
appendfsync everysec
replica:
replicaCount: 2
ingress:
hosts:
- host: gochat.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: gochat-tls
hosts:
- gochat.example.com
# ---- Autoscaling (HPA) — enabled in production ----
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 15
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
# ---- NetworkPolicy — enabled in production ----
networkPolicy:
enabled: true
# ---- PodDisruptionBudget — enabled in production ----
podDisruptionBudget:
enabled: true
minAvailable: 1
# ---- Sealed Secrets — use in production ----
sealedSecrets:
enabled: true
encryptedData: {}
# Generate encrypted data with: kubeseal --format yaml < secret.yaml
# ---- Distributed Tracing — enabled in production ----
tracing:
enabled: true
sampler:
type: parentbased_traceidratio
arg: "0.1" # 10% sampling in production
configMap:
data:
GOCHAT_ENV: "production"
GOCHAT_SERVER_MODE: "release"
GOCHAT_LOG_LEVEL: "info"
GOCHAT_WORKER_CONCURRENCY: "10"
-54
View File
@@ -1,54 +0,0 @@
# GoChat Staging Values Override
global:
environment: staging
app:
replicaCount: 1
image:
tag: "develop"
resources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 250m
memory: 128Mi
worker:
replicaCount: 1
resources:
limits:
cpu: 500m
memory: 256Mi
postgresql:
primary:
persistence:
size: 5Gi
redis:
master:
persistence:
size: 2Gi
configuration: |
maxmemory 128mb
maxmemory-policy allkeys-lru
appendonly yes
ingress:
hosts:
- host: gochat-staging.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: gochat-staging-tls
hosts:
- gochat-staging.example.com
configMap:
data:
GOCHAT_ENV: "staging"
GOCHAT_SERVER_MODE: "debug"
GOCHAT_LOG_LEVEL: "debug"
FRONTEND_URL: "https://gochat-staging.example.com"
-278
View File
@@ -1,278 +0,0 @@
# GoChat Helm Chart Values
# Reference: Chatwoot Helm chart pattern — app + worker + postgres + redis
# Adjust values per environment (dev/staging/prod)
# ---- Global ----
global:
environment: production
# ---- Application ----
app:
replicaCount: 2
image:
repository: gochat/gochat
tag: "1.0.0"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 3000
resources:
limits:
cpu: 1000m
memory: 512Mi
requests:
cpu: 500m
memory: 256Mi
# Health probes — references the health endpoints we created
livenessProbe:
httpGet:
path: /live
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# Rolling update strategy
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
# Graceful shutdown configuration
terminationGracePeriodSeconds: 30
preStopDelaySeconds: 5 # Delay before SIGTERM to allow load balancer deregistration
# Environment variables from ConfigMap + Secrets
envFrom:
configMapRef: gochat-config
secretRef: gochat-secret
# Additional env vars
extraEnv: {}
# Pod annotations for monitoring
podAnnotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9090"
prometheus.io/path: "/metrics"
# Affinity for multi-AZ deployment
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- gochat
topologyKey: kubernetes.io/hostname
# Node selector
nodeSelector: {}
# Tolerations
tolerations: []
# ---- Worker ----
worker:
replicaCount: 2
image:
repository: gochat/gochat
tag: "1.0.0"
pullPolicy: IfNotPresent
command: ["serve", "--worker-only"]
resources:
limits:
cpu: 1000m
memory: 512Mi
requests:
cpu: 250m
memory: 256Mi
envFrom:
configMapRef: gochat-config
secretRef: gochat-secret
# ---- Metrics sidecar ----
metrics:
enabled: true
service:
type: ClusterIP
port: 9090
serviceMonitor:
enabled: true
interval: 15s
path: /metrics
# ---- PostgreSQL ----
postgresql:
enabled: true # Set false to use external PostgreSQL
image:
repository: pgvector/pgvector
tag: pg16
auth:
database: gochat_production
username: gochat
password: "" # Set via --set or secrets
existingSecret: gochat-postgres-secret
primary:
persistence:
enabled: true
size: 10Gi
storageClass: ""
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 500m
memory: 512Mi
service:
port: 5432
# ---- Redis ----
redis:
enabled: true # Set false to use external Redis
auth:
password: "" # Set via --set or secrets
existingSecret: gochat-redis-secret
master:
persistence:
enabled: true
size: 5Gi
storageClass: ""
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 250m
memory: 256Mi
configuration: |
maxmemory 512mb
maxmemory-policy allkeys-lru
appendonly yes
appendfsync everysec
replica:
replicaCount: 1
persistence:
enabled: true
size: 5Gi
# ---- Ingress ----
ingress:
enabled: true
className: "nginx"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
hosts:
- host: gochat.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: gochat-tls
hosts:
- gochat.example.com
# ---- ConfigMap data ----
configMap:
data:
GOCHAT_ENV: "production"
GOCHAT_SERVER_HOST: "0.0.0.0"
GOCHAT_SERVER_PORT: "3000"
GOCHAT_SERVER_MODE: "release"
GOCHAT_LOG_LEVEL: "info"
GOCHAT_LOG_FORMAT: "json"
GOCHAT_METRICS_ENABLED: "true"
GOCHAT_METRICS_PORT: "9090"
GOCHAT_WORKER_CONCURRENCY: "10"
GOCHAT_FEATURE_CAPTAIN_AI: "false"
GOCHAT_FEATURE_CSAT: "true"
FRONTEND_URL: "https://gochat.example.com"
# ---- Autoscaling (HPA) ----
autoscaling:
enabled: false # Enable for production
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300
percent: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 60
percent: 50
periodSeconds: 60
pods: 2
podsPeriodSeconds: 60
# ---- NetworkPolicy ----
networkPolicy:
enabled: false # Enable for production
ingressNamespaceLabels:
kubernetes.io/metadata.name: ingress-nginx
monitoringNamespaceLabels:
kubernetes.io/metadata.name: monitoring
# ---- PodDisruptionBudget ----
podDisruptionBudget:
enabled: false # Enable for production (requires >= 2 replicas)
minAvailable: 1 # Keep at least 1 pod available during disruptions
# maxUnavailable: 1 # Alternative: allow max 1 pod unavailable
# ---- Secrets (placeholder — use --set or sealed-secrets) ----
secrets:
data: {}
# POSTGRES_PASSWORD, REDIS_PASSWORD, JWT_SECRET, SMTP_PASSWORD, etc.
# MUST be set via --set or external secret management
# ---- Sealed Secrets / External Secret Management ----
sealedSecrets:
enabled: false # Enable for production
encryptedData: {}
externalSecret:
enabled: false # Enable for production with External Secrets Operator
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
mapping: {}
# POSTGRES_PASSWORD: postgres-password
# REDIS_PASSWORD: redis-password
# JWT_SECRET: jwt-secret
# ---- Distributed Tracing (OpenTelemetry + Jaeger) ----
tracing:
enabled: false # Enable for production/staging
sampler:
type: parentbased_traceidratio # Sampling strategy: always_on, always_off, parentbased_traceidratio
arg: "0.1" # Sample 10% of traces in production (adjust per environment)
propagators: "tracecontext,baggage" # W3C Trace Context propagation
logLevel: info
otlp:
endpoint: "gochat-jaeger:4317" # OTLP gRPC endpoint (in-cluster Jaeger)
protocol: grpc
timeout: "10s"
jaeger:
image:
repository: jaegertracing/all-in-one
tag: "1.55"
pullPolicy: IfNotPresent
logLevel: info
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
+2 -9
View File
@@ -80,17 +80,10 @@ services:
GOCHAT_SERVER_HOST: 0.0.0.0
GOCHAT_SERVER_PORT: 3000
GOCHAT_SERVER_MODE: ${GOCHAT_SERVER_MODE:-debug}
GOCHAT_DATABASE_HOST: postgres
GOCHAT_DATABASE_PORT: 5432
GOCHAT_DATABASE_USER: ${POSTGRES_USER:-gochat}
GOCHAT_DATABASE_PASSWORD: ${POSTGRES_PASSWORD:-gochat_dev}
GOCHAT_DATABASE_DBNAME: ${POSTGRES_DB:-gochat_dev}
GOCHAT_DATABASE_SSLMODE: disable
GOCHAT_DATABASE_DSN: postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD:-gochat_dev}@postgres:5432/${POSTGRES_DB:-gochat_dev}?sslmode=disable
GOCHAT_DATABASE_RUN_MIGRATIONS: "true"
GOCHAT_DATABASE_MIGRATIONS_PATH: /app/migrations
GOCHAT_REDIS_HOST: redis
GOCHAT_REDIS_PORT: 6379
GOCHAT_REDIS_PASSWORD: ${REDIS_PASSWORD:-}
GOCHAT_REDIS_DSN: ${REDIS_DSN:-redis://redis:6379}
GOCHAT_REDIS_POOL_SIZE: 50
GOCHAT_SEARCH_ENGINE: meilisearch
GOCHAT_SEARCH_HOST: http://meilisearch:7700
+3 -19
View File
@@ -39,33 +39,17 @@ curl -f http://gochat-green:3000/health
docker compose -f docker-compose.prod.yml stop gochat
```
### Step 4: K8s Rolling Update
```bash
# Kubernetes handles rolling updates automatically via Deployment strategy
kubectl set image deployment/gochat gochat=gochat/gochat:${NEW_VERSION}
kubectl rollout status deployment/gochat
```
K8s config (in Helm chart):
- maxSurge: 1 (one extra pod during rollout)
- maxUnavailable: 0 (no pods allowed to be unavailable)
- readinessProbe: /ready endpoint
- livenessProbe: /live endpoint
### Step 5: Verification
1. Smoke test: hit /health, /ready endpoints
### Step 4: Verification
1. Smoke test: hit /health endpoint
2. Check logs for errors: `docker compose logs gochat --since 5m`
3. Verify metrics: Prometheus dashboard should show normal traffic
4. Monitor for 15 minutes before finalizing
### Step 6: Rollback (if needed)
### Step 5: Rollback (if needed)
```bash
# Docker Compose rollback
docker compose -f docker-compose.prod.yml exec gochat /app/gochat migrate down ${N}
docker compose -f docker-compose.prod.yml up -d --no-deps gochat-${OLD_VERSION}
# K8s rollback
kubectl rollout undo deployment/gochat
```
## Migration Compatibility Rules
+2 -2
View File
@@ -352,7 +352,7 @@ GoChat 是一个开源的企业级多渠道客服平台,旨在为企业提供
| Prometheus 指标导出 | ❌ | 2天 |
| Grafana Dashboard 模板 | ❌ | 1天 |
| OpenTelemetry 分布式追踪 | ❌ | 3天 |
| Kubernetes Helm Chart | ❌ | 3天 |
| Kubernetes Helm Chart | ❌ | 已移除 |
| JSON 结构化日志 | ⚠️ zap有 | 1天 |
| 健康检查增强 | ⚠️ | 1天 |
| SLA 管理 | ❌ stub | 3天 |
@@ -427,7 +427,7 @@ GoChat 是一个开源的企业级多渠道客服平台,旨在为企业提供
**验收标准**
1. Prometheus/Grafana 监控面板可用
2. OpenTelemetry 追踪可用
3. Kubernetes Helm Chart 可一键部署
3. Docker Compose 一键部署可用
4. SLA 管理/审计日志可用
5. Slack/API Channel 渠道可用
6. CI/CD 完整流水线运行
+4 -9
View File
@@ -311,8 +311,9 @@ gochat/
│ + 对应 down.sql 回滚文件
├── deploy/ # 部署配置
│ ├── k8s/ # Kubernetes manifests
│ ├── docker/ # Docker 相关
│ ├── docker/ # Dockerfile + docker-compose
│ ├── quickstart/ # 一键启动 Compose 栈
│ ├── fluentd/ # 日志收集
├── tests/ # 测试目录
│ ├── e2e/ # 端到端测试
@@ -1162,13 +1163,7 @@ docker-compose.prod.yml → 生产 (健康检查 + 资源限制)
docker-compose.test.yml → 测试 (独立PG + 覆盖率)
```
### 12.2 Kubernetes 部署
```
deploy/k8s/ → Deployment + Service + ConfigMap + Secret manifests
```
### 12.3 环境配置优先级
### 12.2 环境配置优先级
```
.env (最高) → configs/production.yaml → configs/default.yaml → 程序默认值
+1 -1
View File
@@ -473,7 +473,7 @@ gochat/
│ ├── docker/
│ │ ├── Dockerfile
│ │ └── docker-compose.yaml
│ └── k8s/ # Kubernetes配置(可选)
│ └── quickstart/ # 一键启动 Compose 栈
├── go.mod # Go模块定义
├── go.sum # Go依赖锁定