Files
unitforge/scripts/validate_config.py
William Valentin f38e0c1276 fix: Resolve lint errors in validate_config.py
- Fix E501 line too long errors by breaking long lines appropriately
- Fix F541 f-string without placeholders error
- Add proper type annotations for mypy compatibility
- Suppress false positive bandit security warning for valid host check
- Apply black and isort formatting fixes

All flake8, mypy, black, isort, and bandit checks now pass.
2025-09-15 02:35:53 -07:00

538 lines
19 KiB
Python
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
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:
valid_options = valid_true + valid_false
self.errors.append(
f"{key}: Invalid boolean value '{value}'. " f"Use: {valid_options}"
)
return False
return True
def _validate_integer(
self, key: str, value: str, min_val: int = 0, max_val: Optional[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}\.?|"
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")
valid_hosts = ("0.0.0.0", "127.0.0.1", "localhost") # nosec B104
if host and host not in valid_hosts:
# 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"
)
example_keys = (
"your-secret-key-here",
"dev-secret-key-change-in-production",
)
if secret_key in example_keys:
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"
)
security_headers = self._get_env_value("SECURITY_HEADERS", "true").lower()
if security_headers 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("\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() -> None:
"""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()