mirror of
https://github.com/ChristianLempa/boilerplates.git
synced 2026-08-28 02:24:12 -05:00
263 lines
9.8 KiB
Python
263 lines
9.8 KiB
Python
"""Kind-specific validators for rendered templates."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from .validation_runner import KindValidationFailure, KindValidationResult
|
|
|
|
|
|
class RenderedFilesValidator:
|
|
"""Base class for validators that run CLI tools against rendered files."""
|
|
|
|
validator_name: str
|
|
unavailable_message: str
|
|
|
|
def __init__(self, verbose: bool = False) -> None:
|
|
self.verbose = verbose
|
|
self._available: bool | None = None
|
|
|
|
def command_available(self, command: str) -> bool:
|
|
return shutil.which(command) is not None
|
|
|
|
def validate_rendered_files(self, rendered_files: dict[str, str], case_name: str) -> KindValidationResult:
|
|
result = KindValidationResult(validator=self.validator_name, available=self.is_available())
|
|
if not result.available:
|
|
result.details.append(self.unavailable_message)
|
|
return result
|
|
|
|
with tempfile.TemporaryDirectory(prefix=f"boilerplates-{case_name[:20]}-") as tmp_dir:
|
|
workdir = Path(tmp_dir)
|
|
self._write_rendered_files(rendered_files, workdir)
|
|
return self.validate_directory(workdir)
|
|
|
|
def is_available(self) -> bool:
|
|
raise NotImplementedError
|
|
|
|
def validate_directory(self, workdir: Path) -> KindValidationResult:
|
|
raise NotImplementedError
|
|
|
|
@staticmethod
|
|
def _write_rendered_files(rendered_files: dict[str, str], workdir: Path) -> None:
|
|
for filename, content in rendered_files.items():
|
|
path = workdir / filename
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content, encoding="utf-8")
|
|
|
|
def run_command(
|
|
self,
|
|
args: list[str],
|
|
workdir: Path,
|
|
*,
|
|
env: dict[str, str] | None = None,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
args,
|
|
cwd=workdir,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
def failure_from_process(
|
|
self,
|
|
result: subprocess.CompletedProcess[str],
|
|
file_path: str = "",
|
|
) -> KindValidationFailure | None:
|
|
if result.returncode == 0:
|
|
return None
|
|
|
|
message = result.stderr.strip() or result.stdout.strip() or f"{self.validator_name} failed"
|
|
return KindValidationFailure(file_path=file_path, validator=self.validator_name, message=message)
|
|
|
|
|
|
class TerraformValidator(RenderedFilesValidator):
|
|
"""Validate Terraform/OpenTofu configurations."""
|
|
|
|
validator_name = "tofu validate"
|
|
unavailable_message = "Required command is unavailable: tofu or terraform"
|
|
|
|
def __init__(self, verbose: bool = False) -> None:
|
|
super().__init__(verbose)
|
|
self.command = "tofu" if self.command_available("tofu") else "terraform"
|
|
self.validator_name = f"{self.command} validate"
|
|
|
|
def is_available(self) -> bool:
|
|
if self._available is None:
|
|
self._available = self.command_available(self.command)
|
|
return self._available
|
|
|
|
def validate_directory(self, workdir: Path) -> KindValidationResult:
|
|
result = KindValidationResult(validator=self.validator_name)
|
|
init = self.run_command([self.command, "init", "-backend=false", "-input=false", "-no-color"], workdir)
|
|
failure = self.failure_from_process(init)
|
|
if failure is not None:
|
|
if self._is_provider_resolution_failure(failure.message):
|
|
result.skipped = True
|
|
result.warnings.append(failure.message)
|
|
else:
|
|
result.failures.append(failure)
|
|
return result
|
|
|
|
validate = self.run_command([self.command, "validate", "-no-color"], workdir)
|
|
failure = self.failure_from_process(validate)
|
|
if failure is not None:
|
|
result.failures.append(failure)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _is_provider_resolution_failure(message: str) -> bool:
|
|
return "Failed to resolve provider packages" in message or "could not connect to registry" in message
|
|
|
|
|
|
class KubernetesValidator(RenderedFilesValidator):
|
|
"""Validate Kubernetes manifests with kubectl client dry-run."""
|
|
|
|
validator_name = "kubectl create --dry-run=client"
|
|
unavailable_message = "Required command is unavailable: kubectl"
|
|
|
|
def is_available(self) -> bool:
|
|
if self._available is None:
|
|
self._available = self.command_available("kubectl")
|
|
return self._available
|
|
|
|
def validate_directory(self, workdir: Path) -> KindValidationResult:
|
|
result = KindValidationResult(validator=self.validator_name)
|
|
process = self.run_command(["kubectl", "create", "--dry-run=client", "--validate=false", "-f", "."], workdir)
|
|
failure = self.failure_from_process(process)
|
|
if failure is not None:
|
|
if self._is_cluster_discovery_failure(failure.message):
|
|
result.skipped = True
|
|
result.warnings.append(failure.message)
|
|
else:
|
|
result.failures.append(failure)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _is_cluster_discovery_failure(message: str) -> bool:
|
|
return "couldn't get current server API group list" in message or "unable to recognize" in message
|
|
|
|
|
|
class HelmValidator(RenderedFilesValidator):
|
|
"""Validate Helm chart files."""
|
|
|
|
validator_name = "helm lint"
|
|
unavailable_message = "Required command is unavailable: helm"
|
|
|
|
def is_available(self) -> bool:
|
|
if self._available is None:
|
|
self._available = self.command_available("helm")
|
|
return self._available
|
|
|
|
def validate_directory(self, workdir: Path) -> KindValidationResult:
|
|
result = KindValidationResult(validator=self.validator_name)
|
|
if not (workdir / "Chart.yaml").exists():
|
|
result.skipped = True
|
|
result.warnings.append("Rendered files do not include Chart.yaml")
|
|
return result
|
|
|
|
process = self.run_command(["helm", "lint", "."], workdir)
|
|
failure = self.failure_from_process(process)
|
|
if failure is not None:
|
|
result.failures.append(failure)
|
|
return result
|
|
|
|
|
|
class PackerValidator(RenderedFilesValidator):
|
|
"""Validate Packer templates."""
|
|
|
|
validator_name = "packer validate"
|
|
unavailable_message = "Required command is unavailable: packer"
|
|
|
|
def is_available(self) -> bool:
|
|
if self._available is None:
|
|
self._available = self.command_available("packer")
|
|
return self._available
|
|
|
|
def validate_directory(self, workdir: Path) -> KindValidationResult:
|
|
result = KindValidationResult(validator=self.validator_name)
|
|
if list(workdir.glob("*.pkr.hcl")):
|
|
target = "."
|
|
else:
|
|
candidates = sorted(path for path in workdir.rglob("*") if path.is_file() and path.suffix == ".json")
|
|
if not candidates:
|
|
result.skipped = True
|
|
result.warnings.append("No Packer template files found")
|
|
return result
|
|
target = str(candidates[0].relative_to(workdir))
|
|
|
|
process = self.run_command(["packer", "validate", target], workdir)
|
|
failure = self.failure_from_process(process)
|
|
if failure is not None:
|
|
result.failures.append(failure)
|
|
return result
|
|
|
|
|
|
class AnsibleValidator(RenderedFilesValidator):
|
|
"""Validate Ansible playbooks with syntax-check."""
|
|
|
|
validator_name = "ansible-playbook --syntax-check"
|
|
unavailable_message = "Required command is unavailable: ansible-playbook"
|
|
|
|
def is_available(self) -> bool:
|
|
if self._available is None:
|
|
self._available = self.command_available("ansible-playbook")
|
|
return self._available
|
|
|
|
def validate_directory(self, workdir: Path) -> KindValidationResult:
|
|
result = KindValidationResult(validator=self.validator_name)
|
|
playbooks = self._find_playbooks(workdir)
|
|
if not playbooks:
|
|
result.skipped = True
|
|
result.warnings.append("No Ansible playbooks found")
|
|
return result
|
|
|
|
env = os.environ.copy()
|
|
env["ANSIBLE_REMOTE_TEMP"] = "/tmp/.ansible-${USER}/tmp"
|
|
|
|
with tempfile.TemporaryDirectory(prefix="ansible-local-") as ansible_local_temp:
|
|
env["ANSIBLE_LOCAL_TEMP"] = ansible_local_temp
|
|
|
|
for playbook in playbooks:
|
|
process = self.run_command(
|
|
["ansible-playbook", "--syntax-check", str(playbook.relative_to(workdir))],
|
|
workdir,
|
|
env=env,
|
|
)
|
|
failure = self.failure_from_process(process, str(playbook.relative_to(workdir)))
|
|
if failure is not None:
|
|
if self._is_dependency_resolution_failure(failure.message):
|
|
result.skipped = True
|
|
result.warnings.append(failure.message)
|
|
continue
|
|
result.failures.append(failure)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _find_playbooks(workdir: Path) -> list[Path]:
|
|
candidates = []
|
|
for path in workdir.rglob("*"):
|
|
if not path.is_file() or path.suffix.lower() not in {".yaml", ".yml"}:
|
|
continue
|
|
if "playbook" in path.name.lower() or AnsibleValidator._looks_like_playbook(path):
|
|
candidates.append(path)
|
|
return candidates
|
|
|
|
@staticmethod
|
|
def _looks_like_playbook(path: Path) -> bool:
|
|
try:
|
|
content = path.read_text(encoding="utf-8")
|
|
except OSError:
|
|
return False
|
|
|
|
return any(line.lstrip().startswith("hosts:") for line in content.splitlines())
|
|
|
|
@staticmethod
|
|
def _is_dependency_resolution_failure(message: str) -> bool:
|
|
return ("the role" in message and "was not found" in message) or "couldn't resolve module/action" in message
|