Skip to content

validation

napt.validation

Recipe validation module.

This module provides validation functions for checking recipe syntax and configuration without making network calls or downloading files. This is useful for quick feedback during recipe development and in CI/CD pipelines.

Validation Checks:

  • YAML syntax is valid
  • Required top-level fields present (apiVersion, name, id, discovery)
  • apiVersion is supported
  • parent, when declared, is a string and the file carries the .override.yaml suffix (a mismatch either way is a warning)
  • discovery.strategy exists and is registered
  • Strategy-specific configuration is valid
  • intune.detection fields are valid (types, values, unknown field warnings)
  • psadt.app_vars only contains user-settable keys
  • logging section fields are valid

validate_config

validate_config(
    config: dict[str, Any], recipe_path: str = ""
) -> ValidationResult

Validates a merged configuration dict.

Checks required fields, types, strategy registration, and section-level validation. Used by both validate_recipe (CLI) and load_effective_config (pipeline commands) so that one set of rules applies everywhere.

Parameters:

Name Type Description Default
config dict[str, Any]

The merged configuration dictionary to validate.

required
recipe_path str

Optional path string for error context.

''

Returns:

Type Description
ValidationResult

Validation status, errors, warnings, and app count.

Source code in napt/validation.py
def validate_config(
    config: dict[str, Any],
    recipe_path: str = "",
) -> ValidationResult:
    """Validates a merged configuration dict.

    Checks required fields, types, strategy registration, and section-level
    validation. Used by both ``validate_recipe`` (CLI) and
    ``load_effective_config`` (pipeline commands) so that one set of rules
    applies everywhere.

    Args:
        config: The merged configuration dictionary to validate.
        recipe_path: Optional path string for error context.

    Returns:
        Validation status, errors, warnings, and app count.

    """
    logger = get_global_logger()

    errors: list[str] = []
    warnings: list[str] = []

    # Check apiVersion
    if "apiVersion" not in config:
        errors.append("Missing required field: apiVersion")
    else:
        api_version = config["apiVersion"]
        if not isinstance(api_version, str):
            errors.append("apiVersion must be a string")
        elif api_version != "napt/v1":
            warnings.append(
                f"apiVersion '{api_version}' may not be supported (expected: napt/v1)"
            )
        if not errors:
            logger.verbose("VALIDATION", f"apiVersion: {api_version}")

    # Check required top-level fields: name and id
    for field in ["name", "id"]:
        if field not in config:
            errors.append(f"Missing required field: {field}")

    if "name" in config and not isinstance(config["name"], str):
        errors.append("Field 'name' must be a string")

    if "id" in config:
        if not isinstance(config["id"], str):
            errors.append("Field 'id' must be a string")
        elif not config["id"]:
            errors.append("Field 'id' cannot be empty")
        elif not is_safe_path_component(config["id"]):
            # The id names the app's download, build, package, and state
            # folders, so it must be usable as a folder name as-is.
            errors.append(
                "Field 'id' may contain only letters, digits, '.', '-', '_', "
                "and '+', and must start with a letter or digit"
            )

    _validate_parent_field(config, recipe_path, errors, warnings)

    app_name = config.get("name", "unnamed")
    logger.verbose("VALIDATION", f"Validating: {app_name}")

    # Validate discovery section
    discovery = config.get("discovery")
    if discovery is None:
        errors.append("Missing required field: discovery")
    elif not isinstance(discovery, dict):
        errors.append("Field 'discovery' must be a dictionary")
    else:
        if "strategy" not in discovery:
            errors.append("discovery: Missing required field: strategy")
        else:
            strategy_name = discovery["strategy"]
            if not isinstance(strategy_name, str):
                errors.append("discovery.strategy: Must be a string")
            else:
                logger.verbose(
                    "VALIDATION",
                    f"'{app_name}' uses strategy: {strategy_name}",
                )

                # Validate strategy-specific configuration
                if strategy_name == "url_download":
                    from napt.discovery.url_download import (
                        validate_url_download_config,
                    )

                    try:
                        errors.extend(validate_url_download_config(config))
                    except Exception as err:
                        errors.append(f"Strategy validation failed: {err}")
                else:
                    try:
                        strategy = get_strategy(strategy_name)
                    except ConfigError as err:
                        errors.append(f"discovery.strategy: {err}")
                    else:
                        try:
                            errors.extend(strategy.validate_config(config))
                        except Exception as err:
                            errors.append(f"Strategy validation failed: {err}")

    # Validate optional sections
    _validate_psadt_section(config, errors)
    _validate_intune_section(config, errors, warnings)
    _validate_logging_section(config, errors, warnings)
    _validate_deployment_section(config, errors, warnings)

    # Determine final status
    status = "valid" if len(errors) == 0 else "invalid"
    app_count = 1 if status == "valid" else 0

    if status == "valid":
        logger.verbose("VALIDATION", "Recipe is valid!")
    else:
        logger.verbose("VALIDATION", f"Recipe has {len(errors)} error(s)")

    return ValidationResult(
        status=status,
        errors=errors,
        warnings=warnings,
        app_count=app_count,
        recipe_path=recipe_path,
    )

