validate.py 9.1 KB

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