"""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())