From f22af7ee4c130adcabe620981eef271ef80bb4ce Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Sun, 21 Jun 2026 14:24:23 +0800 Subject: [PATCH] chore(release): initialize separated production env --- .gitea/workflows/ci-cd.yml | 6 ++ .github/workflows/ci-cd.yml | 6 ++ docs/PRODUCTION-RELEASE-CHECKLIST.md | 13 +++- scripts/init_production_env.sh | 83 +++++++++++++++++++++++++ scripts/validate_release_env.py | 4 +- tests/unit/test_release_scripts.py | 13 ++++ tests/unit/test_validate_release_env.py | 26 ++++++++ 7 files changed, 146 insertions(+), 5 deletions(-) create mode 100755 scripts/init_production_env.sh diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 1aacb3b99..cd4f5707f 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -70,6 +70,12 @@ jobs: run: | bandit -r apps packages -q + - name: Validate release scripts syntax + run: | + bash -n scripts/backup_postgres.sh + bash -n scripts/restore_postgres_plan.sh + bash -n scripts/init_production_env.sh + - name: Validate Alembic migrations run: | DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas \ diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 1aacb3b99..cd4f5707f 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -70,6 +70,12 @@ jobs: run: | bandit -r apps packages -q + - name: Validate release scripts syntax + run: | + bash -n scripts/backup_postgres.sh + bash -n scripts/restore_postgres_plan.sh + bash -n scripts/init_production_env.sh + - name: Validate Alembic migrations run: | DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas \ diff --git a/docs/PRODUCTION-RELEASE-CHECKLIST.md b/docs/PRODUCTION-RELEASE-CHECKLIST.md index 2d742db2a..79350edd8 100644 --- a/docs/PRODUCTION-RELEASE-CHECKLIST.md +++ b/docs/PRODUCTION-RELEASE-CHECKLIST.md @@ -12,7 +12,14 @@ ## 2. 生产环境检查 -- `/var/lib/xiaoxia-saas-production/.env` 存在且权限正确。 +- `/var/lib/xiaoxia-saas-production/.env` 存在且权限正确。首次初始化必须使用非部署脚本生成独立 production env: + +```bash +scripts/init_production_env.sh +``` + +脚本只创建目录和 `.env`,不会启动容器;生成后必须人工替换 `CHANGE_ME_PRODUCTION_DB_PASSWORD`,并确认生产 DB/Redis 使用 `xiaoxia-postgres-production` / `xiaoxia-redis-production`,不得复用 staging 的 `xiaoxia-postgres` / `xiaoxia-redis`。 + - `APP_ENV=production`。 - `DEBUG=false`。 - `AUTO_CREATE_SCHEMA=false`。 @@ -21,8 +28,8 @@ - `REDIS_URL` 指向生产 Redis。 - OSS 配置已确认或明确保持本地 fallback。 - `GENERATED_FILES_HOST_DIR` 生产环境不得指向 staging 目录。 -- 本地校验文件:`python scripts/validate_release_env.py /var/lib/xiaoxia-saas-production/.env --strict-external`。 -- 容器内校验已注入环境:`docker exec xiaoxia-api-production python /app/scripts/validate_release_env.py --from-environ --strict-external`。 +- 本地校验文件:`python scripts/validate_release_env.py /var/lib/xiaoxia-saas-production/.env --strict-external`(SMTP 仅在 `ENABLE_EMAIL_DELIVERY=true` 时强制)。 +- 容器内校验已注入环境:`docker exec xiaoxia-api-production python /app/scripts/validate_release_env.py --from-environ --strict-external`(SMTP 仅在启用邮件时强制)。 - 外部服务 smoke:`docker exec xiaoxia-api-production python /app/scripts/smoke_external_services.py --strict --skip-smtp`(SMTP 未启用时)。 - SMTP 真发信 smoke(启用邮件前必须执行):`docker exec xiaoxia-api-production python /app/scripts/smoke_external_services.py --strict --send-email-to <测试邮箱>`。 - 如开启邮件/session: diff --git a/scripts/init_production_env.sh b/scripts/init_production_env.sh new file mode 100755 index 000000000..32d376ee2 --- /dev/null +++ b/scripts/init_production_env.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +STAGING_ENV=${STAGING_ENV:-/var/lib/xiaoxia-saas-staging/.env} +PRODUCTION_DIR=${PRODUCTION_DIR:-/var/lib/xiaoxia-saas-production} +PRODUCTION_ENV=${PRODUCTION_ENV:-$PRODUCTION_DIR/.env} +GENERATED_DIR=${GENERATED_DIR:-$PRODUCTION_DIR/generated} +FORCE=${FORCE:-false} + +if [ -e "$PRODUCTION_ENV" ] && [ "$FORCE" != "true" ]; then + echo "ERROR: production env already exists: $PRODUCTION_ENV" >&2 + echo "Set FORCE=true only after manually reviewing the existing file." >&2 + exit 1 +fi + +mkdir -p "$PRODUCTION_DIR" "$GENERATED_DIR" +chmod 700 "$PRODUCTION_DIR" + +get_staging_value() { + key="$1" + if [ -f "$STAGING_ENV" ]; then + grep -E "^${key}=" "$STAGING_ENV" | tail -1 | cut -d= -f2- || true + fi +} + +random_secret() { + python3 - <<'PY' +import secrets +print(secrets.token_urlsafe(48)) +PY +} + +OSS_ENDPOINT=${OSS_ENDPOINT:-$(get_staging_value OSS_ENDPOINT)} +OSS_BUCKET_NAME=${OSS_BUCKET_NAME:-$(get_staging_value OSS_BUCKET_NAME)} +OSS_ACCESS_KEY_ID=${OSS_ACCESS_KEY_ID:-$(get_staging_value OSS_ACCESS_KEY_ID)} +OSS_ACCESS_KEY_SECRET=${OSS_ACCESS_KEY_SECRET:-$(get_staging_value OSS_ACCESS_KEY_SECRET)} +JWT_SECRET_KEY=${JWT_SECRET_KEY:-$(random_secret)} + +cat > "$PRODUCTION_ENV" < list[str]: errors.append("DEBUG must be false in production") external_requirements = [] - if strict_external or is_enabled(values.get("ENABLE_EMAIL_DELIVERY")): + if is_enabled(values.get("ENABLE_EMAIL_DELIVERY")): external_requirements.extend(REQUIRED_SMTP) if strict_external: external_requirements.extend(REQUIRED_OSS) @@ -117,7 +117,7 @@ def main() -> int: parser.add_argument( "--strict-external", action="store_true", - help="Require SMTP, Redis sessions, and OSS credentials for production readiness.", + help="Require Redis sessions and OSS credentials for production readiness. SMTP is required only when ENABLE_EMAIL_DELIVERY=true.", ) args = parser.parse_args() diff --git a/tests/unit/test_release_scripts.py b/tests/unit/test_release_scripts.py index 9fea15e5d..4829e835e 100644 --- a/tests/unit/test_release_scripts.py +++ b/tests/unit/test_release_scripts.py @@ -10,6 +10,19 @@ def test_restore_postgres_plan_is_non_destructive(): assert "pg_restore" not in executable_prefix +def test_init_production_env_is_non_deploying_and_separated(): + script = Path("scripts/init_production_env.sh").read_text(encoding="utf-8") + + assert "APP_ENV=production" in script + assert "ENVIRONMENT=production" in script + assert "xiaoxia-postgres-production" in script + assert "xiaoxia-redis-production" in script + assert "GENERATED_FILES_HOST_DIR=$GENERATED_DIR" in script + assert "ENABLE_EMAIL_DELIVERY=false" in script + assert "docker compose" not in script + assert "docker run" not in script + + def test_backup_postgres_writes_manifest_and_version(): script = Path("scripts/backup_postgres.sh").read_text(encoding="utf-8") diff --git a/tests/unit/test_validate_release_env.py b/tests/unit/test_validate_release_env.py index 35054d517..e259270e0 100644 --- a/tests/unit/test_validate_release_env.py +++ b/tests/unit/test_validate_release_env.py @@ -35,6 +35,32 @@ def test_validate_release_env_accepts_strict_production(tmp_path: Path): assert validate(parse_env_file(env_file), strict_external=True) == [] +def test_validate_release_env_allows_disabled_smtp_with_strict_external(tmp_path: Path): + env_file = tmp_path / ".env.production" + env_file.write_text( + "\n".join( + [ + "APP_ENV=production", + "DATABASE_URL=postgresql+psycopg://user:pass@db:5432/app", + "JWT_SECRET_KEY=abcdefghijklmnopqrstuvwxyz123456", + "REDIS_URL=redis://redis:6379/0", + "ENABLE_EMAIL_DELIVERY=false", + "ENABLE_REDIS_SESSIONS=true", + "OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com", + "OSS_ACCESS_KEY_ID=ak-real", + "OSS_ACCESS_KEY_SECRET=sk-real-secret", + "OSS_BUCKET_NAME=xiaoxia-prod", + "GENERATED_FILES_HOST_DIR=/var/lib/xiaoxia-saas-production/generated", + "DEBUG=false", + "AUTO_CREATE_SCHEMA=false", + ] + ), + encoding="utf-8", + ) + + assert validate(parse_env_file(env_file), strict_external=True) == [] + + def test_validate_release_env_rejects_placeholders_and_staging_paths(tmp_path: Path): env_file = tmp_path / ".env.production" env_file.write_text(