Files
xiaoxia-saas/scripts/validate_release_env.py
T
2026-06-21 14:24:23 +08:00

147 lines
4.4 KiB
Python

"""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 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, nargs="?", help="Env file to validate. Omit with --from-environ.")
parser.add_argument(
"--from-environ",
action="store_true",
help="Validate the current process environment instead of reading an env file.",
)
parser.add_argument(
"--strict-external",
action="store_true",
help="Require Redis sessions and OSS credentials for production readiness. SMTP is required only when ENABLE_EMAIL_DELIVERY=true.",
)
args = parser.parse_args()
if args.from_environ:
import os
values = dict(os.environ)
source = "process environment"
else:
if args.env_file is None:
parser.error("env_file is required unless --from-environ is set")
values = parse_env_file(args.env_file)
source = str(args.env_file)
errors = validate(values, args.strict_external)
if errors:
for error in errors:
print(f"ERROR: {error}")
return 1
print(f"OK: {source} passed release env validation")
return 0
if __name__ == "__main__":
raise SystemExit(main())