validate.py 9.1 KB

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