From 92f0d51375b803d29661e8ea9462fe59db83f14f Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 29 Jul 2026 20:58:10 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=20DB/Redis=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=BA=20DSN=20=E6=A8=A1=E5=BC=8F=20+=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=20Helm/K8s=20=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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, 滚动升级) --- .env.example | 9 +- .github/workflows/ci.yml | 114 +------ README.md | 6 +- backend/configs/config.development.yaml | 2 +- backend/configs/config.prod.yaml | 2 +- backend/configs/config.yaml | 14 +- backend/internal/app/app.go | 2 +- backend/internal/app/database.go | 5 +- backend/internal/app/redis.go | 42 +-- backend/internal/app/redis_test.go | 15 +- backend/internal/config/config.go | 62 +--- backend/internal/config/config_test.go | 125 ++++---- backend/internal/config/reloader_test.go | 37 +-- backend/internal/config/validator.go | 30 +- backend/scripts/db_backup.sh | 26 +- backend/scripts/migrate.sh | 15 +- backend/scripts/seed.sh | 9 +- deploy/docker/docker-compose.dev.yml | 18 +- deploy/docker/docker-compose.prod.yml | 12 +- deploy/docker/docker-compose.test.yml | 9 +- deploy/docker/docker-compose.yml | 8 +- deploy/helm/gochat/Chart.yaml | 19 -- deploy/helm/gochat/templates/_helpers.tpl | 49 --- deploy/helm/gochat/templates/configmap.yaml | 15 - deploy/helm/gochat/templates/deployment.yaml | 82 ------ .../helm/gochat/templates/externalsecret.yaml | 27 -- deploy/helm/gochat/templates/hpa.yaml | 49 --- deploy/helm/gochat/templates/ingress.yaml | 37 --- deploy/helm/gochat/templates/jaeger.yaml | 83 ------ .../gochat/templates/metrics-service.yaml | 17 -- .../helm/gochat/templates/networkpolicy.yaml | 97 ------ .../helm/gochat/templates/otel-configmap.yaml | 21 -- deploy/helm/gochat/templates/pdb.yaml | 18 -- .../helm/gochat/templates/sealedsecret.yaml | 23 -- deploy/helm/gochat/templates/secrets.yaml | 11 - deploy/helm/gochat/templates/service.yaml | 15 - .../helm/gochat/templates/servicemonitor.yaml | 16 - .../gochat/templates/worker-deployment.yaml | 35 --- deploy/helm/gochat/values-production.yaml | 89 ------ deploy/helm/gochat/values-staging.yaml | 54 ---- deploy/helm/gochat/values.yaml | 278 ------------------ deploy/quickstart/compose.yaml | 11 +- docs/ops/01-rolling-upgrade.md | 22 +- docs/product/01-product-requirements.md | 4 +- docs/product/02-architecture.md | 13 +- docs/product/03-design-project-structure.md | 2 +- 46 files changed, 185 insertions(+), 1464 deletions(-) delete mode 100644 deploy/helm/gochat/Chart.yaml delete mode 100644 deploy/helm/gochat/templates/_helpers.tpl delete mode 100644 deploy/helm/gochat/templates/configmap.yaml delete mode 100644 deploy/helm/gochat/templates/deployment.yaml delete mode 100644 deploy/helm/gochat/templates/externalsecret.yaml delete mode 100644 deploy/helm/gochat/templates/hpa.yaml delete mode 100644 deploy/helm/gochat/templates/ingress.yaml delete mode 100644 deploy/helm/gochat/templates/jaeger.yaml delete mode 100644 deploy/helm/gochat/templates/metrics-service.yaml delete mode 100644 deploy/helm/gochat/templates/networkpolicy.yaml delete mode 100644 deploy/helm/gochat/templates/otel-configmap.yaml delete mode 100644 deploy/helm/gochat/templates/pdb.yaml delete mode 100644 deploy/helm/gochat/templates/sealedsecret.yaml delete mode 100644 deploy/helm/gochat/templates/secrets.yaml delete mode 100644 deploy/helm/gochat/templates/service.yaml delete mode 100644 deploy/helm/gochat/templates/servicemonitor.yaml delete mode 100644 deploy/helm/gochat/templates/worker-deployment.yaml delete mode 100644 deploy/helm/gochat/values-production.yaml delete mode 100644 deploy/helm/gochat/values-staging.yaml delete mode 100644 deploy/helm/gochat/values.yaml diff --git a/.env.example b/.env.example index 1391221f..dc970321 100644 --- a/.env.example +++ b/.env.example @@ -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 ---- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5afe98ea..5e5a98a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 \ No newline at end of file + severity: 'CRITICAL,HIGH' \ No newline at end of file diff --git a/README.md b/README.md index dd78000f..7d05d3fd 100644 --- a/README.md +++ b/README.md @@ -185,13 +185,11 @@ PG-only功能(vector搜索等)使用 `skipIfSQLite` 自动跳过。 ## CI/CD -GitHub Actions 5阶段流水线: +GitHub Actions 3阶段流水线: 1. **Test & Lint**: lint + vet + 单元/集成/e2e/benchmark(PG+SQLite矩阵) 2. **Security Scan**: gosec + govulncheck + Trivy 3. **Build**: Docker多平台镜像 + GHCR推送 -4. **Helm Validate**: K8s Helm chart lint + kubeconform -5. **Deploy**: Staging(develop分支)+ Production(release分支) 详见 [.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 | ## 文档 diff --git a/backend/configs/config.development.yaml b/backend/configs/config.development.yaml index 3931912a..f4ba888d 100644 --- a/backend/configs/config.development.yaml +++ b/backend/configs/config.development.yaml @@ -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: diff --git a/backend/configs/config.prod.yaml b/backend/configs/config.prod.yaml index b1478b1b..40d1d852 100644 --- a/backend/configs/config.prod.yaml +++ b/backend/configs/config.prod.yaml @@ -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" diff --git a/backend/configs/config.yaml b/backend/configs/config.yaml index d101bd6b..6cdda3a4 100644 --- a/backend/configs/config.yaml +++ b/backend/configs/config.yaml @@ -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: diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 7024de67..b33d5659 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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 { diff --git a/backend/internal/app/database.go b/backend/internal/app/database.go index 429c1abc..fc04354e 100644 --- a/backend/internal/app/database.go +++ b/backend/internal/app/database.go @@ -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 } diff --git a/backend/internal/app/redis.go b/backend/internal/app/redis.go index 746e86a7..86a055a5 100644 --- a/backend/internal/app/redis.go +++ b/backend/internal/app/redis.go @@ -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 -} diff --git a/backend/internal/app/redis_test.go b/backend/internal/app/redis_test.go index 2596a5b5..28c01419 100644 --- a/backend/internal/app/redis_test.go +++ b/backend/internal/app/redis_test.go @@ -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) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index df717a66..d9e46d10 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index a41b35ef..f3369595 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -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) { diff --git a/backend/internal/config/reloader_test.go b/backend/internal/config/reloader_test.go index 4360692b..f10d20e7 100644 --- a/backend/internal/config/reloader_test.go +++ b/backend/internal/config/reloader_test.go @@ -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}, diff --git a/backend/internal/config/validator.go b/backend/internal/config/validator.go index aafae9d1..0ffdd67d 100644 --- a/backend/internal/config/validator.go +++ b/backend/internal/config/validator.go @@ -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 diff --git a/backend/scripts/db_backup.sh b/backend/scripts/db_backup.sh index 6574daef..39d62948 100755 --- a/backend/scripts/db_backup.sh +++ b/backend/scripts/db_backup.sh @@ -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})" diff --git a/backend/scripts/migrate.sh b/backend/scripts/migrate.sh index 70c3add3..b51f20fa 100644 --- a/backend/scripts/migrate.sh +++ b/backend/scripts/migrate.sh @@ -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) diff --git a/backend/scripts/seed.sh b/backend/scripts/seed.sh index 46347bf1..f42e21ff 100755 --- a/backend/scripts/seed.sh +++ b/backend/scripts/seed.sh @@ -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, diff --git a/deploy/docker/docker-compose.dev.yml b/deploy/docker/docker-compose.dev.yml index b0c5f397..5d0ea065 100644 --- a/deploy/docker/docker-compose.dev.yml +++ b/deploy/docker/docker-compose.dev.yml @@ -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: diff --git a/deploy/docker/docker-compose.prod.yml b/deploy/docker/docker-compose.prod.yml index 3bab98cf..6d513eb5 100644 --- a/deploy/docker/docker-compose.prod.yml +++ b/deploy/docker/docker-compose.prod.yml @@ -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: diff --git a/deploy/docker/docker-compose.test.yml b/deploy/docker/docker-compose.test.yml index ecf2f7e1..7edb7560 100644 --- a/deploy/docker/docker-compose.test.yml +++ b/deploy/docker/docker-compose.test.yml @@ -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"] diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 2f1dfd4f..487381f9 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -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 diff --git a/deploy/helm/gochat/Chart.yaml b/deploy/helm/gochat/Chart.yaml deleted file mode 100644 index d30aeedd..00000000 --- a/deploy/helm/gochat/Chart.yaml +++ /dev/null @@ -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 diff --git a/deploy/helm/gochat/templates/_helpers.tpl b/deploy/helm/gochat/templates/_helpers.tpl deleted file mode 100644 index f1039da8..00000000 --- a/deploy/helm/gochat/templates/_helpers.tpl +++ /dev/null @@ -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 }} diff --git a/deploy/helm/gochat/templates/configmap.yaml b/deploy/helm/gochat/templates/configmap.yaml deleted file mode 100644 index 396ae0b4..00000000 --- a/deploy/helm/gochat/templates/configmap.yaml +++ /dev/null @@ -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" diff --git a/deploy/helm/gochat/templates/deployment.yaml b/deploy/helm/gochat/templates/deployment.yaml deleted file mode 100644 index 22c4f7bf..00000000 --- a/deploy/helm/gochat/templates/deployment.yaml +++ /dev/null @@ -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 }} diff --git a/deploy/helm/gochat/templates/externalsecret.yaml b/deploy/helm/gochat/templates/externalsecret.yaml deleted file mode 100644 index 6927fb6a..00000000 --- a/deploy/helm/gochat/templates/externalsecret.yaml +++ /dev/null @@ -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 }} \ No newline at end of file diff --git a/deploy/helm/gochat/templates/hpa.yaml b/deploy/helm/gochat/templates/hpa.yaml deleted file mode 100644 index f9d23929..00000000 --- a/deploy/helm/gochat/templates/hpa.yaml +++ /dev/null @@ -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 }} \ No newline at end of file diff --git a/deploy/helm/gochat/templates/ingress.yaml b/deploy/helm/gochat/templates/ingress.yaml deleted file mode 100644 index a1adec84..00000000 --- a/deploy/helm/gochat/templates/ingress.yaml +++ /dev/null @@ -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 }} diff --git a/deploy/helm/gochat/templates/jaeger.yaml b/deploy/helm/gochat/templates/jaeger.yaml deleted file mode 100644 index ccc87398..00000000 --- a/deploy/helm/gochat/templates/jaeger.yaml +++ /dev/null @@ -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 }} \ No newline at end of file diff --git a/deploy/helm/gochat/templates/metrics-service.yaml b/deploy/helm/gochat/templates/metrics-service.yaml deleted file mode 100644 index dc230da7..00000000 --- a/deploy/helm/gochat/templates/metrics-service.yaml +++ /dev/null @@ -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 }} diff --git a/deploy/helm/gochat/templates/networkpolicy.yaml b/deploy/helm/gochat/templates/networkpolicy.yaml deleted file mode 100644 index dc2e0ed6..00000000 --- a/deploy/helm/gochat/templates/networkpolicy.yaml +++ /dev/null @@ -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 }} \ No newline at end of file diff --git a/deploy/helm/gochat/templates/otel-configmap.yaml b/deploy/helm/gochat/templates/otel-configmap.yaml deleted file mode 100644 index 19464466..00000000 --- a/deploy/helm/gochat/templates/otel-configmap.yaml +++ /dev/null @@ -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 }} \ No newline at end of file diff --git a/deploy/helm/gochat/templates/pdb.yaml b/deploy/helm/gochat/templates/pdb.yaml deleted file mode 100644 index fd96cea9..00000000 --- a/deploy/helm/gochat/templates/pdb.yaml +++ /dev/null @@ -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 }} \ No newline at end of file diff --git a/deploy/helm/gochat/templates/sealedsecret.yaml b/deploy/helm/gochat/templates/sealedsecret.yaml deleted file mode 100644 index a111c800..00000000 --- a/deploy/helm/gochat/templates/sealedsecret.yaml +++ /dev/null @@ -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 }} \ No newline at end of file diff --git a/deploy/helm/gochat/templates/secrets.yaml b/deploy/helm/gochat/templates/secrets.yaml deleted file mode 100644 index 7f04aebd..00000000 --- a/deploy/helm/gochat/templates/secrets.yaml +++ /dev/null @@ -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 }} diff --git a/deploy/helm/gochat/templates/service.yaml b/deploy/helm/gochat/templates/service.yaml deleted file mode 100644 index 6ee4c106..00000000 --- a/deploy/helm/gochat/templates/service.yaml +++ /dev/null @@ -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 }} diff --git a/deploy/helm/gochat/templates/servicemonitor.yaml b/deploy/helm/gochat/templates/servicemonitor.yaml deleted file mode 100644 index 4f50523b..00000000 --- a/deploy/helm/gochat/templates/servicemonitor.yaml +++ /dev/null @@ -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 }} diff --git a/deploy/helm/gochat/templates/worker-deployment.yaml b/deploy/helm/gochat/templates/worker-deployment.yaml deleted file mode 100644 index b9ae2efe..00000000 --- a/deploy/helm/gochat/templates/worker-deployment.yaml +++ /dev/null @@ -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 }} diff --git a/deploy/helm/gochat/values-production.yaml b/deploy/helm/gochat/values-production.yaml deleted file mode 100644 index 6c57a97b..00000000 --- a/deploy/helm/gochat/values-production.yaml +++ /dev/null @@ -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" diff --git a/deploy/helm/gochat/values-staging.yaml b/deploy/helm/gochat/values-staging.yaml deleted file mode 100644 index d0385bd5..00000000 --- a/deploy/helm/gochat/values-staging.yaml +++ /dev/null @@ -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" diff --git a/deploy/helm/gochat/values.yaml b/deploy/helm/gochat/values.yaml deleted file mode 100644 index 358037b8..00000000 --- a/deploy/helm/gochat/values.yaml +++ /dev/null @@ -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 diff --git a/deploy/quickstart/compose.yaml b/deploy/quickstart/compose.yaml index e4057d31..7ba916cf 100644 --- a/deploy/quickstart/compose.yaml +++ b/deploy/quickstart/compose.yaml @@ -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 diff --git a/docs/ops/01-rolling-upgrade.md b/docs/ops/01-rolling-upgrade.md index eeb0b753..757b6347 100644 --- a/docs/ops/01-rolling-upgrade.md +++ b/docs/ops/01-rolling-upgrade.md @@ -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 diff --git a/docs/product/01-product-requirements.md b/docs/product/01-product-requirements.md index 5f7305d2..ff876bf4 100644 --- a/docs/product/01-product-requirements.md +++ b/docs/product/01-product-requirements.md @@ -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 完整流水线运行 diff --git a/docs/product/02-architecture.md b/docs/product/02-architecture.md index 9aca3e86..d987d555 100644 --- a/docs/product/02-architecture.md +++ b/docs/product/02-architecture.md @@ -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 → 程序默认值 diff --git a/docs/product/03-design-project-structure.md b/docs/product/03-design-project-structure.md index 44a50197..8e90850c 100644 --- a/docs/product/03-design-project-structure.md +++ b/docs/product/03-design-project-structure.md @@ -473,7 +473,7 @@ gochat/ │ ├── docker/ │ │ ├── Dockerfile │ │ └── docker-compose.yaml -│ └── k8s/ # Kubernetes配置(可选) +│ └── quickstart/ # 一键启动 Compose 栈 │ ├── go.mod # Go模块定义 ├── go.sum # Go依赖锁定