HH-439: harden production artifact pipeline (#86)
* HH-439: harden production artifact pipeline * fix(HH-439): address production compose review * fix(HH-439): preserve previous JWT secrets in production --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.agent_context
|
||||
.multica
|
||||
**/node_modules
|
||||
frontend/dist
|
||||
backend/bin
|
||||
backend/coverage.out
|
||||
.env
|
||||
.env.*
|
||||
+21
-62
@@ -1,69 +1,28 @@
|
||||
# GoChat Environment Variables
|
||||
# Copy to .env and fill in your values. NEVER commit .env to git.
|
||||
# GoChat production variables. Copy to .env and replace every CHANGE_ME value.
|
||||
|
||||
# ---- Application ----
|
||||
GOCHAT_ENV=prod
|
||||
# Keep disabled in production. Enable only for controlled QA/UAT environments.
|
||||
GOCHAT_IMAGE_REF=ghcr.io/rogeecn/gochat@sha256:CHANGE_ME
|
||||
GOCHAT_ENV=production
|
||||
GOCHAT_PORT=3000
|
||||
GOCHAT_SERVER_MODE=release
|
||||
GOCHAT_SERVER_CORS_ALLOWED_ORIGINS=https://chat.CHANGE_ME.example.com
|
||||
|
||||
# ---- Server ----
|
||||
GOCHAT_SERVER_HOST=0.0.0.0
|
||||
GOCHAT_SERVER_PORT=3000
|
||||
GOCHAT_SERVER_MODE=release # debug, release, test
|
||||
POSTGRES_DB=gochat_production
|
||||
POSTGRES_USER=gochat
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
# The built-in PostgreSQL service is non-TLS. For an external database, set a
|
||||
# complete GOCHAT_DATABASE_DSN with sslmode=require, verify-ca, or verify-full.
|
||||
|
||||
# ---- Database ----
|
||||
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
|
||||
POSTGRES_IMAGE_REF=pgvector/pgvector:pg16@sha256:ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b
|
||||
REDIS_IMAGE_REF=redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf
|
||||
MEILI_IMAGE_REF=getmeili/meilisearch:v1.13@sha256:bed3fb650e62da53145777204891159242f6ea4ce69e215b36223af4aa64a0ae
|
||||
|
||||
# ---- Redis ----
|
||||
GOCHAT_REDIS_DSN=redis://localhost:6379
|
||||
GOCHAT_REDIS_POOL_SIZE=50
|
||||
|
||||
# ---- JWT ----
|
||||
GOCHAT_JWT_SECRET=CHANGE_ME_TO_A_RANDOM_64_CHAR_STRING
|
||||
REDIS_PASSWORD=CHANGE_ME
|
||||
MEILI_MASTER_KEY=CHANGE_ME
|
||||
GOCHAT_JWT_SECRET=CHANGE_ME_WITH_AT_LEAST_32_RANDOM_CHARACTERS
|
||||
# Optional during a bounded rotation window; comma-separated old 32+ byte secrets.
|
||||
GOCHAT_JWT_PREVIOUS_SECRETS=
|
||||
GOCHAT_JWT_EXPIRY_HOURS=72
|
||||
GOCHAT_JWT_ACCESS_EXPIRY_MINUTES=15
|
||||
GOCHAT_JWT_REFRESH_EXPIRY_HOURS=168
|
||||
GOCHAT_JWT_ALLOW_INSECURE_HEADER_AUTH=false
|
||||
|
||||
# ---- Logging ----
|
||||
GOCHAT_LOG_LEVEL=info # debug, info, warn, error
|
||||
GOCHAT_LOG_FORMAT=json # json, text
|
||||
|
||||
# ---- SMTP / Email ----
|
||||
SMTP_ADDRESS=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=your-email@example.com
|
||||
SMTP_PASSWORD=your-email-password
|
||||
|
||||
# ---- Worker ----
|
||||
GOCHAT_WORKER_CONCURRENCY=10
|
||||
|
||||
# ---- Frontend URL ----
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
|
||||
# ---- Search ----
|
||||
GOCHAT_SEARCH_ENGINE=meilisearch
|
||||
GOCHAT_SEARCH_HOST=http://localhost:7700
|
||||
GOCHAT_SEARCH_API_KEY=
|
||||
GOCHAT_SEARCH_INDEX_PREFIX=gochat_
|
||||
GOCHAT_SEARCH_TIMEOUT_SECONDS=5
|
||||
|
||||
# ---- Storage ----
|
||||
GOCHAT_STORAGE_PROVIDER=local
|
||||
GOCHAT_STORAGE_LOCAL_PATH=./uploads
|
||||
GOCHAT_STORAGE_MAX_FILE_SIZE=20971520 # 20MB
|
||||
|
||||
# ---- OIDC (Enterprise OAuth/OIDC SSO, disabled by default) ----
|
||||
GOCHAT_OIDC_ENABLED=false
|
||||
GOCHAT_OIDC_DEFAULT_CLIENT_ID=
|
||||
GOCHAT_OIDC_DEFAULT_CLIENT_SECRET=
|
||||
GOCHAT_OIDC_DEFAULT_REDIRECT_URL=https://your-domain.com/api/v1/oidc/callback
|
||||
GOCHAT_OIDC_DEFAULT_ISSUER_URL=https://accounts.google.com
|
||||
GOCHAT_OIDC_DEFAULT_AUTHORIZATION_URL=
|
||||
GOCHAT_OIDC_DEFAULT_TOKEN_URL=
|
||||
GOCHAT_OIDC_DEFAULT_USER_INFO_URL=
|
||||
GOCHAT_OIDC_DEFAULT_JWKS_URL=
|
||||
GOCHAT_OIDC_DEFAULT_SCOPES=openid,profile,email
|
||||
# Optional connector. Supply the digest published by its release pipeline.
|
||||
SHANGWUTONG_IMAGE_REF=ghcr.io/rogeecn/shangwutong@sha256:CHANGE_ME
|
||||
GOCHAT_CONNECTOR_SERVICE_TOKEN=
|
||||
|
||||
@@ -5,10 +5,15 @@ on:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*.*.*']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend (SQLite)
|
||||
@@ -153,3 +158,110 @@ jobs:
|
||||
- name: Prettier representative JS/TS/Vue files
|
||||
run: pnpm --dir frontend exec prettier --check app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.spec.js app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.vue vite.config.ts app/javascript/histoire.setup.ts
|
||||
- run: pnpm --dir frontend build
|
||||
|
||||
production-smoke:
|
||||
name: Production image smoke
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOCHAT_IMAGE_REF: gochat:production-smoke
|
||||
GOCHAT_PORT: "38080"
|
||||
GOCHAT_SERVER_CORS_ALLOWED_ORIGINS: https://chat.example.test
|
||||
POSTGRES_PASSWORD: ci-postgres-secret
|
||||
REDIS_PASSWORD: ci-redis-secret
|
||||
MEILI_MASTER_KEY: ci-meili-secret-16
|
||||
GOCHAT_JWT_SECRET: ci-smoke-jwt-secret-at-least-32-characters
|
||||
GOCHAT_JWT_PREVIOUS_SECRETS: ci-previous-jwt-secret-at-least-32-characters
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build production images from repository root
|
||||
run: |
|
||||
docker build -t "$GOCHAT_IMAGE_REF" -f deploy/docker/Dockerfile .
|
||||
docker build -t shangwutong:production-smoke -f channels/shangwutong/Dockerfile .
|
||||
docker run -d --name gochat-ci-registry -p 127.0.0.1:5000:5000 registry:2
|
||||
timeout 30 sh -c 'until curl -fsS http://127.0.0.1:5000/v2/; do sleep 1; done'
|
||||
docker tag "$GOCHAT_IMAGE_REF" localhost:5000/gochat:production-smoke
|
||||
docker tag shangwutong:production-smoke localhost:5000/shangwutong:production-smoke
|
||||
docker push localhost:5000/gochat:production-smoke
|
||||
docker push localhost:5000/shangwutong:production-smoke
|
||||
echo "GOCHAT_IMAGE_REF=$(docker inspect --format '{{index .RepoDigests 0}}' localhost:5000/gochat:production-smoke)" >> "$GITHUB_ENV"
|
||||
echo "SHANGWUTONG_IMAGE_REF=$(docker inspect --format '{{index .RepoDigests 0}}' localhost:5000/shangwutong:production-smoke)" >> "$GITHUB_ENV"
|
||||
- name: Verify artifact contents
|
||||
run: >-
|
||||
docker run --rm --entrypoint sh "$GOCHAT_IMAGE_REF" -c
|
||||
'test -s /app/configs/config.production.yaml &&
|
||||
test -d /app/migrations &&
|
||||
test -s /app/frontend/dist/index.html'
|
||||
- name: Start production Compose and smoke core pages
|
||||
run: |
|
||||
if MEILI_MASTER_KEY=too-short deploy/docker/preflight.sh; then
|
||||
echo "preflight accepted a short Meilisearch key" >&2
|
||||
exit 1
|
||||
fi
|
||||
if POSTGRES_IMAGE_REF=pgvector/pgvector:pg16 deploy/docker/preflight.sh; then
|
||||
echo "preflight accepted a mutable PostgreSQL image" >&2
|
||||
exit 1
|
||||
fi
|
||||
deploy/docker/preflight.sh
|
||||
docker compose -f deploy/docker/docker-compose.prod.yml config --format json | python3 -c 'import json, os, sys; config = json.load(sys.stdin); assert all(config["services"][service]["environment"]["GOCHAT_JWT_PREVIOUS_SECRETS"] == os.environ["GOCHAT_JWT_PREVIOUS_SECRETS"] for service in ("gochat", "worker"))'
|
||||
docker compose -f deploy/docker/docker-compose.prod.yml up -d --wait gochat
|
||||
curl -fsS "http://127.0.0.1:$GOCHAT_PORT/health" | grep -q '"status":"ok"'
|
||||
curl -fsS "http://127.0.0.1:$GOCHAT_PORT/app" | grep -q '/assets/'
|
||||
- name: Stop production Compose
|
||||
if: always()
|
||||
run: |
|
||||
docker compose -f deploy/docker/docker-compose.prod.yml down -v
|
||||
docker rm -f gochat-ci-registry || true
|
||||
|
||||
release:
|
||||
name: Publish immutable image
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [backend, backend-postgres, frontend]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
image_ref: ${{ steps.ref.outputs.image_ref }}
|
||||
image_digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract immutable tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
flavor: latest=false
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=sha
|
||||
- name: Capture build date
|
||||
id: date
|
||||
run: echo "value=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: deploy/docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ github.ref_name }}
|
||||
COMMIT_SHA=${{ github.sha }}
|
||||
BUILD_DATE=${{ steps.date.outputs.value }}
|
||||
cache-from: type=gha,scope=gochat
|
||||
cache-to: type=gha,mode=max,scope=gochat
|
||||
- name: Record digest reference
|
||||
id: ref
|
||||
run: |
|
||||
image_ref="${REGISTRY}/${IMAGE_NAME}@${{ steps.build.outputs.digest }}"
|
||||
echo "image_ref=$image_ref" >> "$GITHUB_OUTPUT"
|
||||
echo "GoChat image: \`$image_ref\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# GoChat Production Environment Overrides
|
||||
# Reference: Chatwoot config/environments/production.rb
|
||||
|
||||
server:
|
||||
mode: "release"
|
||||
cors:
|
||||
# PRODUCTION: Must specify exact origins or wildcard patterns.
|
||||
# NEVER leave empty in production — empty + non-debug = no CORS allowed.
|
||||
allowed_origins:
|
||||
- "https://app.yourdomain.com"
|
||||
- "https://admin.yourdomain.com"
|
||||
- "*.yourdomain.com" # matches any subdomain
|
||||
allow_credentials: true # needed for JWT cookie-based auth
|
||||
max_age: 86400
|
||||
|
||||
jwt:
|
||||
allow_insecure_header_auth: false
|
||||
|
||||
database:
|
||||
dsn: "postgres://gochat:CHANGE_ME@localhost:5432/gochat_production?sslmode=require"
|
||||
pool_max: 20
|
||||
log_level: "warn"
|
||||
|
||||
log:
|
||||
level: "info"
|
||||
format: "json"
|
||||
|
||||
worker:
|
||||
concurrency: 10
|
||||
redis_stream_prefix: "gochat:jobs"
|
||||
redis_consumer_group: "gochat-workers"
|
||||
redis_block_timeout_s: 5
|
||||
redis_sweep_interval_s: 30
|
||||
|
||||
redis:
|
||||
channel_prefix: "gochat_production"
|
||||
@@ -0,0 +1,38 @@
|
||||
# GoChat production overrides. Secrets and public origins must come from the environment.
|
||||
server:
|
||||
mode: "release"
|
||||
cors:
|
||||
allowed_origins: ["https://CHANGE_ME.example.com"]
|
||||
allow_credentials: true
|
||||
|
||||
database:
|
||||
dsn: "postgres://gochat:CHANGE_ME@postgres:5432/gochat_production?sslmode=disable"
|
||||
run_migrations: true
|
||||
migrations_path: "/app/migrations"
|
||||
|
||||
redis:
|
||||
dsn: "redis://:CHANGE_ME@redis:6379"
|
||||
|
||||
jwt:
|
||||
secret: "CHANGE_ME"
|
||||
allow_insecure_header_auth: false
|
||||
|
||||
search:
|
||||
engine: "meilisearch"
|
||||
host: "http://meilisearch:7700"
|
||||
api_key: "CHANGE_ME"
|
||||
|
||||
log:
|
||||
level: "info"
|
||||
format: "json"
|
||||
|
||||
worker:
|
||||
concurrency: 10
|
||||
redis_stream_prefix: "gochat:jobs"
|
||||
redis_consumer_group: "gochat-workers"
|
||||
redis_block_timeout_s: 5
|
||||
redis_sweep_interval_s: 30
|
||||
|
||||
storage:
|
||||
provider: "local"
|
||||
local_path: "/app/storage/uploads"
|
||||
@@ -157,9 +157,9 @@ type LogConfig struct {
|
||||
Format string `mapstructure:"format"` // json, text
|
||||
}
|
||||
|
||||
// SAMLConfig and LDAPConfig removed — only OIDC is supported for enterprise SSO.
|
||||
// SAMLConfig and LDAPConfig removed — only OIDC is supported for enterprise SSO.
|
||||
|
||||
// OIDCConfig holds OIDC/OAuth2 enterprise authentication configuration.
|
||||
// OIDCConfig holds OIDC/OAuth2 enterprise authentication configuration.
|
||||
// Reference: M13 §4.3 — OIDC (OpenID Connect) provider integration.
|
||||
// Supports Google Workspace, Auth0, Keycloak, Azure AD and any OIDC-compliant IdP.
|
||||
// Per-account OIDC settings override these defaults (stored in DB).
|
||||
@@ -460,6 +460,10 @@ func (r *ConfigReloader) Stop() {
|
||||
//
|
||||
// config/environments/development.rb overrides config/application.rb defaults.
|
||||
func LoadWithEnv(env string) (*Config, error) {
|
||||
if env == "prod" {
|
||||
return nil, fmt.Errorf("GOCHAT_ENV=prod is unsupported; use GOCHAT_ENV=production")
|
||||
}
|
||||
|
||||
v := viper.New()
|
||||
|
||||
// Env key replacer: GOCHAT_DATABASE_DSN → database.dsn
|
||||
@@ -473,6 +477,7 @@ func LoadWithEnv(env string) (*Config, error) {
|
||||
"GOCHAT_SERVER_HOST": "server.host",
|
||||
"GOCHAT_SERVER_PORT": "server.port",
|
||||
"GOCHAT_SERVER_MODE": "server.mode",
|
||||
"GOCHAT_SERVER_CORS_ALLOWED_ORIGINS": "server.cors.allowed_origins",
|
||||
"GOCHAT_DATABASE_DSN": "database.dsn",
|
||||
"GOCHAT_DATABASE_MAX_IDLE_CONNS": "database.max_idle_conns",
|
||||
"GOCHAT_DATABASE_MAX_OPEN_CONNS": "database.max_open_conns",
|
||||
@@ -547,11 +552,7 @@ func LoadWithEnv(env string) (*Config, error) {
|
||||
|
||||
// Overlay environment-specific config: config.{env}.yaml
|
||||
if env != "" && env != "default" {
|
||||
overlayEnv := env
|
||||
if env == "production" {
|
||||
overlayEnv = "prod"
|
||||
}
|
||||
envFile := fmt.Sprintf("config.%s.yaml", overlayEnv)
|
||||
envFile := fmt.Sprintf("config.%s.yaml", env)
|
||||
// Search in the same directory as the base config
|
||||
baseConfigPath := v.ConfigFileUsed()
|
||||
if baseConfigPath != "" {
|
||||
@@ -562,6 +563,8 @@ func LoadWithEnv(env string) (*Config, error) {
|
||||
return nil, fmt.Errorf("env config merge failed (%s): %w", env, err)
|
||||
}
|
||||
applogger.L().Infof("Merged env config overlay: %s", envConfigPath)
|
||||
} else if env == "production" {
|
||||
return nil, fmt.Errorf("production config overlay not found: %s", envConfigPath)
|
||||
} else {
|
||||
applogger.L().Warnf("Env config file not found: %s (continuing with base config)", envConfigPath)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -127,13 +129,13 @@ func TestValidate_ReleaseJWTSecurity(t *testing.T) {
|
||||
validSecret := "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE"
|
||||
base := func() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{Port: 8080, Mode: "release"},
|
||||
Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db"},
|
||||
Redis: RedisConfig{DSN: "redis://localhost:6379"},
|
||||
Server: ServerConfig{Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
|
||||
Database: DatabaseConfig{DSN: "postgres://user:database-secret@postgres:5432/db?sslmode=disable"},
|
||||
Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"},
|
||||
JWT: JWTConfig{Secret: validSecret},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 1, SweepIntervalS: 1},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700"},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700", APIKey: "search-secret-123"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +243,91 @@ func TestValidate_SearchDBFallbackRejectedInRelease(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "release mode requires meilisearch")
|
||||
}
|
||||
|
||||
func TestValidate_ReleaseRejectsPlaceholders(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
|
||||
Database: DatabaseConfig{DSN: "postgres://gochat:CHANGE_ME@postgres:5432/gochat"},
|
||||
Redis: RedisConfig{DSN: "redis://:secret@redis:6379"},
|
||||
JWT: JWTConfig{Secret: "production-jwt-secret-at-least-32-characters"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "search-secret", TimeoutSeconds: 5},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
assert.ErrorContains(t, err, "database password")
|
||||
}
|
||||
|
||||
func TestValidate_ReleaseRejectsShortSearchKey(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
|
||||
Database: DatabaseConfig{DSN: "postgres://gochat:database-secret@postgres:5432/gochat?sslmode=disable"},
|
||||
Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"},
|
||||
JWT: JWTConfig{Secret: "production-jwt-secret-at-least-32-characters"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "123456789012345", TimeoutSeconds: 5},
|
||||
}
|
||||
|
||||
assert.ErrorContains(t, Validate(cfg), "search API key must be at least 16 bytes")
|
||||
}
|
||||
|
||||
func TestValidate_ReleaseDatabaseTLS(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
|
||||
Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"},
|
||||
JWT: JWTConfig{Secret: "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "search-secret-123", TimeoutSeconds: 5},
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
dsn string
|
||||
wantErr bool
|
||||
}{
|
||||
{"external disable", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=disable", true},
|
||||
{"external missing sslmode", "postgres://gochat:database-secret@db.example.test:5432/gochat", true},
|
||||
{"external require", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require", false},
|
||||
{"built-in compose disable", "postgres://gochat:database-secret@postgres:5432/gochat?sslmode=disable", false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg.Database.DSN = tt.dsn
|
||||
if tt.wantErr {
|
||||
assert.ErrorContains(t, Validate(cfg), "production database DSN must use sslmode")
|
||||
} else {
|
||||
assert.NoError(t, Validate(cfg))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithEnv_ProductionRequiresOverlay(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "configs"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "configs", "config.yaml"), []byte("server:\n mode: debug\n"), 0o644))
|
||||
t.Chdir(tmpDir)
|
||||
|
||||
_, err := LoadWithEnv("production")
|
||||
assert.ErrorContains(t, err, "production config overlay not found")
|
||||
}
|
||||
|
||||
func TestLoadWithEnv_ProductionOverlay(t *testing.T) {
|
||||
t.Setenv("GOCHAT_DATABASE_DSN", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require")
|
||||
t.Setenv("GOCHAT_REDIS_DSN", "redis://:redis-secret@redis:6379")
|
||||
t.Setenv("GOCHAT_JWT_SECRET", "production-jwt-secret-at-least-32-characters")
|
||||
t.Setenv("GOCHAT_SEARCH_API_KEY", "search-secret-123")
|
||||
t.Setenv("GOCHAT_SERVER_CORS_ALLOWED_ORIGINS", "https://chat.example.test")
|
||||
t.Chdir("../..")
|
||||
|
||||
cfg, err := LoadWithEnv("production")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "release", cfg.Server.Mode)
|
||||
assert.Equal(t, []string{"https://chat.example.test"}, cfg.Server.CORS.AllowedOrigins)
|
||||
assert.NoError(t, Validate(cfg))
|
||||
}
|
||||
|
||||
func TestDatabaseConfig_MigrateDSN(t *testing.T) {
|
||||
cfg := DatabaseConfig{
|
||||
DSN: "postgres://gochat:secret@localhost:5432/gochat_db?sslmode=disable",
|
||||
|
||||
@@ -111,9 +111,39 @@ func Validate(cfg *Config) error {
|
||||
return fmt.Errorf("search.timeout_seconds must be >= 0")
|
||||
}
|
||||
|
||||
if cfg.Server.Mode == "release" {
|
||||
if len(cfg.Server.CORS.AllowedOrigins) == 0 || containsPlaceholder(strings.Join(cfg.Server.CORS.AllowedOrigins, ",")) {
|
||||
return fmt.Errorf("production CORS origins are required and must not contain placeholders")
|
||||
}
|
||||
if dbURL.User == nil || dbURL.User.Username() == "" {
|
||||
return fmt.Errorf("production database credentials are required")
|
||||
}
|
||||
if password, ok := dbURL.User.Password(); !ok || password == "" || containsPlaceholder(password) {
|
||||
return fmt.Errorf("production database password is required and must not contain placeholders")
|
||||
}
|
||||
sslMode := dbURL.Query().Get("sslmode")
|
||||
if !(dbURL.Hostname() == "postgres" && sslMode == "disable") && sslMode != "require" && sslMode != "verify-ca" && sslMode != "verify-full" {
|
||||
return fmt.Errorf("production database DSN must use sslmode=require, verify-ca, or verify-full (sslmode=disable is only allowed for the built-in postgres service)")
|
||||
}
|
||||
if redisURL.User == nil {
|
||||
return fmt.Errorf("production Redis credentials are required")
|
||||
}
|
||||
if password, ok := redisURL.User.Password(); !ok || password == "" || containsPlaceholder(password) {
|
||||
return fmt.Errorf("production Redis password is required and must not contain placeholders")
|
||||
}
|
||||
if len(cfg.Search.APIKey) < 16 || containsPlaceholder(cfg.Search.APIKey) {
|
||||
return fmt.Errorf("production search API key must be at least 16 bytes and must not contain placeholders")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsPlaceholder(value string) bool {
|
||||
value = strings.ToLower(value)
|
||||
return strings.Contains(value, "change_me") || strings.Contains(value, "change-me") || strings.Contains(value, "changeme")
|
||||
}
|
||||
|
||||
func validateProductionJWTSecret(name, secret string) error {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if len([]byte(secret)) < 32 {
|
||||
|
||||
@@ -53,6 +53,13 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
# ========== Production Stage ==========
|
||||
FROM alpine:3.21
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT_SHA=unknown
|
||||
ARG BUILD_DATE=unknown
|
||||
LABEL org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.revision="${COMMIT_SHA}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}"
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk --no-cache add ca-certificates tzdata curl && addgroup -S gochat && adduser -S gochat -G gochat
|
||||
|
||||
|
||||
@@ -1,106 +1,133 @@
|
||||
# GoChat Production Environment
|
||||
# Reference: Chatwoot docker-compose.production.yaml — rails + sidekiq + postgres + redis, restart policies
|
||||
# Enhanced with pgvector, resource limits, non-root user, health checks
|
||||
name: gochat-production
|
||||
|
||||
version: '3.8'
|
||||
x-gochat-image: &gochat-image ${GOCHAT_IMAGE_REF:?set GOCHAT_IMAGE_REF to an immutable image digest}
|
||||
x-gochat-environment: &gochat-environment
|
||||
GOCHAT_ENV: production
|
||||
GOCHAT_SERVER_HOST: 0.0.0.0
|
||||
GOCHAT_SERVER_PORT: 3000
|
||||
GOCHAT_SERVER_MODE: release
|
||||
GOCHAT_SERVER_CORS_ALLOWED_ORIGINS: ${GOCHAT_SERVER_CORS_ALLOWED_ORIGINS:?set production CORS origins}
|
||||
GOCHAT_DATABASE_DSN: ${GOCHAT_DATABASE_DSN:-postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gochat_production}?sslmode=disable}
|
||||
GOCHAT_DATABASE_RUN_MIGRATIONS: "true"
|
||||
GOCHAT_DATABASE_MIGRATIONS_PATH: /app/migrations
|
||||
GOCHAT_REDIS_DSN: redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD}@redis:6379
|
||||
GOCHAT_SEARCH_ENGINE: meilisearch
|
||||
GOCHAT_SEARCH_HOST: http://meilisearch:7700
|
||||
GOCHAT_SEARCH_API_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY}
|
||||
GOCHAT_JWT_SECRET: ${GOCHAT_JWT_SECRET:?set GOCHAT_JWT_SECRET}
|
||||
GOCHAT_JWT_PREVIOUS_SECRETS: ${GOCHAT_JWT_PREVIOUS_SECRETS:-}
|
||||
GOCHAT_LOG_LEVEL: info
|
||||
GOCHAT_LOG_FORMAT: json
|
||||
GOCHAT_STORAGE_PROVIDER: local
|
||||
GOCHAT_STORAGE_LOCAL_PATH: /app/storage/uploads
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
container_name: gochat-postgres
|
||||
image: ${POSTGRES_IMAGE_REF:-pgvector/pgvector:pg16@sha256:ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b}
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-gochat_production}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-gochat}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # MUST be set in .env
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432" # Only localhost access
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-gochat}"]
|
||||
interval: 10s
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 20
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: gochat-redis
|
||||
image: ${REDIS_IMAGE_REF:-redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf}
|
||||
restart: always
|
||||
command: redis-server --requirepass "${REDIS_PASSWORD}" --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379" # Only localhost access
|
||||
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?set REDIS_PASSWORD}", "--appendonly", "yes", "--maxmemory", "512mb", "--maxmemory-policy", "allkeys-lru"]
|
||||
environment:
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:?set REDIS_PASSWORD}
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 10s
|
||||
test: ["CMD-SHELL", "redis-cli -a '$${REDIS_PASSWORD}' ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 20
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
meilisearch:
|
||||
image: ${MEILI_IMAGE_REF:-getmeili/meilisearch:v1.13@sha256:bed3fb650e62da53145777204891159242f6ea4ce69e215b36223af4aa64a0ae}
|
||||
restart: always
|
||||
environment:
|
||||
MEILI_ENV: production
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY}
|
||||
MEILI_NO_ANALYTICS: "true"
|
||||
volumes:
|
||||
- meili_data:/meili_data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--spider", "http://127.0.0.1:7700/health"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
gochat:
|
||||
image: gochat/gochat:${GOCHAT_VERSION:-latest}
|
||||
container_name: gochat-app
|
||||
image: *gochat-image
|
||||
restart: always
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
environment: *gochat-environment
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000" # Reverse proxy should handle external access
|
||||
env_file: ../../.env
|
||||
environment:
|
||||
- GOCHAT_ENV=production
|
||||
- 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
|
||||
- "127.0.0.1:${GOCHAT_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- gochat_storage:/app/storage
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
start_period: 15s
|
||||
retries: 3
|
||||
retries: 30
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '1.0'
|
||||
cpus: "1.0"
|
||||
reservations:
|
||||
memory: 256M
|
||||
cpus: '0.5'
|
||||
cpus: "0.5"
|
||||
|
||||
worker:
|
||||
image: gochat/gochat:${GOCHAT_VERSION:-latest}
|
||||
container_name: gochat-worker
|
||||
image: *gochat-image
|
||||
restart: always
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
command: ["serve", "--worker-only"]
|
||||
env_file: ../../.env
|
||||
environment:
|
||||
- GOCHAT_ENV=production
|
||||
- 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
|
||||
<<: *gochat-environment
|
||||
GOCHAT_DATABASE_RUN_MIGRATIONS: "false"
|
||||
volumes:
|
||||
- gochat_storage:/app/storage
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '1.0'
|
||||
cpus: "1.0"
|
||||
|
||||
shangwutong:
|
||||
image: ${SHANGWUTONG_IMAGE:-ghcr.io/gochat/shangwutong:latest}
|
||||
container_name: gochat-shangwutong
|
||||
image: ${SHANGWUTONG_IMAGE_REF:?set SHANGWUTONG_IMAGE_REF to an immutable image digest}
|
||||
restart: always
|
||||
stop_grace_period: ${SWT_SHUTDOWN_TIMEOUT:-30s}
|
||||
environment:
|
||||
@@ -125,13 +152,15 @@ services:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '1.0'
|
||||
cpus: "1.0"
|
||||
reservations:
|
||||
memory: 128M
|
||||
cpus: '0.25'
|
||||
cpus: "0.25"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
meili_data:
|
||||
gochat_storage:
|
||||
shangwutong_data:
|
||||
shangwutong_backups:
|
||||
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export LC_ALL=C
|
||||
|
||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
compose_args=(-f "$script_dir/docker-compose.prod.yml")
|
||||
if (($#)); then
|
||||
env_file=$1
|
||||
set -a
|
||||
source "$env_file"
|
||||
set +a
|
||||
compose_args=(--env-file "$env_file" "${compose_args[@]}")
|
||||
fi
|
||||
|
||||
required=(GOCHAT_IMAGE_REF SHANGWUTONG_IMAGE_REF GOCHAT_SERVER_CORS_ALLOWED_ORIGINS POSTGRES_PASSWORD REDIS_PASSWORD MEILI_MASTER_KEY GOCHAT_JWT_SECRET)
|
||||
for name in "${required[@]}"; do
|
||||
value=${!name:-}
|
||||
if [[ -z $value || ${value^^} == *CHANGE_ME* ]]; then
|
||||
echo "$name is required and must not contain CHANGE_ME" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ((${#GOCHAT_JWT_SECRET} < 32)); then
|
||||
echo "GOCHAT_JWT_SECRET must be at least 32 characters" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ((${#MEILI_MASTER_KEY} < 16)); then
|
||||
echo "MEILI_MASTER_KEY must be at least 16 bytes" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n ${GOCHAT_DATABASE_DSN:-} && ! $GOCHAT_DATABASE_DSN =~ (^|[?&])sslmode=(require|verify-ca|verify-full)(&|$) ]]; then
|
||||
echo "GOCHAT_DATABASE_DSN must explicitly require TLS for an external database" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
images=$(docker compose "${compose_args[@]}" config --images)
|
||||
while IFS= read -r image; do
|
||||
if [[ $image =~ @sha256:[0-9a-fA-F]{64}$ ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "production image must be pinned to a sha256 digest: $image" >&2
|
||||
exit 1
|
||||
done <<< "$images"
|
||||
echo "production preflight passed"
|
||||
Reference in New Issue
Block a user