feat: Phase 2 (fragment migration), Phase 3 (testing infrastructure), Phase 4 (CI/CD)

Phase 2: Fragment Migration
- Migrated 42 ingredients to 54 fine-grained fragments
- Replaced %include with %ksappend syntax
- Created organized fragment structure (platform/generic-43, platform/generic-rawhide, shared/)
- Updated generator to use fragment paths
- All 16 manifest variants generate successfully

Phase 3: Testing Infrastructure
- Created containerized test runner (tests/container/)
- Added integration test suite (tests/integration/)
- Created golden master fixtures (tests/fixtures/expected_recipes/)
- 41 tests passing (36 unit + 5 integration)
- 54 fragments, 16 recipes validated

Phase 4: CI/CD Integration
- 5 new workflows: validate-recipes, validate-fragments, test-generation, container-tests, build-iso
- Added validation gates before ISO builds
- Works with existing fedora-runner-image
- Local testing support via act_runner
This commit is contained in:
Lukas Greve
2026-03-24 21:27:29 +01:00
parent a0a5de31cc
commit 5e8afd7d6f
113 changed files with 2973 additions and 224 deletions
+62 -42
View File
@@ -102,7 +102,7 @@ class RecipeGenerator:
sys.exit(2)
def validate_template(self, template: Dict) -> List[str]:
"""Validate template structure and ingredient existence."""
"""Validate template structure and fragment existence."""
errors = []
# Check required keys
@@ -111,34 +111,50 @@ class RecipeGenerator:
if key not in template:
errors.append(f"Missing required key: {key}")
# Validate base ingredient exists
# Validate base ingredient exists (for compatibility with old ingredients)
if 'base' in template:
base_path = self.ingredients_dir / f"{template['base']}.cfg"
if not base_path.exists():
errors.append(f"Base ingredient not found: {template['base']}.cfg")
# Validate required ingredients exist
# Validate required fragments exist
for item in template.get('required', []):
if isinstance(item, dict):
inc_name = list(item.values())[0]
fragment_path = list(item.values())[0]
else:
inc_name = item
inc_path = self.ingredients_dir / f"{inc_name}.cfg"
if not inc_path.exists():
errors.append(f"Required ingredient not found: {inc_name}.cfg")
continue
# Handle both absolute fragment paths and old ingredient names
if fragment_path.startswith('fragments/'):
full_path = self.project_root / fragment_path
else:
full_path = self.ingredients_dir / f"{fragment_path}.cfg"
if not full_path.exists():
errors.append(f"Required fragment not found: {fragment_path}")
# Validate optional ingredient values
# Validate optional fragment values
for opt_key, opt_config in template.get('optional', {}).items():
if isinstance(opt_config, dict):
for value, inc_name in opt_config.items():
inc_path = self.ingredients_dir / f"{inc_name}.cfg"
if not inc_path.exists():
errors.append(f"Optional ingredient not found: {inc_name}.cfg (for {opt_key}={value})")
for value, fragment_path in opt_config.items():
if fragment_path.startswith('fragments/'):
full_path = self.project_root / fragment_path
elif fragment_path is None:
continue
else:
full_path = self.ingredients_dir / f"{fragment_path}.cfg"
if not full_path.exists():
errors.append(f"Optional fragment not found: {fragment_path} (for {opt_key}={value})")
elif isinstance(opt_config, list):
for inc_name in opt_config:
inc_path = self.ingredients_dir / f"{inc_name}.cfg"
if not inc_path.exists():
errors.append(f"Optional ingredient not found: {inc_name}.cfg (in list)")
for fragment_path in opt_config:
if fragment_path.startswith('fragments/'):
full_path = self.project_root / fragment_path
else:
full_path = self.ingredients_dir / f"{fragment_path}.cfg"
if not full_path.exists():
errors.append(f"Optional fragment not found: {fragment_path} (in list)")
return errors
@@ -201,7 +217,7 @@ class RecipeGenerator:
return header
def build_includes(self, template: Dict, version: str, modifiers: Dict) -> List[str]:
"""Build %include lines from template and modifiers."""
"""Build %ksappend lines from template and modifiers."""
includes = []
seen = set() # Track to prevent duplicates
# Add version to modifiers for template processing
@@ -211,14 +227,13 @@ class RecipeGenerator:
# Add required includes
for item in template.get('required', []):
if isinstance(item, dict):
inc_name = list(item.values())[0]
fragment_path = list(item.values())[0]
else:
inc_name = item
continue
inc_file = f"{inc_name}.cfg"
if inc_file not in seen:
includes.append(f"%include ../ingredients/{inc_file}")
seen.add(inc_file)
if fragment_path not in seen:
includes.append(f"%ksappend {fragment_path}")
seen.add(fragment_path)
# Add optional includes based on modifiers
for opt_key, opt_config in template.get('optional', {}).items():
@@ -226,33 +241,32 @@ class RecipeGenerator:
value = modifiers[opt_key]
if isinstance(opt_config, dict):
if value in opt_config:
inc_file = f"{opt_config[value]}.cfg"
if inc_file not in seen:
includes.append(f"%include ../ingredients/{inc_file}")
seen.add(inc_file)
fragment_path = opt_config[value]
if fragment_path not in seen:
includes.append(f"%ksappend {fragment_path}")
seen.add(fragment_path)
elif isinstance(opt_config, list) and value is True:
for item in opt_config:
inc_file = f"{item}.cfg"
if inc_file not in seen:
includes.append(f"%include ../ingredients/{inc_file}")
seen.add(inc_file)
for fragment_path in opt_config:
if fragment_path not in seen:
includes.append(f"%ksappend {fragment_path}")
seen.add(fragment_path)
# Handle special modifiers (CPU, GPU)
for mod_key, mod_value in modifiers.items():
if mod_key in template.get('modifiers', {}):
mod_config = template['modifiers'][mod_key]
if isinstance(mod_config, dict) and mod_value in mod_config:
inc_file = f"{mod_config[mod_value]}.cfg"
if inc_file not in seen:
includes.append(f"%include ../ingredients/{inc_file}")
seen.add(inc_file)
fragment_path = mod_config[mod_value]
if fragment_path and fragment_path not in seen:
includes.append(f"%ksappend {fragment_path}")
seen.add(fragment_path)
return includes
def validate_recipe(self, content: str) -> List[str]:
"""Validate recipe content, return list of warnings/errors."""
issues = []
includes = [line for line in content.split('\n') if line.startswith('%include')]
includes = [line for line in content.split('\n') if line.startswith('%ksappend')]
# Check for duplicate includes
seen = set()
@@ -265,15 +279,15 @@ class RecipeGenerator:
issues.append(f"Duplicate include: {path}")
seen.add(path)
# Check ingredient existence
# Check fragment existence (relative to project root)
for inc in includes:
parts = inc.split()
if len(parts) < 2:
continue
path = parts[1]
inc_path = self.ingredients_dir / path
if not inc_path.exists():
issues.append(f"Missing ingredient: {path}")
fragment_path = self.project_root / path
if not fragment_path.exists():
issues.append(f"Missing fragment: {path}")
return issues
@@ -352,10 +366,16 @@ class RecipeGenerator:
return filename_match.group(1)
for line in content.split('\n'):
# Check for old %include references
if 'core-fedora-repo-43' in line:
return '43'
elif 'core-fedora-repo-rawhide' in line:
return 'rawhide'
# Check for new %ksappend references
if 'generic-43/repo' in line:
return '43'
elif 'generic-rawhide/repo' in line:
return 'rawhide'
return None