|
|
@@ -1,252 +1,88 @@
|
|
|
"""Docker Compose validation functionality."""
|
|
|
|
|
|
import logging
|
|
|
+import shutil
|
|
|
import subprocess
|
|
|
import tempfile
|
|
|
from pathlib import Path
|
|
|
|
|
|
-from typer import Exit
|
|
|
-
|
|
|
-from ...core.template import Template
|
|
|
+from ...core.validation import KindValidationFailure, KindValidationResult
|
|
|
|
|
|
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",
|
|
|
+class ComposeDockerValidator:
|
|
|
+ """Kind-specific validator backed by Docker Compose."""
|
|
|
+
|
|
|
+ validator_name = "docker compose config"
|
|
|
+ unavailable_message = "Required command is unavailable: docker compose"
|
|
|
+
|
|
|
+ def __init__(self, verbose: bool = False) -> None:
|
|
|
+ self.verbose = verbose
|
|
|
+ self._available: bool | None = None
|
|
|
+
|
|
|
+ def is_available(self) -> bool:
|
|
|
+ """Check whether Docker Compose is available locally."""
|
|
|
+ if self._available is not None:
|
|
|
+ return self._available
|
|
|
+
|
|
|
+ if shutil.which("docker") is None:
|
|
|
+ self._available = False
|
|
|
+ return self._available
|
|
|
+
|
|
|
+ result = subprocess.run(
|
|
|
+ ["docker", "compose", "version"],
|
|
|
+ capture_output=True,
|
|
|
+ text=True,
|
|
|
+ check=False,
|
|
|
)
|
|
|
- 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"))
|
|
|
- ]
|
|
|
+ self._available = result.returncode == 0
|
|
|
+ return self._available
|
|
|
|
|
|
+ def validate_rendered_files(self, rendered_files: dict[str, str], _case_name: str) -> KindValidationResult:
|
|
|
+ """Validate rendered Compose files with Docker Compose."""
|
|
|
+ result = KindValidationResult(validator=self.validator_name, available=self.is_available())
|
|
|
+ if not result.available:
|
|
|
+ result.details.append(self.unavailable_message)
|
|
|
+ return result
|
|
|
+
|
|
|
+ compose_files = _find_compose_files(rendered_files)
|
|
|
if not compose_files:
|
|
|
- module_instance.display.warning(f"[{config_name}] No Docker Compose files found")
|
|
|
- return True
|
|
|
+ result.warnings.append("No Docker Compose files found")
|
|
|
+ return result
|
|
|
|
|
|
- # 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
|
|
|
+ failure = self._validate_compose_content(filename, content)
|
|
|
+ if failure is not None:
|
|
|
+ result.failures.append(failure)
|
|
|
+
|
|
|
+ return result
|
|
|
+
|
|
|
+ def _validate_compose_content(self, filename: str, content: str) -> KindValidationFailure | None:
|
|
|
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp_file:
|
|
|
+ tmp_file.write(content)
|
|
|
+ tmp_path = tmp_file.name
|
|
|
+
|
|
|
+ try:
|
|
|
+ result = subprocess.run(
|
|
|
+ ["docker", "compose", "-f", tmp_path, "config", "--quiet"],
|
|
|
+ capture_output=True,
|
|
|
+ text=True,
|
|
|
+ check=False,
|
|
|
+ )
|
|
|
+ finally:
|
|
|
+ Path(tmp_path).unlink(missing_ok=True)
|
|
|
+
|
|
|
+ if result.returncode == 0:
|
|
|
+ return None
|
|
|
+
|
|
|
+ message = result.stderr.strip() or result.stdout.strip() or "Docker Compose validation failed"
|
|
|
+ return KindValidationFailure(file_path=filename, validator=self.validator_name, message=message)
|
|
|
+
|
|
|
+
|
|
|
+def _find_compose_files(rendered_files: dict[str, str]) -> list[tuple[str, str]]:
|
|
|
+ return [
|
|
|
+ (filename, content)
|
|
|
+ for filename, content in rendered_files.items()
|
|
|
+ if filename.endswith(("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"))
|
|
|
+ ]
|