diff --git a/scripts/validate_config.py b/scripts/validate_config.py new file mode 100755 index 0000000..6c808b5 --- /dev/null +++ b/scripts/validate_config.py @@ -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()