147 lines
4.4 KiB
Python
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())
|