| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252 |
- """Docker Compose validation functionality."""
- import logging
- import subprocess
- import tempfile
- from pathlib import Path
- from typer import Exit
- from ...core.template import Template
- logger = logging.getLogger(__name__)
- def run_docker_validation(
- module_instance,
- template_id: str | None,
- path: str | None,
- test_all: bool,
- verbose: bool,
- ) -> None:
- """Run Docker Compose validation using docker compose config.
- Args:
- module_instance: The module instance (for display and template loading)
- template_id: Template ID to validate
- path: Path to template directory
- test_all: Test all variable combinations
- verbose: Show detailed output
- Raises:
- Exit: If validation fails or docker is not available
- """
- try:
- # Load the template
- if path:
- template_path = Path(path).resolve()
- template = Template(template_path, library_name="local")
- else:
- template = module_instance._load_template_by_id(template_id)
- module_instance.display.info("")
- module_instance.display.info("Running Docker Compose validation...")
- # Test multiple combinations or single configuration
- if test_all:
- _test_variable_combinations(module_instance, template, verbose)
- else:
- # Single configuration with template defaults
- success = _validate_compose_files(
- module_instance, template, template.variables, verbose, "Template defaults"
- )
- if success:
- module_instance.display.success("Docker Compose validation passed")
- else:
- module_instance.display.error("Docker Compose validation failed")
- raise Exit(code=1) from None
- except FileNotFoundError as e:
- module_instance.display.error(
- "Docker Compose CLI not found",
- context="Install Docker Desktop or Docker Engine with Compose plugin",
- )
- raise Exit(code=1) from e
- except Exception as e:
- module_instance.display.error(f"Docker validation failed: {e}")
- raise Exit(code=1) from e
- def _validate_compose_files(module_instance, template, variables, verbose: bool, config_name: str) -> bool:
- """Validate rendered compose files using docker compose config.
- Args:
- module_instance: The module instance
- template: The template object
- variables: VariableCollection with configured values
- verbose: Show detailed output
- config_name: Name of this configuration (for display)
- Returns:
- True if validation passed, False otherwise
- """
- try:
- # Render the template
- debug_mode = logger.isEnabledFor(logging.DEBUG)
- rendered_files, _ = template.render(variables, debug=debug_mode)
- # Find compose files
- compose_files = [
- (filename, content)
- for filename, content in rendered_files.items()
- if filename.endswith(("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"))
- ]
- if not compose_files:
- module_instance.display.warning(f"[{config_name}] No Docker Compose files found")
- return True
- # Validate each compose file
- has_errors = False
- for filename, content in compose_files:
- if verbose:
- module_instance.display.info(f"[{config_name}] Validating: {filename}")
- # Write to temporary file
- with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp_file:
- tmp_file.write(content)
- tmp_path = tmp_file.name
- try:
- # Run docker compose config
- result = subprocess.run(
- ["docker", "compose", "-f", tmp_path, "config", "--quiet"],
- capture_output=True,
- text=True,
- check=False,
- )
- if result.returncode != 0:
- has_errors = True
- module_instance.display.error(f"[{config_name}] Docker validation failed for {filename}")
- if result.stderr:
- module_instance.display.info(f"\n{result.stderr}")
- elif verbose:
- module_instance.display.success(f"[{config_name}] Docker validation passed: {filename}")
- finally:
- # Clean up temporary file
- Path(tmp_path).unlink(missing_ok=True)
- return not has_errors
- except Exception as e:
- module_instance.display.error(f"[{config_name}] Validation failed: {e}")
- return False
- def _test_variable_combinations(module_instance, template, verbose: bool) -> None:
- """Test multiple variable combinations intelligently.
- Tests:
- 1. Minimal config (all toggles OFF)
- 2. Maximal config (all toggles ON)
- 3. Each toggle individually ON (to isolate toggle-specific issues)
- Args:
- module_instance: The module instance
- template: The template object
- verbose: Show detailed output
- Raises:
- Exit: If any validation fails
- """
- module_instance.display.info("Testing multiple variable combinations...")
- module_instance.display.info("")
- # Find all boolean toggle variables
- toggle_vars = _find_toggle_variables(template)
- if not toggle_vars:
- module_instance.display.warning("No toggle variables found - testing default configuration only")
- success = _validate_compose_files(module_instance, template, template.variables, verbose, "Default")
- if not success:
- raise Exit(code=1) from None
- module_instance.display.success("Docker Compose validation passed")
- return
- module_instance.display.info(f"Found {len(toggle_vars)} toggle variable(s): {', '.join(toggle_vars)}")
- module_instance.display.info("")
- all_passed = True
- test_count = 0
- # Test 1: Minimal (all OFF)
- module_instance.display.info("[1/3] Testing minimal configuration (all toggles OFF)...")
- toggle_config = dict.fromkeys(toggle_vars, False)
- variables = _get_variables_with_toggles(module_instance, template, toggle_config)
- if not _validate_compose_files(module_instance, template, variables, verbose, "Minimal"):
- all_passed = False
- test_count += 1
- module_instance.display.info("")
- # Test 2: Maximal (all ON)
- module_instance.display.info("[2/3] Testing maximal configuration (all toggles ON)...")
- toggle_config = dict.fromkeys(toggle_vars, True)
- variables = _get_variables_with_toggles(module_instance, template, toggle_config)
- if not _validate_compose_files(module_instance, template, variables, verbose, "Maximal"):
- all_passed = False
- test_count += 1
- module_instance.display.info("")
- # Test 3: Each toggle individually
- module_instance.display.info(f"[3/3] Testing each toggle individually ({len(toggle_vars)} tests)...")
- for i, toggle in enumerate(toggle_vars, 1):
- # Set all OFF except the current one
- toggle_config = {t: t == toggle for t in toggle_vars}
- variables = _get_variables_with_toggles(module_instance, template, toggle_config)
- config_name = f"{toggle}=true"
- if not _validate_compose_files(module_instance, template, variables, verbose, config_name):
- all_passed = False
- test_count += 1
- if verbose and i < len(toggle_vars):
- module_instance.display.info("")
- # Summary
- module_instance.display.info("")
- module_instance.display.info("─" * 80)
- if all_passed:
- module_instance.display.success(f"All {test_count} configuration(s) passed Docker Compose validation")
- else:
- module_instance.display.error("Some configurations failed Docker Compose validation")
- raise Exit(code=1) from None
- def _find_toggle_variables(template) -> list[str]:
- """Find all boolean toggle variables in a template.
- Args:
- template: The template object
- Returns:
- List of toggle variable names
- """
- toggle_vars = []
- for var_name, var in template.variables._variable_map.items():
- if var.type == "bool" and var_name.endswith("_enabled"):
- toggle_vars.append(var_name)
- return sorted(toggle_vars)
- def _get_variables_with_toggles(module_instance, template, toggle_config: dict[str, bool]): # noqa: ARG001
- """Get VariableCollection with specific toggle settings.
- Args:
- module_instance: The module instance (unused, for signature consistency)
- template: The template object
- toggle_config: Dict mapping toggle names to boolean values
- Returns:
- VariableCollection with configured toggle values
- """
- # Reload template to get fresh VariableCollection
- # (template.variables is mutated by previous calls)
- fresh_template = Template(template.template_dir, library_name=template.metadata.library)
- variables = fresh_template.variables
- # Apply toggle configuration
- for toggle_name, toggle_value in toggle_config.items():
- if toggle_name in variables._variable_map:
- variables._variable_map[toggle_name].value = toggle_value
- return variables
|