chore(release): validate production env readiness

This commit is contained in:
Xiaoxia AI
2026-06-21 12:34:51 +08:00
parent 46bd774742
commit 6d68c28860
7 changed files with 202 additions and 7 deletions
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
+1
View File
@@ -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 记录引用的文件。
+2
View File
@@ -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 连通性。
+1 -1
View File
@@ -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:
+131
View File
@@ -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())
+61
View File
@@ -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