validators.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. """Semantic validators for template content.
  2. This module provides validators for specific file types and formats,
  3. enabling semantic validation beyond Jinja2 syntax checking.
  4. """
  5. from __future__ import annotations
  6. import logging
  7. from abc import ABC, abstractmethod
  8. from pathlib import Path
  9. from typing import TYPE_CHECKING, Any, ClassVar
  10. if TYPE_CHECKING:
  11. pass
  12. import yaml
  13. from .display import DisplayManager
  14. logger = logging.getLogger(__name__)
  15. class ValidationResult:
  16. """Represents the result of a validation operation."""
  17. def __init__(self):
  18. self.errors: list[str] = []
  19. self.warnings: list[str] = []
  20. self.info: list[str] = []
  21. def add_error(self, message: str) -> None:
  22. """Add an error message."""
  23. self.errors.append(message)
  24. logger.error(f"Validation error: {message}")
  25. def add_warning(self, message: str) -> None:
  26. """Add a warning message."""
  27. self.warnings.append(message)
  28. logger.warning(f"Validation warning: {message}")
  29. def add_info(self, message: str) -> None:
  30. """Add an info message."""
  31. self.info.append(message)
  32. logger.info(f"Validation info: {message}")
  33. @property
  34. def is_valid(self) -> bool:
  35. """Check if validation passed (no errors)."""
  36. return len(self.errors) == 0
  37. @property
  38. def has_warnings(self) -> bool:
  39. """Check if validation has warnings."""
  40. return len(self.warnings) > 0
  41. def display(self, context: str = "Validation") -> None:
  42. """Display validation results using DisplayManager."""
  43. display = DisplayManager()
  44. if self.errors:
  45. display.error(f"\n✗ {context} Failed:")
  46. for error in self.errors:
  47. display.error(f" • {error}")
  48. if self.warnings:
  49. display.warning(f"\n⚠ {context} Warnings:")
  50. for warning in self.warnings:
  51. display.warning(f" • {warning}")
  52. if self.info:
  53. display.text(f"\n[blue]i {context} Info:[/blue]")
  54. for info_msg in self.info:
  55. display.text(f" [blue]• {info_msg}[/blue]")
  56. if self.is_valid and not self.has_warnings:
  57. display.text(f"\n[green]✓ {context} Passed[/green]")
  58. class ContentValidator(ABC):
  59. """Abstract base class for content validators."""
  60. @abstractmethod
  61. def validate(self, content: str, file_path: str) -> ValidationResult:
  62. """Validate content and return results.
  63. Args:
  64. content: The file content to validate
  65. file_path: Path to the file (for error messages)
  66. Returns:
  67. ValidationResult with errors, warnings, and info
  68. """
  69. pass
  70. @abstractmethod
  71. def can_validate(self, file_path: str) -> bool:
  72. """Check if this validator can validate the given file.
  73. Args:
  74. file_path: Path to the file
  75. Returns:
  76. True if this validator can handle the file
  77. """
  78. pass
  79. class DockerComposeValidator(ContentValidator):
  80. """Validator for Docker Compose files."""
  81. COMPOSE_FILENAMES: ClassVar[set[str]] = {
  82. "docker-compose.yml",
  83. "docker-compose.yaml",
  84. "compose.yml",
  85. "compose.yaml",
  86. }
  87. def can_validate(self, file_path: str) -> bool:
  88. """Check if file is a Docker Compose file."""
  89. filename = Path(file_path).name.lower()
  90. return filename in self.COMPOSE_FILENAMES
  91. def validate(self, content: str, file_path: str) -> ValidationResult:
  92. """Validate Docker Compose file structure."""
  93. result = ValidationResult()
  94. try:
  95. # Parse YAML
  96. data = yaml.safe_load(content)
  97. if not isinstance(data, dict):
  98. result.add_error("Docker Compose file must be a YAML dictionary")
  99. return result
  100. # Check for version (optional in Compose v2, but good practice)
  101. if "version" not in data:
  102. result.add_info(
  103. "No 'version' field specified (using Compose v2 format)"
  104. )
  105. # Check for services (required)
  106. if "services" not in data:
  107. result.add_error("Missing required 'services' section")
  108. return result
  109. services = data.get("services", {})
  110. if not isinstance(services, dict):
  111. result.add_error("'services' must be a dictionary")
  112. return result
  113. if not services:
  114. result.add_warning("No services defined")
  115. # Validate each service
  116. for service_name, service_config in services.items():
  117. self._validate_service(service_name, service_config, result)
  118. # Check for networks (optional but recommended)
  119. if "networks" in data:
  120. networks = data.get("networks", {})
  121. if networks and isinstance(networks, dict):
  122. result.add_info(f"Defines {len(networks)} network(s)")
  123. # Check for volumes (optional)
  124. if "volumes" in data:
  125. volumes = data.get("volumes", {})
  126. if volumes and isinstance(volumes, dict):
  127. result.add_info(f"Defines {len(volumes)} volume(s)")
  128. except yaml.YAMLError as e:
  129. result.add_error(f"YAML parsing error: {e}")
  130. except Exception as e:
  131. result.add_error(f"Unexpected validation error: {e}")
  132. return result
  133. def _validate_service(
  134. self, name: str, config: Any, result: ValidationResult
  135. ) -> None:
  136. """Validate a single service configuration."""
  137. if not isinstance(config, dict):
  138. result.add_error(f"Service '{name}': configuration must be a dictionary")
  139. return
  140. # Check for image or build (at least one required)
  141. has_image = "image" in config
  142. has_build = "build" in config
  143. if not has_image and not has_build:
  144. result.add_error(f"Service '{name}': must specify 'image' or 'build'")
  145. # Warn about common misconfigurations
  146. if "restart" in config:
  147. restart_value = config["restart"]
  148. valid_restart_policies = ["no", "always", "on-failure", "unless-stopped"]
  149. if restart_value not in valid_restart_policies:
  150. result.add_warning(
  151. f"Service '{name}': restart policy '{restart_value}' may be invalid. "
  152. f"Valid values: {', '.join(valid_restart_policies)}"
  153. )
  154. # Check for environment variables
  155. if "environment" in config:
  156. env = config["environment"]
  157. if isinstance(env, list):
  158. # Check for duplicate keys in list format
  159. keys = [e.split("=")[0] for e in env if isinstance(e, str) and "=" in e]
  160. duplicates = {k for k in keys if keys.count(k) > 1}
  161. if duplicates:
  162. result.add_warning(
  163. f"Service '{name}': duplicate environment variables: {', '.join(duplicates)}"
  164. )
  165. # Check for ports
  166. if "ports" in config:
  167. ports = config["ports"]
  168. if not isinstance(ports, list):
  169. result.add_warning(f"Service '{name}': 'ports' should be a list")
  170. class YAMLValidator(ContentValidator):
  171. """Basic YAML syntax validator."""
  172. def can_validate(self, file_path: str) -> bool:
  173. """Check if file is a YAML file."""
  174. return Path(file_path).suffix.lower() in [".yml", ".yaml"]
  175. def validate(self, content: str, file_path: str) -> ValidationResult:
  176. """Validate YAML syntax."""
  177. result = ValidationResult()
  178. try:
  179. yaml.safe_load(content)
  180. result.add_info("YAML syntax is valid")
  181. except yaml.YAMLError as e:
  182. result.add_error(f"YAML parsing error: {e}")
  183. return result
  184. class ValidatorRegistry:
  185. """Registry for content validators."""
  186. def __init__(self):
  187. self.validators: list[ContentValidator] = []
  188. self._register_default_validators()
  189. def _register_default_validators(self) -> None:
  190. """Register built-in validators."""
  191. self.register(DockerComposeValidator())
  192. self.register(YAMLValidator())
  193. def register(self, validator: ContentValidator) -> None:
  194. """Register a validator.
  195. Args:
  196. validator: The validator to register
  197. """
  198. self.validators.append(validator)
  199. logger.debug(f"Registered validator: {validator.__class__.__name__}")
  200. def get_validator(self, file_path: str) -> ContentValidator | None:
  201. """Get the most appropriate validator for a file.
  202. Args:
  203. file_path: Path to the file
  204. Returns:
  205. ContentValidator if found, None otherwise
  206. """
  207. # Try specific validators first (e.g., DockerComposeValidator before YAMLValidator)
  208. for validator in self.validators:
  209. if validator.can_validate(file_path):
  210. return validator
  211. return None
  212. def validate_file(self, content: str, file_path: str) -> ValidationResult:
  213. """Validate file content using appropriate validator.
  214. Args:
  215. content: The file content
  216. file_path: Path to the file
  217. Returns:
  218. ValidationResult with validation results
  219. """
  220. validator = self.get_validator(file_path)
  221. if validator:
  222. logger.debug(f"Validating {file_path} with {validator.__class__.__name__}")
  223. return validator.validate(content, file_path)
  224. # No validator found - return empty result
  225. result = ValidationResult()
  226. result.add_info(
  227. f"No semantic validator available for {Path(file_path).suffix} files"
  228. )
  229. return result
  230. # Global registry instance
  231. _registry = ValidatorRegistry()
  232. def get_validator_registry() -> ValidatorRegistry:
  233. """Get the global validator registry."""
  234. return _registry