def validate_recipe(recipe_path: Path) -> ValidationResult:
"""Validate a recipe file without downloading anything.
Validates recipe syntax, required fields, and configuration without
making network calls.
Args:
recipe_path: Path to the recipe YAML file to validate.
Returns:
Validation status, errors, warnings, and app count.
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()
errors = []
warnings = []
app_count = 0
logger.verbose("VALIDATION", f"Validating recipe: {recipe_path}")
# Check file exists
if not recipe_path.exists():
errors.append(f"Recipe file not found: {recipe_path}")
return ValidationResult(
status="invalid",
errors=errors,
warnings=warnings,
app_count=0,
recipe_path=str(recipe_path),
)
# Parse YAML
try:
with open(recipe_path, encoding="utf-8") as f:
recipe = yaml.safe_load(f)
except yaml.YAMLError as err:
errors.append(f"Invalid YAML syntax: {err}")
return ValidationResult(
status="invalid",
errors=errors,
warnings=warnings,
app_count=0,
recipe_path=str(recipe_path),
)
except Exception as err:
errors.append(f"Failed to read recipe file: {err}")
return ValidationResult(
status="invalid",
errors=errors,
warnings=warnings,
app_count=0,
recipe_path=str(recipe_path),
)
logger.verbose("VALIDATION", "YAML syntax is valid")
# Validate recipe is a dict
if not isinstance(recipe, dict):
errors.append("Recipe must be a YAML dictionary/mapping")
return ValidationResult(
status="invalid",
errors=errors,
warnings=warnings,
app_count=0,
recipe_path=str(recipe_path),
)
# Check apiVersion
if "apiVersion" not in recipe:
errors.append("Missing required field: apiVersion")
else:
api_version = recipe["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 app field
app = recipe.get("app")
if not app:
errors.append("Field 'app' is required")
return ValidationResult(
status="invalid",
errors=errors,
warnings=warnings,
app_count=0,
recipe_path=str(recipe_path),
)
if not isinstance(app, dict):
errors.append("Field 'app' must be a dictionary")
return ValidationResult(
status="invalid",
errors=errors,
warnings=warnings,
app_count=0,
recipe_path=str(recipe_path),
)
app_prefix = "app"
logger.verbose("VALIDATION", f"Found app: {app.get('name', 'unnamed')}")
# Check required fields
for field in ["name", "id", "source"]:
if field not in app:
errors.append(f"{app_prefix}: Missing required field: {field}")
# Validate name
if "name" in app and not isinstance(app["name"], str):
errors.append(f"{app_prefix}: Field 'name' must be a string")
# Validate id
if "id" in app:
if not isinstance(app["id"], str):
errors.append(f"{app_prefix}: Field 'id' must be a string")
elif not app["id"]:
errors.append(f"{app_prefix}: Field 'id' cannot be empty")
# Validate source
if "source" not in app:
# Already reported missing field, but continue to check other things
pass
else:
source = app["source"]
if not isinstance(source, dict):
errors.append(f"{app_prefix}.source: Must be a dictionary")
else:
# Check strategy field
if "strategy" not in source:
errors.append(f"{app_prefix}.source: Missing required field: strategy")
else:
strategy_name = source["strategy"]
if not isinstance(strategy_name, str):
errors.append(f"{app_prefix}.source.strategy: Must be a string")
else:
logger.verbose(
"VALIDATION",
f"App '{app.get('name', 'unnamed')}' uses strategy: {strategy_name}",
)
# Check if strategy exists
try:
strategy = get_strategy(strategy_name)
except ConfigError as err:
errors.append(f"{app_prefix}.source.strategy: {err}")
else:
# Validate strategy-specific configuration
if hasattr(strategy, "validate_config"):
try:
config_errors = strategy.validate_config(app)
for error in config_errors:
errors.append(f"{app_prefix}: {error}")
except Exception as err:
errors.append(
f"{app_prefix}: Strategy validation failed: {err}"
)
# Validate win32 configuration
_validate_win32_config(app, app_prefix, errors, warnings)
# Validate psadt configuration
_validate_psadt_config(app, app_prefix, errors)
# Validate optional top-level intune: section
_validate_intune_config(recipe, 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=str(recipe_path),
)