validate.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. """Docker Compose validation functionality."""
  2. from __future__ import annotations
  3. import logging
  4. import subprocess
  5. import tempfile
  6. from pathlib import Path
  7. from typer import Exit
  8. from ...core.template import Template
  9. logger = logging.getLogger(__name__)
  10. def run_docker_validation(
  11. module_instance,
  12. template_id: str | None,
  13. path: str | None,
  14. test_all: bool,
  15. verbose: bool,
  16. ) -> None:
  17. """Run Docker Compose validation using docker compose config.
  18. Args:
  19. module_instance: The module instance (for display and template loading)
  20. template_id: Template ID to validate
  21. path: Path to template directory
  22. test_all: Test all variable combinations
  23. verbose: Show detailed output
  24. Raises:
  25. Exit: If validation fails or docker is not available
  26. """
  27. try:
  28. # Load the template
  29. if path:
  30. template_path = Path(path).resolve()
  31. template = Template(template_path, library_name="local")
  32. else:
  33. template = module_instance._load_template_by_id(template_id)
  34. module_instance.display.info("")
  35. module_instance.display.info("Running Docker Compose validation...")
  36. # Test multiple combinations or single configuration
  37. if test_all:
  38. _test_variable_combinations(module_instance, template, verbose)
  39. else:
  40. # Single configuration with template defaults
  41. success = _validate_compose_files(
  42. module_instance, template, template.variables, verbose, "Template defaults"
  43. )
  44. if success:
  45. module_instance.display.success("Docker Compose validation passed")
  46. else:
  47. module_instance.display.error("Docker Compose validation failed")
  48. raise Exit(code=1) from None
  49. except FileNotFoundError as e:
  50. module_instance.display.error(
  51. "Docker Compose CLI not found",
  52. context="Install Docker Desktop or Docker Engine with Compose plugin",
  53. )
  54. raise Exit(code=1) from e
  55. except Exception as e:
  56. module_instance.display.error(f"Docker validation failed: {e}")
  57. raise Exit(code=1) from e
  58. def _validate_compose_files(module_instance, template, variables, verbose: bool, config_name: str) -> bool:
  59. """Validate rendered compose files using docker compose config.
  60. Args:
  61. module_instance: The module instance
  62. template: The template object
  63. variables: VariableCollection with configured values
  64. verbose: Show detailed output
  65. config_name: Name of this configuration (for display)
  66. Returns:
  67. True if validation passed, False otherwise
  68. """
  69. try:
  70. # Render the template
  71. debug_mode = logger.isEnabledFor(logging.DEBUG)
  72. rendered_files, _ = template.render(variables, debug=debug_mode)
  73. # Find compose files
  74. compose_files = [
  75. (filename, content)
  76. for filename, content in rendered_files.items()
  77. if filename.endswith(("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"))
  78. ]
  79. if not compose_files:
  80. module_instance.display.warning(f"[{config_name}] No Docker Compose files found")
  81. return True
  82. # Validate each compose file
  83. has_errors = False
  84. for filename, content in compose_files:
  85. if verbose:
  86. module_instance.display.info(f"[{config_name}] Validating: {filename}")
  87. # Write to temporary file
  88. with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp_file:
  89. tmp_file.write(content)
  90. tmp_path = tmp_file.name
  91. try:
  92. # Run docker compose config
  93. result = subprocess.run(
  94. ["docker", "compose", "-f", tmp_path, "config", "--quiet"],
  95. capture_output=True,
  96. text=True,
  97. check=False,
  98. )
  99. if result.returncode != 0:
  100. has_errors = True
  101. module_instance.display.error(f"[{config_name}] Docker validation failed for {filename}")
  102. if result.stderr:
  103. module_instance.display.info(f"\n{result.stderr}")
  104. elif verbose:
  105. module_instance.display.success(f"[{config_name}] Docker validation passed: {filename}")
  106. finally:
  107. # Clean up temporary file
  108. Path(tmp_path).unlink(missing_ok=True)
  109. return not has_errors
  110. except Exception as e:
  111. module_instance.display.error(f"[{config_name}] Validation failed: {e}")
  112. return False
  113. def _test_variable_combinations(module_instance, template, verbose: bool) -> None:
  114. """Test multiple variable combinations intelligently.
  115. Tests:
  116. 1. Minimal config (all toggles OFF)
  117. 2. Maximal config (all toggles ON)
  118. 3. Each toggle individually ON (to isolate toggle-specific issues)
  119. Args:
  120. module_instance: The module instance
  121. template: The template object
  122. verbose: Show detailed output
  123. Raises:
  124. Exit: If any validation fails
  125. """
  126. module_instance.display.info("Testing multiple variable combinations...")
  127. module_instance.display.info("")
  128. # Find all boolean toggle variables
  129. toggle_vars = _find_toggle_variables(template)
  130. if not toggle_vars:
  131. module_instance.display.warning("No toggle variables found - testing default configuration only")
  132. success = _validate_compose_files(module_instance, template, template.variables, verbose, "Default")
  133. if not success:
  134. raise Exit(code=1) from None
  135. module_instance.display.success("Docker Compose validation passed")
  136. return
  137. module_instance.display.info(f"Found {len(toggle_vars)} toggle variable(s): {', '.join(toggle_vars)}")
  138. module_instance.display.info("")
  139. all_passed = True
  140. test_count = 0
  141. # Test 1: Minimal (all OFF)
  142. module_instance.display.info("[1/3] Testing minimal configuration (all toggles OFF)...")
  143. toggle_config = dict.fromkeys(toggle_vars, False)
  144. variables = _get_variables_with_toggles(module_instance, template, toggle_config)
  145. if not _validate_compose_files(module_instance, template, variables, verbose, "Minimal"):
  146. all_passed = False
  147. test_count += 1
  148. module_instance.display.info("")
  149. # Test 2: Maximal (all ON)
  150. module_instance.display.info("[2/3] Testing maximal configuration (all toggles ON)...")
  151. toggle_config = dict.fromkeys(toggle_vars, True)
  152. variables = _get_variables_with_toggles(module_instance, template, toggle_config)
  153. if not _validate_compose_files(module_instance, template, variables, verbose, "Maximal"):
  154. all_passed = False
  155. test_count += 1
  156. module_instance.display.info("")
  157. # Test 3: Each toggle individually
  158. module_instance.display.info(f"[3/3] Testing each toggle individually ({len(toggle_vars)} tests)...")
  159. for i, toggle in enumerate(toggle_vars, 1):
  160. # Set all OFF except the current one
  161. toggle_config = {t: t == toggle for t in toggle_vars}
  162. variables = _get_variables_with_toggles(module_instance, template, toggle_config)
  163. config_name = f"{toggle}=true"
  164. if not _validate_compose_files(module_instance, template, variables, verbose, config_name):
  165. all_passed = False
  166. test_count += 1
  167. if verbose and i < len(toggle_vars):
  168. module_instance.display.info("")
  169. # Summary
  170. module_instance.display.info("")
  171. module_instance.display.info("─" * 80)
  172. if all_passed:
  173. module_instance.display.success(f"All {test_count} configuration(s) passed Docker Compose validation")
  174. else:
  175. module_instance.display.error("Some configurations failed Docker Compose validation")
  176. raise Exit(code=1) from None
  177. def _find_toggle_variables(template) -> list[str]:
  178. """Find all boolean toggle variables in a template.
  179. Args:
  180. template: The template object
  181. Returns:
  182. List of toggle variable names
  183. """
  184. toggle_vars = []
  185. for var_name, var in template.variables._variable_map.items():
  186. if var.type == "bool" and var_name.endswith("_enabled"):
  187. toggle_vars.append(var_name)
  188. return sorted(toggle_vars)
  189. def _get_variables_with_toggles(module_instance, template, toggle_config: dict[str, bool]): # noqa: ARG001
  190. """Get VariableCollection with specific toggle settings.
  191. Args:
  192. module_instance: The module instance (unused, for signature consistency)
  193. template: The template object
  194. toggle_config: Dict mapping toggle names to boolean values
  195. Returns:
  196. VariableCollection with configured toggle values
  197. """
  198. # Reload template to get fresh VariableCollection
  199. # (template.variables is mutated by previous calls)
  200. fresh_template = Template(template.template_dir, library_name=template.metadata.library)
  201. variables = fresh_template.variables
  202. # Apply toggle configuration
  203. for toggle_name, toggle_value in toggle_config.items():
  204. if toggle_name in variables._variable_map:
  205. variables._variable_map[toggle_name].value = toggle_value
  206. return variables