validate_recipe

validate_recipe(recipe_path: Path) -> ValidationResult

Validates a recipe file's own schema.

Parses the YAML file, merges it over its parent recipe when it declares one, and validates the result. Organization and vendor defaults are not applied. This is the entry point for napt validate.

Parameters:

Name Type Description Default
recipe_path Path

Path to the recipe YAML file to validate.

required

Returns:

Type Description
ValidationResult

Validation status, errors, warnings, app count, and the parent path when the recipe declares one.

Example

Validate a recipe and check results:

from pathlib import Path

result = validate_recipe(Path("recipes/app.yaml"))
if result.status == "valid":
    print("Recipe is valid!")
else:
    for error in result.errors:
        print(f"Error: {error}")

Source code in napt/validation.py
def validate_recipe(recipe_path: Path) -> ValidationResult:
    """Validates a recipe file's own schema.

    Parses the YAML file, merges it over its parent recipe when it declares
    one, and validates the result. Organization and vendor defaults are not
    applied. This is the entry point for ``napt validate``.

    Args:
        recipe_path: Path to the recipe YAML file to validate.

    Returns:
        Validation status, errors, warnings, app count, and the parent path
            when the recipe declares one.

    Example:
        Validate a recipe and check results:
            ```python
            from pathlib import Path

            result = validate_recipe(Path("recipes/app.yaml"))
            if result.status == "valid":
                print("Recipe is valid!")
            else:
                for error in result.errors:
                    print(f"Error: {error}")
            ```

    """
    logger = get_global_logger()

    recipe_path_str = str(recipe_path)

    logger.verbose("VALIDATION", f"Validating recipe: {recipe_path}")

    # Check file exists
    if not recipe_path.exists():
        return ValidationResult(
            status="invalid",
            errors=[f"Recipe file not found: {recipe_path}"],
            warnings=[],
            app_count=0,
            recipe_path=recipe_path_str,
        )

    # Parse YAML
    try:
        with open(recipe_path, encoding="utf-8") as f:
            recipe = yaml.safe_load(f)
    except yaml.YAMLError as err:
        return ValidationResult(
            status="invalid",
            errors=[f"Invalid YAML syntax: {err}"],
            warnings=[],
            app_count=0,
            recipe_path=recipe_path_str,
        )
    except Exception as err:
        return ValidationResult(
            status="invalid",
            errors=[f"Failed to read recipe file: {err}"],
            warnings=[],
            app_count=0,
            recipe_path=recipe_path_str,
        )

    logger.verbose("VALIDATION", "YAML syntax is valid")

    # Validate recipe is a dict
    if not isinstance(recipe, dict):
        return ValidationResult(
            status="invalid",
            errors=["Recipe must be a YAML dictionary/mapping"],
            warnings=[],
            app_count=0,
            recipe_path=recipe_path_str,
        )

    # Merge over the parent recipe, if declared
    from napt.config.loader import merge_parent

    try:
        recipe, parent_path = merge_parent(recipe_path, recipe)
    except ConfigError as err:
        return ValidationResult(
            status="invalid",
            errors=[str(err)],
            warnings=[],
            app_count=0,
            recipe_path=recipe_path_str,
        )
    if parent_path is not None:
        logger.verbose("VALIDATION", f"Parent recipe: {parent_path}")

    # Validate the parsed config dict
    result = validate_config(recipe, recipe_path=recipe_path_str)
    if parent_path is None:
        return result
    return ValidationResult(
        status=result.status,
        errors=result.errors,
        warnings=result.warnings,
        app_count=result.app_count,
        recipe_path=result.recipe_path,
        parent_path=str(parent_path),
    )