diff --git a/nac_validate/cli/main.py b/nac_validate/cli/main.py index 198c79e..a70fb2f 100644 --- a/nac_validate/cli/main.py +++ b/nac_validate/cli/main.py @@ -123,6 +123,16 @@ def version_callback(value: bool) -> None: ] +YamllintOn = Annotated[ + bool, + typer.Option( + "--yamllint-on", + help="Enable yamllint validation.", + envvar="NAC_VALIDATE_YAMLLINT_ON", + ), +] + + Version = Annotated[ bool, typer.Option( @@ -142,13 +152,16 @@ def main( rules: Rules = DEFAULT_RULES, output: Output = None, non_strict: NonStrict = False, + yamllint_on: YamllintOn = False, version: Version = False, ) -> None: """A CLI tool to perform syntactic and semantic validation of YAML files.""" configure_logging(verbosity) try: - validator = nac_validate.validator.Validator(schema, rules) + validator = nac_validate.validator.Validator( + schema, rules, enable_yamllint=yamllint_on + ) validator.validate_syntax(paths, not non_strict) validator.validate_semantics(paths) if output: diff --git a/nac_validate/validator.py b/nac_validate/validator.py index 4bac414..b8c8cc4 100644 --- a/nac_validate/validator.py +++ b/nac_validate/validator.py @@ -5,6 +5,7 @@ import importlib.util import logging import os +import subprocess # nosec B404 import sys import warnings from inspect import signature @@ -29,7 +30,10 @@ class Validator: - def __init__(self, schema_path: Path, rules_path: Path): + def __init__( + self, schema_path: Path, rules_path: Path, enable_yamllint: bool = False + ): + self.enable_yamllint = enable_yamllint self.data: dict[str, Any] | None = None self.schema = None if os.path.exists(schema_path): @@ -71,6 +75,41 @@ def __init__(self, schema_path: Path, rules_path: Path): f"Rules directory not found: {rules_path}" ) + def _run_yamllint(self, file_path: Path) -> None: + """Run yamllint validation on a file""" + if file_path.suffix not in [".yaml", ".yml"]: + return + + logger.debug(f"Running yamllint on {file_path}") + + try: + # NAC-specific yamllint configuration - minimal validation with only new-lines and anchors + config = "{extends: relaxed, rules: {key-duplicates: disable, new-line-at-end-of-file: disable, hyphens: disable, indentation: disable, colons: disable, commas: disable, empty-lines: disable, line-length: disable, trailing-spaces: disable, new-lines: enable}}" + + result = subprocess.run( # nosec B603 B607 + ["yamllint", "-d", config, str(file_path)], + capture_output=True, + text=True, + ) + + logger.debug(f"Yamllint exit code: {result.returncode}") + + if result.returncode != 0: + # Parse yamllint output - log errors but don't block other validations + for line in result.stdout.strip().split("\n"): + if line.strip() and ":" in line: + # Extract filename and error details + if file_path.name in line: + continue # Skip filename line + msg = f"Yamllint error: {line.strip()}" + logger.error(msg) + # Note: NOT adding to self.errors to allow other validations to continue + + except FileNotFoundError: + logger.warning("yamllint not found - skipping yamllint validation") + except Exception as e: + logger.warning(f"yamllint validation failed for {file_path}: {e}") + def _validate_syntax_file(self, file_path: Path, strict: bool = True) -> None: """Run syntactic validation for a single file""" if os.path.isfile(file_path) and file_path.suffix in [".yaml", ".yml"]: @@ -107,6 +146,10 @@ def _validate_syntax_file(self, file_path: Path, strict: bool = True) -> None: logger.error(msg) self.errors.append(msg) + # Run yamllint after other validations (if enabled) + if self.enable_yamllint: + self._run_yamllint(file_path) + def _get_named_path(self, data: dict[str, Any], path: str) -> str: """Convert a numeric path to a named path for better error messages.""" path_segments = path.split(".") diff --git a/pyproject.toml b/pyproject.toml index b12ac57..02ce8c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "typer>=0.17.4", "yamale>=6.0.0", "jmespath>=1.0.0", + "yamllint>=1.35.1", ] [project.urls]