- Transition from multi-template (install/live recipe types) to single universal template (proteus) - Remove recipe_type parameter from RecipeGenerator - Update manifest format (remove recipe_type field) - Simplify CLI (remove --type argument, use --output for single generation) - Filename generation now uses primary modifier as first component
417 lines
16 KiB
Python
417 lines
16 KiB
Python
"""Validation logic for recipes and templates.
|
|
|
|
This module provides three layers of validation for Phyllome OS kickstart recipes:
|
|
|
|
1. TemplateValidator
|
|
- Validates the template YAML structure itself
|
|
- Checks that all referenced ingredient files actually exist on disk
|
|
- Validates required, versioned, conditional, optional, and flag paths
|
|
|
|
2. ContentValidator
|
|
- Validates the generated recipe content
|
|
- Checks for duplicate %include directives
|
|
- Verifies all referenced ingredients exist on disk
|
|
|
|
3. SemanticValidator
|
|
- Uses the official pykickstart library to parse recipes
|
|
- Checks for syntax errors and invalid kickstart directives
|
|
- Validates against Fedora version-specific kickstart rules
|
|
|
|
All validators return lists of warning/error strings. They don't raise exceptions
|
|
for minor issues; instead, they collect and report issues so callers can decide
|
|
how to handle them (warn, error, exit, etc.)
|
|
|
|
The separation allows for:
|
|
- Early detection of template issues (TemplateValidator)
|
|
- Detection of generation issues (ContentValidator)
|
|
- Detection of actual syntax errors (SemanticValidator)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
from pykickstart.parser import KickstartParser
|
|
from pykickstart.version import makeVersion, DEVEL
|
|
|
|
|
|
class TemplateValidator:
|
|
"""Validate template structure and fragment existence.
|
|
|
|
This validator checks that:
|
|
1. Required template keys are present (description, required)
|
|
2. All ingredient paths referenced in the template exist on disk
|
|
3. The template structure matches expected format
|
|
|
|
It's typically used:
|
|
- During development to catch template errors early
|
|
- In CI/CD pipelines to validate templates before use
|
|
- Programmatically when loading templates
|
|
|
|
The validator doesn't check for semantic issues in the ingredients themselves -
|
|
it only verifies that they exist and are properly referenced.
|
|
"""
|
|
|
|
def __init__(self, project_root: Path):
|
|
"""Initialize the validator with project root path.
|
|
|
|
Args:
|
|
project_root: Path to the phyllomeos project root directory.
|
|
All ingredient paths in templates are relative to this.
|
|
"""
|
|
self.project_root = project_root
|
|
|
|
def validate(self, template: Dict) -> List[str]:
|
|
"""Validate template structure and fragment existence.
|
|
|
|
This method performs a comprehensive check of the template dictionary:
|
|
|
|
1. Required Keys Check:
|
|
- Verifies 'description' key exists (used in header)
|
|
- Verifies 'required' key exists (base ingredients)
|
|
|
|
2. Required Ingredients Check:
|
|
- Iterates through template['required'] nested dict structure
|
|
- For each section and value, checks if path(s) are valid
|
|
- Checks if the file actually exists
|
|
|
|
3. Modifier Ingredients Check:
|
|
- Checks template['modifiers'] structure
|
|
- For each modifier and value, checks referenced files exist
|
|
|
|
4. Optional Ingredients Check:
|
|
- Checks paths in template['optional']
|
|
- Verifies referenced files exist on disk
|
|
|
|
5. Versioned Ingredients Check:
|
|
- Checks paths in template['versioned']
|
|
- If the path contains {version}, it's deferred to generation time
|
|
- Otherwise checks if the resolved file exists
|
|
|
|
6. Flag Ingredients Check:
|
|
- Checks all paths in template['flags']
|
|
- Flags are simple boolean on/off switches
|
|
|
|
Args:
|
|
template: The entire template YAML content
|
|
Structure: {'name': str, 'description': str, 'required': {...}, ...}
|
|
|
|
Returns:
|
|
List of validation error strings (empty if valid)
|
|
Example: ['Missing required key: description', 'Required ingredient not found: ingredients/gnome.cfg']
|
|
"""
|
|
errors = []
|
|
|
|
# Check required keys at the template level
|
|
required_keys = ['description', 'required']
|
|
for key in required_keys:
|
|
if key not in template:
|
|
errors.append(f"Missing required key: {key}")
|
|
|
|
# Validate required ingredients exist on disk
|
|
for item in template.get('required', []):
|
|
if isinstance(item, dict):
|
|
# Dict format: {priority: path} or {condition: path}
|
|
fragment_path = list(item.values())[0]
|
|
else:
|
|
# Simple string path
|
|
fragment_path = item
|
|
|
|
# Resolve relative path from project root
|
|
full_path = self.project_root / fragment_path
|
|
if not full_path.exists():
|
|
errors.append(f"Required ingredient not found: {fragment_path}")
|
|
|
|
# Validate versioned ingredients exist on disk
|
|
# These are paths like 'ingredients/repo/f{version}.cfg'
|
|
# If the path has {version}, it'll be resolved at generation time
|
|
# Otherwise, check if it exists now
|
|
for key, fragment_path in template.get('versioned', {}).items():
|
|
if '{version}' in fragment_path:
|
|
# Will be resolved at generation time
|
|
continue
|
|
full_path = self.project_root / fragment_path
|
|
if not full_path.exists():
|
|
errors.append(f"Versioned ingredient not found: {fragment_path}")
|
|
|
|
# Validate conditional ingredients exist on disk
|
|
# Conditional ingredients depend on modifier values
|
|
conditional = template.get('conditional', {})
|
|
for modifier, modifier_config in conditional.items():
|
|
if isinstance(modifier_config, dict):
|
|
# Dict format: {'value': 'path'} or {'value': ['path1', 'path2']}
|
|
for value, fragment_path in modifier_config.items():
|
|
if fragment_path is None:
|
|
# None means "don't include anything for this value"
|
|
continue
|
|
if isinstance(fragment_path, list):
|
|
# List of paths (e.g., multiple ingredients for this value)
|
|
for fp in fragment_path:
|
|
if fp is not None:
|
|
full_path = self.project_root / fp
|
|
if not full_path.exists():
|
|
errors.append(f"Conditional ingredient not found: {fp} (in list for {modifier}={value})")
|
|
else:
|
|
full_path = self.project_root / fragment_path
|
|
if not full_path.exists():
|
|
errors.append(f"Conditional ingredient not found: {fragment_path} (for {modifier}={value})")
|
|
|
|
# Validate flag ingredients exist on disk
|
|
# Flags are simple boolean on/off switches
|
|
for key, fragment_path in template.get('flags', {}).items():
|
|
if fragment_path is None:
|
|
continue
|
|
full_path = self.project_root / fragment_path
|
|
if not full_path.exists():
|
|
errors.append(f"Flag ingredient not found: {fragment_path}")
|
|
|
|
return errors
|
|
|
|
|
|
class ContentValidator:
|
|
"""Validate recipe content for ingredient existence and duplicates.
|
|
|
|
This validator checks the content of a generated recipe file:
|
|
|
|
1. Duplicate Include Detection:
|
|
- Scans for all %include directives
|
|
- Tracks which ingredient paths have been seen
|
|
- Reports if the same path appears multiple times
|
|
- This prevents redundant loading and potential conflicts
|
|
|
|
2. Ingredient Existence Check:
|
|
- For each %include directive, Verifies the referenced file exists
|
|
- Uses project_root to resolve relative paths
|
|
- Reports missing files so generators can catch issues early
|
|
|
|
The validator is designed to run on generated recipes, not templates.
|
|
It's lighter than TemplateValidator and runs during generation to
|
|
catch issues immediately.
|
|
"""
|
|
|
|
def __init__(self, project_root: Path):
|
|
"""Initialize the validator with project root path.
|
|
|
|
Args:
|
|
project_root: Path to the phyllomeos project root directory.
|
|
All ingredient paths are relative to this.
|
|
"""
|
|
self.project_root = project_root
|
|
|
|
def validate(self, content: str) -> List[str]:
|
|
"""Validate recipe content for issues.
|
|
|
|
This method parses the recipe content string and checks for:
|
|
|
|
1. Duplicate Includes:
|
|
- Extracts all %include directives
|
|
- Tracks seen paths in a set
|
|
- Reports if a path appears more than once
|
|
- Duplicates are problematic because they slow down generation
|
|
and can cause conflicts in the final kickstart file
|
|
|
|
2. Missing Ingredients:
|
|
- For each %include directive, checks if the file exists
|
|
- Path is resolved relative to project_root
|
|
- Reports missing files that would cause generation to fail
|
|
- This catches typos in template paths before runtime
|
|
|
|
The validation is "best effort" - it doesn't raise exceptions for
|
|
minor issues but collects all problems to report them together.
|
|
|
|
Args:
|
|
content: The recipe content string to validate
|
|
Contains %include directives and other kickstart code
|
|
|
|
Returns:
|
|
List of validation warning strings (empty if valid)
|
|
Example: ['Duplicate include: ingredients/gnome.cfg', 'Missing ingredient: ingredients/unknown.cfg']
|
|
"""
|
|
issues = []
|
|
includes = [line for line in content.split('\n') if line.startswith('%include')]
|
|
|
|
# Check for duplicate includes
|
|
seen = set()
|
|
for inc in includes:
|
|
parts = inc.split()
|
|
if len(parts) < 2:
|
|
# Malformed include line, skip
|
|
continue
|
|
path = parts[1]
|
|
if path in seen:
|
|
issues.append(f"Duplicate include: {path}")
|
|
seen.add(path)
|
|
|
|
# Check ingredient existence
|
|
for inc in includes:
|
|
parts = inc.split()
|
|
if len(parts) < 2:
|
|
continue
|
|
path = parts[1]
|
|
ingredient_path = self.project_root / path
|
|
if not ingredient_path.exists():
|
|
issues.append(f"Missing ingredient: {path}")
|
|
|
|
return issues
|
|
|
|
|
|
class SemanticValidator:
|
|
"""Validate recipe using pykickstart parser.
|
|
|
|
This is the most thorough validator - it actually parses the recipe
|
|
as a kickstart file and checks for syntax errors and semantic issues.
|
|
|
|
It uses the pykickstart library, which is the same library Anaconda uses
|
|
to parse kickstart files. This means it catches:
|
|
- Syntax errors in kickstart directives
|
|
- Invalid options or values
|
|
- Incompatible directives for the target Fedora version
|
|
- Other structural issues
|
|
|
|
The validator is lenient about include resolution issues (file not found)
|
|
because those are expected - the actual ingredient files are included
|
|
during the installation, not at generation time.
|
|
|
|
Version Mapping:
|
|
- '43' -> pykickstart F42 (Phyllome OS 43 is based on Fedora 42)
|
|
- 'rawhide' -> DEVEL (development version, uses latest rules)
|
|
- This accounts for the fact that Phyllome OS lags Fedora by one version
|
|
"""
|
|
|
|
def validate(self, content: str, version: str) -> List[str]:
|
|
"""Validate recipe using pykickstart parser.
|
|
|
|
This method attempts to parse the recipe content as a kickstart file
|
|
using the pykickstart library. It:
|
|
|
|
1. Maps Phyllome OS version to pykickstart version string
|
|
2. Creates a KickstartParser with the appropriate version
|
|
3. Attempts to parse the content
|
|
4. Catches and reports any parsing errors
|
|
|
|
The parser is configured to be lenient about missing includes
|
|
(file not found errors) because those are expected - the ingredients
|
|
are resolved at installation time, not generation time.
|
|
|
|
Args:
|
|
content: The recipe content string to validate
|
|
version: Phyllome OS version string ('43' or 'rawhide')
|
|
Determines which kickstart version rules to apply
|
|
|
|
Returns:
|
|
List of validation error strings (empty if valid)
|
|
Only reports actual validation errors, not file missing errors
|
|
"""
|
|
issues = []
|
|
|
|
try:
|
|
# Get pykickstart version string
|
|
ks_version_str = self.get_ksversion(version)
|
|
if ks_version_str:
|
|
# Create version-specific parser
|
|
ks_version = makeVersion(ks_version_str)
|
|
else:
|
|
# Use development version for rawhide
|
|
ks_version = makeVersion(DEVEL)
|
|
|
|
# Parse the content
|
|
parser = KickstartParser(ks_version)
|
|
parser.readKickstartFromString(content)
|
|
except Exception as e:
|
|
# Only report actual validation errors, not include resolution issues
|
|
err_str = str(e)
|
|
if 'Unable to open input kickstart file' not in err_str:
|
|
# This is a real validation error, not a missing include
|
|
issues.append(f"Validation error: {err_str}")
|
|
|
|
return issues
|
|
|
|
def get_ksversion(self, version: str) -> Optional[str]:
|
|
"""Map Phyllome OS version to pykickstart version string.
|
|
|
|
This helper method converts Phyllome OS version strings to the
|
|
corresponding pykickstart version strings.
|
|
|
|
Pykickstart uses Fedora version naming:
|
|
- F42, F43, etc. for stable releases
|
|
- DEVEL for development versions
|
|
|
|
Phyllome OS version mapping:
|
|
- Phyllome OS 43 is based on Fedora 42, so use F42
|
|
- Phyllome OS 44 is based on Fedora 43, so use F43
|
|
- The pattern: F(Phyllome_OS_version - 1)
|
|
|
|
For rawhide, we use None which tells pykickstart to use the
|
|
latest development version.
|
|
|
|
Args:
|
|
version: Phyllome OS version string ('43' or 'rawhide')
|
|
|
|
Returns:
|
|
Pykickstart version string ('F42', 'F43', etc.) or None for rawhide
|
|
"""
|
|
if version == 'rawhide':
|
|
return None
|
|
else:
|
|
# Phyllome OS 43 -> Fedora 42 -> F42
|
|
return f'F{int(version) - 1}'
|
|
|
|
|
|
def validate_manifest(manifest: Dict) -> List[str]:
|
|
"""Validate manifest structure.
|
|
|
|
This function validates the high-level structure of a manifest YAML file.
|
|
It checks that the manifest has the required sections and that each recipe
|
|
configuration has the necessary fields.
|
|
|
|
The manifest structure:
|
|
recipes:
|
|
- name: virtual-desktop
|
|
variants:
|
|
- version: 43
|
|
desktop: gnome
|
|
- version: rawhide
|
|
storage: encrypted
|
|
|
|
Validation checks:
|
|
1. 'recipes' key exists at top level
|
|
2. Each recipe config has 'name' key
|
|
3. Each recipe config has 'variants' key
|
|
4. Each variant has 'version' key
|
|
|
|
This is a schema-level validation that catches structural errors before
|
|
trying to process the manifest. It's less detailed than template validation
|
|
but catches the most obvious problems early.
|
|
|
|
Args:
|
|
manifest: The manifest dictionary to validate
|
|
|
|
Returns:
|
|
List of validation error strings (empty if valid)
|
|
Example: ["Manifest missing 'recipes' key", "Recipe 'virtual-desktop' variant missing 'version'"]
|
|
"""
|
|
errors = []
|
|
|
|
# Check for required top-level key
|
|
if 'recipes' not in manifest:
|
|
errors.append("Manifest missing 'recipes' key")
|
|
return errors # Can't continue without recipes
|
|
|
|
# Validate each recipe configuration
|
|
for recipe_config in manifest.get('recipes', []):
|
|
if 'name' not in recipe_config:
|
|
errors.append("Recipe config missing 'name' key")
|
|
|
|
if 'variants' not in recipe_config:
|
|
# Recipe doesn't have variants, can't validate further
|
|
errors.append(f"Recipe '{recipe_config.get('name', 'unnamed')}' "
|
|
"missing 'variants' key")
|
|
else:
|
|
# Each variant must have version
|
|
for variant in recipe_config.get('variants', []):
|
|
if 'version' not in variant:
|
|
errors.append(f"Recipe '{recipe_config['name']}' variant missing 'version'")
|
|
|
|
return errors
|