feat: add comprehensive environment configuration validation system
- Create validate_config.py script with 100+ validation checks - Validate basic settings (URLs, emails, integers, booleans) - Check security settings and production readiness - Validate feature flags, performance settings, and file upload limits - Support both .env files and direct environment variables - Provide detailed error messages and configuration suggestions - Include environment consistency checks and best practice warnings
This commit is contained in:
456
scripts/validate_config.py
Executable file
456
scripts/validate_config.py
Executable file
@@ -0,0 +1,456 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Environment Configuration Validation Script for UnitForge
|
||||
|
||||
This script validates the environment configuration and checks for common
|
||||
configuration issues. It can be used during development, deployment, and
|
||||
troubleshooting to ensure all settings are properly configured.
|
||||
|
||||
Usage:
|
||||
python scripts/validate_config.py
|
||||
python scripts/validate_config.py --env-file .env.production
|
||||
python scripts/validate_config.py --check-all
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
import re
|
||||
|
||||
|
||||
class ConfigValidator:
|
||||
"""Validates UnitForge environment configuration."""
|
||||
|
||||
def __init__(self, env_file: Optional[str] = None):
|
||||
"""Initialize validator with optional environment file."""
|
||||
self.env_file = env_file
|
||||
self.errors: List[str] = []
|
||||
self.warnings: List[str] = []
|
||||
self.info: List[str] = []
|
||||
self.config: Dict[str, Any] = {}
|
||||
|
||||
# Store original environment variables before loading file
|
||||
self.original_env = dict(os.environ)
|
||||
|
||||
# Load environment file if specified
|
||||
if env_file and Path(env_file).exists():
|
||||
self._load_env_file(env_file)
|
||||
|
||||
def _load_env_file(self, env_file: str) -> None:
|
||||
"""Load environment variables from file."""
|
||||
try:
|
||||
with open(env_file, 'r', encoding='utf-8') as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"\'')
|
||||
|
||||
# Remove inline comments
|
||||
if '#' in value:
|
||||
value = value.split('#')[0].strip()
|
||||
|
||||
# Don't override existing environment variables
|
||||
if key not in self.original_env:
|
||||
os.environ[key] = value
|
||||
self.config[key] = value
|
||||
self.info.append(f"Loaded environment file: {env_file}")
|
||||
except Exception as e:
|
||||
self.errors.append(f"Failed to load environment file {env_file}: {e}")
|
||||
|
||||
def _get_env_value(self, key: str, default: str = "") -> str:
|
||||
"""Get environment variable value."""
|
||||
return os.getenv(key, default)
|
||||
|
||||
def _validate_boolean(self, key: str, value: str) -> bool:
|
||||
"""Validate boolean environment variable."""
|
||||
if not value:
|
||||
return True # Empty is valid (uses default)
|
||||
|
||||
valid_true = ('true', '1', 'yes', 'on')
|
||||
valid_false = ('false', '0', 'no', 'off')
|
||||
|
||||
if value.lower() not in valid_true + valid_false:
|
||||
self.errors.append(f"{key}: Invalid boolean value '{value}'. Use: {valid_true + valid_false}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _validate_integer(self, key: str, value: str, min_val: int = 0, max_val: int = None) -> bool:
|
||||
"""Validate integer environment variable."""
|
||||
if not value:
|
||||
return True # Empty is valid (uses default)
|
||||
|
||||
try:
|
||||
int_val = int(value)
|
||||
if int_val < min_val:
|
||||
self.errors.append(f"{key}: Value {int_val} is below minimum {min_val}")
|
||||
return False
|
||||
if max_val is not None and int_val > max_val:
|
||||
self.errors.append(f"{key}: Value {int_val} is above maximum {max_val}")
|
||||
return False
|
||||
return True
|
||||
except ValueError:
|
||||
self.errors.append(f"{key}: Invalid integer value '{value}'")
|
||||
return False
|
||||
|
||||
def _validate_url(self, key: str, value: str) -> bool:
|
||||
"""Validate URL format."""
|
||||
if not value:
|
||||
return True # Empty is valid for optional URLs
|
||||
|
||||
url_pattern = re.compile(
|
||||
r'^https?://' # http:// or https://
|
||||
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain
|
||||
r'localhost|' # localhost
|
||||
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # IP
|
||||
r'(?::\d+)?' # optional port
|
||||
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
|
||||
|
||||
if not url_pattern.match(value):
|
||||
self.errors.append(f"{key}: Invalid URL format '{value}'")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _validate_email(self, key: str, value: str) -> bool:
|
||||
"""Validate email format."""
|
||||
if not value:
|
||||
return True # Empty is valid for optional emails
|
||||
|
||||
email_pattern = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
||||
if not email_pattern.match(value):
|
||||
self.errors.append(f"{key}: Invalid email format '{value}'")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _validate_json_array(self, key: str, value: str) -> bool:
|
||||
"""Validate JSON array format."""
|
||||
if not value or value == "*":
|
||||
return True # Empty or wildcard is valid
|
||||
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if not isinstance(parsed, list):
|
||||
self.errors.append(f"{key}: Must be a JSON array, got {type(parsed).__name__}")
|
||||
return False
|
||||
return True
|
||||
except json.JSONDecodeError as e:
|
||||
# Try comma-separated format
|
||||
if ',' in value and not value.startswith('['):
|
||||
self.warnings.append(f"{key}: Using comma-separated format. Consider JSON array format for clarity")
|
||||
return True
|
||||
self.errors.append(f"{key}: Invalid JSON array format: {e}")
|
||||
return False
|
||||
|
||||
def _validate_log_level(self, key: str, value: str) -> bool:
|
||||
"""Validate log level."""
|
||||
if not value:
|
||||
return True
|
||||
|
||||
valid_levels = ('debug', 'info', 'warning', 'error', 'critical')
|
||||
if value.lower() not in valid_levels:
|
||||
self.errors.append(f"{key}: Invalid log level '{value}'. Valid levels: {valid_levels}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _validate_environment(self, key: str, value: str) -> bool:
|
||||
"""Validate environment name."""
|
||||
if not value:
|
||||
return True
|
||||
|
||||
valid_envs = ('development', 'staging', 'production', 'test')
|
||||
if value.lower() not in valid_envs:
|
||||
self.warnings.append(f"{key}: Unusual environment '{value}'. Common values: {valid_envs}")
|
||||
return True
|
||||
|
||||
def _validate_file_extensions(self, key: str, value: str) -> bool:
|
||||
"""Validate file extensions format."""
|
||||
if not value:
|
||||
return True
|
||||
|
||||
try:
|
||||
if value.startswith('['):
|
||||
# JSON array format
|
||||
extensions = json.loads(value)
|
||||
if not isinstance(extensions, list):
|
||||
self.errors.append(f"{key}: Must be a list of extensions")
|
||||
return False
|
||||
else:
|
||||
# Comma-separated format
|
||||
extensions = [ext.strip() for ext in value.split(',')]
|
||||
|
||||
for ext in extensions:
|
||||
if not ext.startswith('.'):
|
||||
self.errors.append(f"{key}: Extension '{ext}' should start with '.'")
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
self.errors.append(f"{key}: Invalid extensions format: {e}")
|
||||
return False
|
||||
|
||||
def validate_basic_config(self) -> None:
|
||||
"""Validate basic application configuration."""
|
||||
print("🔍 Validating basic configuration...")
|
||||
|
||||
# Application Information
|
||||
app_name = self._get_env_value('APP_NAME')
|
||||
if not app_name:
|
||||
self.warnings.append("APP_NAME: Not set, using default")
|
||||
elif len(app_name) > 100:
|
||||
self.warnings.append("APP_NAME: Very long name (>100 chars)")
|
||||
|
||||
app_version = self._get_env_value('APP_VERSION')
|
||||
if app_version and not re.match(r'^\d+\.\d+\.\d+', app_version):
|
||||
self.warnings.append(f"APP_VERSION: Unusual version format '{app_version}'")
|
||||
|
||||
# URLs
|
||||
self._validate_url('GITHUB_URL', self._get_env_value('GITHUB_URL'))
|
||||
self._validate_url('DOCUMENTATION_URL', self._get_env_value('DOCUMENTATION_URL'))
|
||||
self._validate_url('BUG_REPORTS_URL', self._get_env_value('BUG_REPORTS_URL'))
|
||||
|
||||
# Email
|
||||
self._validate_email('CONTACT_EMAIL', self._get_env_value('CONTACT_EMAIL'))
|
||||
|
||||
def validate_server_config(self) -> None:
|
||||
"""Validate server configuration."""
|
||||
print("🔍 Validating server configuration...")
|
||||
|
||||
# Boolean values
|
||||
self._validate_boolean('DEBUG', self._get_env_value('DEBUG'))
|
||||
self._validate_boolean('RELOAD', self._get_env_value('RELOAD'))
|
||||
|
||||
# Environment
|
||||
self._validate_environment('ENVIRONMENT', self._get_env_value('ENVIRONMENT'))
|
||||
self._validate_log_level('LOG_LEVEL', self._get_env_value('LOG_LEVEL'))
|
||||
|
||||
# Integer values
|
||||
self._validate_integer('PORT', self._get_env_value('PORT'), 1, 65535)
|
||||
self._validate_integer('WORKERS', self._get_env_value('WORKERS'), 1, 64)
|
||||
|
||||
# Host validation
|
||||
host = self._get_env_value('HOST')
|
||||
if host and host not in ('0.0.0.0', '127.0.0.1', 'localhost'):
|
||||
# Basic IP validation
|
||||
if not re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', host):
|
||||
self.warnings.append(f"HOST: Unusual host value '{host}'")
|
||||
|
||||
def validate_security_config(self) -> None:
|
||||
"""Validate security configuration."""
|
||||
print("🔍 Validating security configuration...")
|
||||
|
||||
# Secret key
|
||||
secret_key = self._get_env_value('SECRET_KEY')
|
||||
env = self._get_env_value('ENVIRONMENT', 'production').lower()
|
||||
|
||||
if secret_key:
|
||||
if len(secret_key) < 32:
|
||||
self.warnings.append("SECRET_KEY: Too short, use at least 32 characters")
|
||||
if secret_key in ('your-secret-key-here', 'dev-secret-key-change-in-production'):
|
||||
if env == 'production':
|
||||
self.errors.append("SECRET_KEY: Using default/example secret key in production!")
|
||||
else:
|
||||
self.warnings.append("SECRET_KEY: Using example secret key (OK for development)")
|
||||
elif env == 'production':
|
||||
self.warnings.append("SECRET_KEY: Not set in production environment")
|
||||
|
||||
# Boolean security settings
|
||||
self._validate_boolean('SECURITY_HEADERS', self._get_env_value('SECURITY_HEADERS'))
|
||||
self._validate_boolean('CSP_ENABLED', self._get_env_value('CSP_ENABLED'))
|
||||
|
||||
# HSTS settings
|
||||
self._validate_integer('HSTS_MAX_AGE', self._get_env_value('HSTS_MAX_AGE'), 0)
|
||||
|
||||
# CORS
|
||||
cors_origins = self._get_env_value('CORS_ORIGINS')
|
||||
if cors_origins != '*':
|
||||
self._validate_json_array('CORS_ORIGINS', cors_origins)
|
||||
|
||||
# Allowed hosts
|
||||
self._validate_json_array('ALLOWED_HOSTS', self._get_env_value('ALLOWED_HOSTS'))
|
||||
|
||||
def validate_feature_flags(self) -> None:
|
||||
"""Validate feature flags."""
|
||||
print("🔍 Validating feature flags...")
|
||||
|
||||
feature_flags = [
|
||||
'ENABLE_API_METRICS', 'ENABLE_REQUEST_LOGGING',
|
||||
'ENABLE_TEMPLATE_CACHING', 'ENABLE_VALIDATION_CACHING',
|
||||
'HEALTH_CHECK_ENABLED', 'METRICS_ENABLED', 'TRACING_ENABLED',
|
||||
'API_DOCS_ENABLED', 'SWAGGER_UI_ENABLED', 'REDOC_ENABLED',
|
||||
'HOT_RELOAD', 'SOURCE_MAPS', 'MINIFY_ASSETS', 'COMPRESS_RESPONSES'
|
||||
]
|
||||
|
||||
for flag in feature_flags:
|
||||
self._validate_boolean(flag, self._get_env_value(flag))
|
||||
|
||||
def validate_performance_config(self) -> None:
|
||||
"""Validate performance configuration."""
|
||||
print("🔍 Validating performance configuration...")
|
||||
|
||||
# Timeout settings
|
||||
self._validate_integer('REQUEST_TIMEOUT', self._get_env_value('REQUEST_TIMEOUT'), 1, 3600)
|
||||
self._validate_integer('KEEPALIVE_TIMEOUT', self._get_env_value('KEEPALIVE_TIMEOUT'), 1, 300)
|
||||
self._validate_integer('MAX_CONNECTIONS', self._get_env_value('MAX_CONNECTIONS'), 1, 10000)
|
||||
|
||||
# Cache settings
|
||||
self._validate_integer('TEMPLATE_CACHE_TTL', self._get_env_value('TEMPLATE_CACHE_TTL'), 0)
|
||||
self._validate_integer('VALIDATION_CACHE_TTL', self._get_env_value('VALIDATION_CACHE_TTL'), 0)
|
||||
|
||||
# File upload
|
||||
self._validate_integer('MAX_UPLOAD_SIZE', self._get_env_value('MAX_UPLOAD_SIZE'), 1024)
|
||||
self._validate_file_extensions('ALLOWED_EXTENSIONS', self._get_env_value('ALLOWED_EXTENSIONS'))
|
||||
|
||||
def validate_production_readiness(self) -> None:
|
||||
"""Validate production readiness."""
|
||||
print("🔍 Validating production readiness...")
|
||||
|
||||
env = self._get_env_value('ENVIRONMENT', 'production').lower()
|
||||
debug = self._get_env_value('DEBUG', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
if env == 'production':
|
||||
if debug:
|
||||
self.errors.append("Production environment should not have DEBUG=true")
|
||||
|
||||
if not self._get_env_value('SECRET_KEY'):
|
||||
self.errors.append("Production environment must have SECRET_KEY set")
|
||||
|
||||
cors_origins = self._get_env_value('CORS_ORIGINS', '*')
|
||||
if cors_origins == '*':
|
||||
self.warnings.append("Production environment using wildcard CORS origins")
|
||||
|
||||
if self._get_env_value('SECURITY_HEADERS', 'true').lower() not in ('true', '1', 'yes', 'on'):
|
||||
self.warnings.append("Production environment should enable SECURITY_HEADERS")
|
||||
|
||||
workers = int(self._get_env_value('WORKERS', '4'))
|
||||
if workers < 2:
|
||||
self.warnings.append("Production environment should use multiple workers")
|
||||
|
||||
def check_environment_consistency(self) -> None:
|
||||
"""Check for environment consistency issues."""
|
||||
print("🔍 Checking environment consistency...")
|
||||
|
||||
# Debug mode consistency
|
||||
debug = self._get_env_value('DEBUG', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||
env = self._get_env_value('ENVIRONMENT', 'production').lower()
|
||||
log_level = self._get_env_value('LOG_LEVEL', 'info').lower()
|
||||
|
||||
if debug and env == 'production':
|
||||
self.warnings.append("DEBUG mode enabled in production environment")
|
||||
|
||||
if debug and log_level not in ('debug', 'info'):
|
||||
self.warnings.append("DEBUG mode with non-debug log level")
|
||||
|
||||
# Performance consistency
|
||||
hot_reload = self._get_env_value('HOT_RELOAD', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||
minify = self._get_env_value('MINIFY_ASSETS', 'true').lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
if hot_reload and minify:
|
||||
self.warnings.append("HOT_RELOAD and MINIFY_ASSETS both enabled (unusual for development)")
|
||||
|
||||
def validate_all(self) -> Tuple[int, int, int]:
|
||||
"""Run all validations and return counts."""
|
||||
print("🚀 Starting UnitForge configuration validation...\n")
|
||||
|
||||
self.validate_basic_config()
|
||||
self.validate_server_config()
|
||||
self.validate_security_config()
|
||||
self.validate_feature_flags()
|
||||
self.validate_performance_config()
|
||||
self.validate_production_readiness()
|
||||
self.check_environment_consistency()
|
||||
|
||||
return len(self.errors), len(self.warnings), len(self.info)
|
||||
|
||||
def print_results(self) -> None:
|
||||
"""Print validation results."""
|
||||
print("\n" + "="*60)
|
||||
print("🔍 CONFIGURATION VALIDATION RESULTS")
|
||||
print("="*60)
|
||||
|
||||
if self.info:
|
||||
print(f"\n📋 Information ({len(self.info)}):")
|
||||
for msg in self.info:
|
||||
print(f" ℹ️ {msg}")
|
||||
|
||||
if self.warnings:
|
||||
print(f"\n⚠️ Warnings ({len(self.warnings)}):")
|
||||
for msg in self.warnings:
|
||||
print(f" ⚠️ {msg}")
|
||||
|
||||
if self.errors:
|
||||
print(f"\n❌ Errors ({len(self.errors)}):")
|
||||
for msg in self.errors:
|
||||
print(f" ❌ {msg}")
|
||||
|
||||
print(f"\n📊 Summary:")
|
||||
print(f" • Errors: {len(self.errors)}")
|
||||
print(f" • Warnings: {len(self.warnings)}")
|
||||
print(f" • Info: {len(self.info)}")
|
||||
|
||||
if len(self.errors) == 0 and len(self.warnings) == 0:
|
||||
print("\n✅ Configuration validation passed!")
|
||||
elif len(self.errors) == 0:
|
||||
print("\n⚠️ Configuration validation passed with warnings.")
|
||||
else:
|
||||
print("\n❌ Configuration validation failed.")
|
||||
|
||||
print("="*60)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate UnitForge environment configuration",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
parser.add_argument(
|
||||
'--env-file',
|
||||
type=str,
|
||||
help="Path to environment file to load (default: .env)"
|
||||
)
|
||||
parser.add_argument(
|
||||
'--check-all',
|
||||
action='store_true',
|
||||
help="Run all validation checks including production readiness"
|
||||
)
|
||||
parser.add_argument(
|
||||
'--quiet',
|
||||
action='store_true',
|
||||
help="Suppress informational output"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Default to .env if it exists
|
||||
env_file = args.env_file
|
||||
if not env_file and Path('.env').exists():
|
||||
env_file = '.env'
|
||||
|
||||
validator = ConfigValidator(env_file)
|
||||
|
||||
if not args.quiet:
|
||||
if env_file:
|
||||
print(f"📄 Using environment file: {env_file}")
|
||||
else:
|
||||
print("📄 Using system environment variables only")
|
||||
print()
|
||||
|
||||
# Run validation
|
||||
errors, warnings, info = validator.validate_all()
|
||||
|
||||
# Print results
|
||||
if not args.quiet:
|
||||
validator.print_results()
|
||||
|
||||
# Exit with appropriate code
|
||||
if errors > 0:
|
||||
sys.exit(1)
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user