From 6d68c28860e75762b04d2dca3bf5b61f5e6edb5e Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Sun, 21 Jun 2026 12:34:51 +0800 Subject: [PATCH] chore(release): validate production env readiness --- .gitea/workflows/ci-cd.yml | 6 +- .github/workflows/ci-cd.yml | 6 +- docs/DEPLOYMENT.md | 1 + docs/PRODUCTION-RELEASE-CHECKLIST.md | 2 + infra/docker/compose.yml | 2 +- scripts/validate_release_env.py | 131 ++++++++++++++++++++++++ tests/unit/test_validate_release_env.py | 61 +++++++++++ 7 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 scripts/validate_release_env.py create mode 100644 tests/unit/test_validate_release_env.py diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index b0aa91c10..af380b82e 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -61,9 +61,9 @@ jobs: - name: Run code quality checks run: | - python -m compileall -q alembic apps packages tests scripts/check_schema_metadata.py - python -m black --check alembic apps packages tests scripts/check_schema_metadata.py - python -m isort --check-only alembic apps packages tests scripts/check_schema_metadata.py + python -m compileall -q alembic apps packages tests scripts + python -m black --check alembic apps packages tests scripts + python -m isort --check-only alembic apps packages tests scripts python -m flake8 apps packages tests --count --statistics bandit -r apps packages -q diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index b0aa91c10..af380b82e 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -61,9 +61,9 @@ jobs: - name: Run code quality checks run: | - python -m compileall -q alembic apps packages tests scripts/check_schema_metadata.py - python -m black --check alembic apps packages tests scripts/check_schema_metadata.py - python -m isort --check-only alembic apps packages tests scripts/check_schema_metadata.py + python -m compileall -q alembic apps packages tests scripts + python -m black --check alembic apps packages tests scripts + python -m isort --check-only alembic apps packages tests scripts python -m flake8 apps packages tests --count --statistics bandit -r apps packages -q diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index ec95a4041..b2672e68b 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -81,6 +81,7 @@ Staging 当前可以保持 no-op;Production 开启前必须先验证 SMTP/Redi ## 生成文件存储与保留 - Staging 未配置 OSS 凭证时,生成视频落盘到 `/var/lib/xiaoxia-saas-staging/generated`,并通过 Nginx `/generated-files/` 公开访问。 +- Docker volume host path 由 `GENERATED_FILES_HOST_DIR` 控制,默认仅适用于 staging:`/var/lib/xiaoxia-saas-staging/generated`。 - Production 优先使用 OSS;若临时启用本地 fallback,必须配置独立持久化目录、Nginx 只读公开路径和磁盘告警。 - 保留策略建议:staging 生成文件保留 7 天或保留最近 20GB;production 按业务套餐/订单状态定义,禁止无上限增长。 - 清理脚本上线前必须先 dry-run 输出待删列表,再按 workspace/project 维度删除,避免误删仍被 GeneratedVideo 记录引用的文件。 diff --git a/docs/PRODUCTION-RELEASE-CHECKLIST.md b/docs/PRODUCTION-RELEASE-CHECKLIST.md index 7f777feab..605f2ba28 100644 --- a/docs/PRODUCTION-RELEASE-CHECKLIST.md +++ b/docs/PRODUCTION-RELEASE-CHECKLIST.md @@ -20,6 +20,8 @@ - `DATABASE_URL` 指向生产数据库。 - `REDIS_URL` 指向生产 Redis。 - OSS 配置已确认或明确保持本地 fallback。 +- `GENERATED_FILES_HOST_DIR` 生产环境不得指向 staging 目录。 +- 本地校验:`python scripts/validate_release_env.py /var/lib/xiaoxia-saas-production/.env --strict-external`。 - 如开启邮件/session: - `ENABLE_EMAIL_DELIVERY=true` 前先验证 SMTP 凭证。 - `ENABLE_REDIS_SESSIONS=true` 前先验证 Redis 连通性。 diff --git a/infra/docker/compose.yml b/infra/docker/compose.yml index b53ec1f32..665c25e51 100644 --- a/infra/docker/compose.yml +++ b/infra/docker/compose.yml @@ -68,7 +68,7 @@ volumes: driver_opts: type: none o: bind - device: /var/lib/xiaoxia-saas-staging/generated + device: ${GENERATED_FILES_HOST_DIR:-/var/lib/xiaoxia-saas-staging/generated} networks: xiaoxia-net: diff --git a/scripts/validate_release_env.py b/scripts/validate_release_env.py new file mode 100644 index 000000000..37ba0ad5c --- /dev/null +++ b/scripts/validate_release_env.py @@ -0,0 +1,131 @@ +"""Validate production/staging environment files before release.""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +PLACEHOLDER_PATTERNS = ( + "CHANGE_ME", + "your-", + "your_", + "example.com", + "localhost", + "minioadmin", +) + +REQUIRED_ALWAYS = ( + "APP_ENV", + "DATABASE_URL", + "JWT_SECRET_KEY", + "REDIS_URL", +) + +REQUIRED_SMTP = ( + "SMTP_HOST", + "SMTP_PORT", + "SMTP_USER", + "SMTP_PASSWORD", + "SMTP_FROM_EMAIL", + "SMTP_FROM_NAME", +) + +REQUIRED_OSS = ( + "OSS_ENDPOINT", + "OSS_ACCESS_KEY_ID", + "OSS_ACCESS_KEY_SECRET", + "OSS_BUCKET_NAME", +) + + +def parse_env_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + raise ValueError(f"{path}:{line_number}: invalid env line without '='") + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"').strip("'") + return values + + +def is_enabled(value: str | None) -> bool: + return str(value or "").lower() in {"1", "true", "yes", "on"} + + +def has_placeholder(value: str) -> bool: + lowered = value.lower() + return any(pattern.lower() in lowered for pattern in PLACEHOLDER_PATTERNS) + + +def validate(values: dict[str, str], strict_external: bool) -> list[str]: + errors: list[str] = [] + + for key in REQUIRED_ALWAYS: + if not values.get(key): + errors.append(f"missing required env: {key}") + + app_env = values.get("APP_ENV") or values.get("ENVIRONMENT") + if app_env not in {"staging", "production"}: + errors.append("APP_ENV must be staging or production") + + jwt_secret = values.get("JWT_SECRET_KEY", "") + if len(jwt_secret) < 32: + errors.append("JWT_SECRET_KEY must be at least 32 characters") + + if is_enabled(values.get("AUTO_CREATE_SCHEMA")): + errors.append("AUTO_CREATE_SCHEMA must not be enabled outside development") + + if values.get("DEBUG", "false").lower() == "true" and app_env == "production": + errors.append("DEBUG must be false in production") + + external_requirements = [] + if strict_external or is_enabled(values.get("ENABLE_EMAIL_DELIVERY")): + external_requirements.extend(REQUIRED_SMTP) + if strict_external: + external_requirements.extend(REQUIRED_OSS) + if strict_external or is_enabled(values.get("ENABLE_REDIS_SESSIONS")): + external_requirements.append("REDIS_URL") + + for key in sorted(set(external_requirements)): + if not values.get(key): + errors.append(f"missing external-service env: {key}") + + secret_like = re.compile(r"(SECRET|PASSWORD|TOKEN|KEY)") + for key, value in values.items(): + if value and has_placeholder(value) and (strict_external or secret_like.search(key)): + errors.append(f"placeholder value remains in {key}") + + if values.get("GENERATED_FILES_HOST_DIR") and app_env == "production": + if "staging" in values["GENERATED_FILES_HOST_DIR"]: + errors.append("GENERATED_FILES_HOST_DIR must not point at staging in production") + + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("env_file", type=Path) + parser.add_argument( + "--strict-external", + action="store_true", + help="Require SMTP, Redis sessions, and OSS credentials for production readiness.", + ) + args = parser.parse_args() + + values = parse_env_file(args.env_file) + errors = validate(values, args.strict_external) + if errors: + for error in errors: + print(f"ERROR: {error}") + return 1 + + print(f"OK: {args.env_file} passed release env validation") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_validate_release_env.py b/tests/unit/test_validate_release_env.py new file mode 100644 index 000000000..35054d517 --- /dev/null +++ b/tests/unit/test_validate_release_env.py @@ -0,0 +1,61 @@ +from pathlib import Path + +from scripts.validate_release_env import parse_env_file, validate + + +def test_validate_release_env_accepts_strict_production(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=true", + "ENABLE_REDIS_SESSIONS=true", + "SMTP_HOST=smtp.example.internal", + "SMTP_PORT=587", + "SMTP_USER=mailer", + "SMTP_PASSWORD=strong-password", + "SMTP_FROM_EMAIL=noreply@xiaoxia.local", + "SMTP_FROM_NAME=Xiaoxia", + "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( + "\n".join( + [ + "APP_ENV=production", + "DATABASE_URL=postgresql+psycopg://user:pass@db:5432/app", + "JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars", + "REDIS_URL=redis://redis:6379/0", + "GENERATED_FILES_HOST_DIR=/var/lib/xiaoxia-saas-staging/generated", + "DEBUG=true", + "AUTO_CREATE_SCHEMA=true", + ] + ), + encoding="utf-8", + ) + + errors = validate(parse_env_file(env_file), strict_external=True) + + assert "placeholder value remains in JWT_SECRET_KEY" in errors + assert "GENERATED_FILES_HOST_DIR must not point at staging in production" in errors + assert "DEBUG must be false in production" in errors + assert "AUTO_CREATE_SCHEMA must not be enabled outside development" in errors + assert "missing external-service env: OSS_ACCESS_KEY_ID" in errors