chore(release): validate production env readiness
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 记录引用的文件。
|
||||
|
||||
@@ -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 连通性。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user