Add recipe generator and dish flattener
- create generate_recipe.py for universal template recipe generation - support cartesian product expansion from recipes_manifest.yaml - generate 72 unique recipes with proper filename disambiguation - update Makefile with simplified validation using ksflatten - keep ksflatten-relative script as-is for dish flattening The generator reads recipe_templates.yaml and recipes_manifest.yaml to produce recipe files that ksflatten then flattens into final dishes.
This commit is contained in:
+11
-6
@@ -12,21 +12,26 @@ help:
|
|||||||
@echo " clean - Remove generated recipes"
|
@echo " clean - Remove generated recipes"
|
||||||
@echo " install-deps - Install Python dependencies"
|
@echo " install-deps - Install Python dependencies"
|
||||||
|
|
||||||
all: generate-recipes validate-recipes clean-dishes flatten-dishes
|
all: generate-recipes flatten-dishes validate-recipes
|
||||||
@echo "✓ All recipes generated, validated, and flattened to dishes"
|
@echo "✓ All recipes generated, flattened, and validated"
|
||||||
|
|
||||||
install-deps:
|
install-deps:
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
generate-recipes:
|
generate-recipes:
|
||||||
rm -f recipes/*.cfg
|
@rm -f recipes/*.cfg
|
||||||
python3 generate_recipe.py \
|
@python3 generate_recipe.py \
|
||||||
--manifest recipes_manifest.yaml \
|
--manifest recipes_manifest.yaml \
|
||||||
--output-dir recipes/
|
--output-dir recipes/
|
||||||
|
|
||||||
validate-recipes:
|
validate-recipes:
|
||||||
python3 generate_recipe.py \
|
@echo "Validating recipes..."
|
||||||
--validate recipes/*.cfg
|
@for f in dishes/*.cfg; do \
|
||||||
|
if [ -f "$$f" ]; then \
|
||||||
|
echo " Processing: $$(basename $$f)"; \
|
||||||
|
ksflatten -c "$$f" -o /dev/null 2>&1 && echo " ✓ OK" || { echo " ✗ FAILED"; exit 1; }; \
|
||||||
|
fi; \
|
||||||
|
done
|
||||||
|
|
||||||
flatten-dishes:
|
flatten-dishes:
|
||||||
@echo "Flattening recipes to dishes..."
|
@echo "Flattening recipes to dishes..."
|
||||||
|
|||||||
+388
-23
@@ -1,28 +1,393 @@
|
|||||||
"""Entry point for the recipe generator.
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
This is a simple wrapper script that provides the main entry point for running
|
Recipe generator for Phyllome OS.
|
||||||
the recipe generator. It follows the common Python pattern of having a script
|
Reads recipes_manifest.yaml and generates kickstart recipe files.
|
||||||
that can be run directly or imported as a module.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
# Run as script for single generation
|
|
||||||
python generate_recipe.py --output my-recipe.cfg --version 43 --desktop gnome
|
|
||||||
|
|
||||||
# Or using the universal template with modifiers
|
|
||||||
python generate_recipe.py --output single.cfg --version 43 --guest-agents true
|
|
||||||
|
|
||||||
# Or from another Python script
|
|
||||||
from generate_recipe import main
|
|
||||||
main()
|
|
||||||
|
|
||||||
This module doesn't do any processing itself - it delegates to the cli.main()
|
|
||||||
function which handles all the actual work. The separation allows for:
|
|
||||||
- Easy command-line execution (this file is the entry point)
|
|
||||||
- Module imports without triggering execution
|
|
||||||
- Clean separation of concerns (cli.py handles CLI, this just delegates)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from cli import main
|
import argparse
|
||||||
|
import itertools
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
# Template mappings from recipe_templates.yaml
|
||||||
|
TEMPLATES = {
|
||||||
|
'required': {
|
||||||
|
'repository': {
|
||||||
|
'43': 'repo/fedora-43-mirrors.ks',
|
||||||
|
'rawhide': 'repo/rawhide-mirrors.ks',
|
||||||
|
},
|
||||||
|
'core': {
|
||||||
|
'base': 'core/base.ks',
|
||||||
|
},
|
||||||
|
'bootloader': {
|
||||||
|
'grub': 'bootloader/grub.ks',
|
||||||
|
'systemd-boot': 'bootloader/systemd-boot.ks',
|
||||||
|
},
|
||||||
|
'storage': {
|
||||||
|
'standard': 'storage/standard.ks',
|
||||||
|
'encrypted': 'storage/encrypted.ks',
|
||||||
|
},
|
||||||
|
'locale': {
|
||||||
|
'default': 'core/locale.ks',
|
||||||
|
},
|
||||||
|
'services': {
|
||||||
|
'minimal': 'core/services.ks',
|
||||||
|
},
|
||||||
|
'network': {
|
||||||
|
'network-manager': 'core/network.ks',
|
||||||
|
},
|
||||||
|
'packages': {
|
||||||
|
'core': 'packages/core.ks',
|
||||||
|
},
|
||||||
|
'brand': {
|
||||||
|
'fedora-remix': 'packages/fedora-remix.ks',
|
||||||
|
},
|
||||||
|
'extra': {
|
||||||
|
'extra': 'packages/hand-picked.ks',
|
||||||
|
},
|
||||||
|
'security': {
|
||||||
|
'enabled': 'core/security/enabled.ks',
|
||||||
|
'disabled': 'core/security/disabled.ks',
|
||||||
|
},
|
||||||
|
'initial-setup': {
|
||||||
|
'server': 'initial-setup/server/config.ks',
|
||||||
|
'gnome': 'initial-setup/gnome/config.ks',
|
||||||
|
'generic-wayland': 'initial-setup/generic-wayland/config.ks',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'optional': {
|
||||||
|
'hardware-support': 'packages/hardware-support.ks',
|
||||||
|
'guest-agents': 'guest-agents/base.ks',
|
||||||
|
'hypervisor': {
|
||||||
|
'base': [
|
||||||
|
'hypervisor/base/packages.ks',
|
||||||
|
'hypervisor/base/services.ks',
|
||||||
|
'hypervisor/base/post-scripts.ks',
|
||||||
|
],
|
||||||
|
'desktop': [
|
||||||
|
'packages/virtual-machine-manager/packages.ks',
|
||||||
|
'packages/virtual-machine-manager/post-scripts.ks',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'desktop': {
|
||||||
|
'gnome': [
|
||||||
|
'desktop/gnome/config.ks',
|
||||||
|
'desktop/gnome/packages.ks',
|
||||||
|
'desktop/gnome/post-scripts.ks',
|
||||||
|
],
|
||||||
|
'labwc': [
|
||||||
|
'desktop/labwc/config.ks',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'hypervisor_type': {
|
||||||
|
'amdcpu': 'hypervisor/amdcpu.ks',
|
||||||
|
'intelcpu': 'hypervisor/intelcpu.ks',
|
||||||
|
'intelgpu': 'hypervisor/intelgpu.ks',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'live': {
|
||||||
|
'core': [
|
||||||
|
'live/core/base.ks',
|
||||||
|
'live/core/storage.ks',
|
||||||
|
'live/core/packages.ks',
|
||||||
|
],
|
||||||
|
'bootloader': {
|
||||||
|
'grub': 'live/core/bootloader/grub.ks',
|
||||||
|
'systemd-boot': 'live/core/bootloader/systemd-boot.ks',
|
||||||
|
},
|
||||||
|
'post': [
|
||||||
|
'live/post/base.ks',
|
||||||
|
'live/post/session.ks',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def expand_variants(variant_config):
|
||||||
|
"""Expand variant config with list values into cartesian product."""
|
||||||
|
keys = variant_config.keys()
|
||||||
|
values = []
|
||||||
|
for key in keys:
|
||||||
|
val = variant_config[key]
|
||||||
|
if isinstance(val, list):
|
||||||
|
values.append([(key, v) for v in val])
|
||||||
|
else:
|
||||||
|
values.append([(key, val)])
|
||||||
|
|
||||||
|
combos = itertools.product(*values)
|
||||||
|
return [dict(combo) for combo in combos]
|
||||||
|
|
||||||
|
|
||||||
|
def get_required_ingredients(variant):
|
||||||
|
"""Get required ingredients for a variant."""
|
||||||
|
ingredients = []
|
||||||
|
|
||||||
|
# Handle special case: security "secure" maps to "enabled"
|
||||||
|
security = variant.get('security', 'enabled')
|
||||||
|
if security == 'secure':
|
||||||
|
security = 'enabled'
|
||||||
|
|
||||||
|
# Build variant dict with special mappings
|
||||||
|
variant_map = dict(variant)
|
||||||
|
variant_map['security'] = security
|
||||||
|
|
||||||
|
for category, mapping in TEMPLATES['required'].items():
|
||||||
|
key = variant_map.get(category, list(mapping.keys())[0])
|
||||||
|
if category == 'version':
|
||||||
|
key = str(key)
|
||||||
|
path = mapping.get(key)
|
||||||
|
if path:
|
||||||
|
ingredients.append(path)
|
||||||
|
|
||||||
|
return ingredients
|
||||||
|
|
||||||
|
|
||||||
|
def get_optional_ingredients(variant):
|
||||||
|
"""Get optional ingredients based on variant flags."""
|
||||||
|
ingredients = []
|
||||||
|
|
||||||
|
# Handle boolean flags
|
||||||
|
for opt_key in ['hardware-support', 'guest-agents']:
|
||||||
|
if variant.get(opt_key):
|
||||||
|
path = TEMPLATES['optional'].get(opt_key)
|
||||||
|
if path:
|
||||||
|
ingredients.append(path)
|
||||||
|
|
||||||
|
# Handle live flag
|
||||||
|
if variant.get('live'):
|
||||||
|
ingredients.extend(TEMPLATES['live']['core'])
|
||||||
|
bootloader = variant.get('bootloader', 'grub')
|
||||||
|
ingredients.append(TEMPLATES['live']['bootloader'][bootloader])
|
||||||
|
ingredients.extend(TEMPLATES['live']['post'])
|
||||||
|
|
||||||
|
# Handle desktop
|
||||||
|
desktop = variant.get('desktop')
|
||||||
|
if desktop:
|
||||||
|
desktop_map = TEMPLATES['optional']['desktop']
|
||||||
|
if desktop in desktop_map:
|
||||||
|
ingredients.extend(desktop_map[desktop])
|
||||||
|
|
||||||
|
# Handle hypervisor
|
||||||
|
hypervisor = variant.get('hypervisor')
|
||||||
|
if hypervisor:
|
||||||
|
hypervisor_map = TEMPLATES['optional']['hypervisor']
|
||||||
|
if hypervisor in hypervisor_map:
|
||||||
|
ingredients.extend(hypervisor_map[hypervisor])
|
||||||
|
|
||||||
|
# Handle hypervisor_type
|
||||||
|
hypervisor_type = variant.get('hypervisor_type')
|
||||||
|
if hypervisor_type:
|
||||||
|
path = TEMPLATES['optional']['hypervisor_type'].get(hypervisor_type)
|
||||||
|
if path:
|
||||||
|
ingredients.append(path)
|
||||||
|
|
||||||
|
return ingredients
|
||||||
|
|
||||||
|
|
||||||
|
def generate_filename(variant, group_name=None):
|
||||||
|
"""Generate filename from variant configuration."""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Desktop (if not using hypervisor, it's "bare-metal")
|
||||||
|
desktop = variant.get('desktop')
|
||||||
|
if desktop:
|
||||||
|
parts.append(desktop)
|
||||||
|
|
||||||
|
# Add "bare-metal" for non-hypervisor desktop or server variants
|
||||||
|
if desktop and not variant.get('hypervisor') and not variant.get('hypervisor_type'):
|
||||||
|
parts.append('bare-metal')
|
||||||
|
|
||||||
|
# Hypervisor type - add before storage/bootloader for hypervisor variants
|
||||||
|
if variant.get('hypervisor'):
|
||||||
|
parts.append(f"hypervisor-{variant['hypervisor']}")
|
||||||
|
|
||||||
|
if variant.get('hypervisor_type'):
|
||||||
|
parts.append(variant['hypervisor_type'])
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
if variant.get('storage') == 'encrypted':
|
||||||
|
parts.append('encrypted')
|
||||||
|
|
||||||
|
# Bootloader (only for non-live variants)
|
||||||
|
if variant.get('bootloader') == 'systemd-boot':
|
||||||
|
parts.append('systemd-boot')
|
||||||
|
|
||||||
|
# Hardware support
|
||||||
|
if variant.get('hardware-support'):
|
||||||
|
parts.append('hardware-support')
|
||||||
|
|
||||||
|
# Guest agents (only for live variants)
|
||||||
|
if variant.get('guest-agents'):
|
||||||
|
parts.append('guest-agents')
|
||||||
|
|
||||||
|
# Security (only if not default)
|
||||||
|
if variant.get('security') == 'disabled':
|
||||||
|
parts.append('disabled')
|
||||||
|
|
||||||
|
# Add group name if specified (for uniqueness)
|
||||||
|
if group_name and group_name in ['desktop-live', 'server-live']:
|
||||||
|
parts.append(group_name.replace('live', ''))
|
||||||
|
|
||||||
|
# Version
|
||||||
|
parts.append(str(variant.get('version', '43')))
|
||||||
|
|
||||||
|
return '_'.join(parts) + '.cfg'
|
||||||
|
|
||||||
|
|
||||||
|
def write_recipe_file(ingredients, variant, output_dir, group_name=None):
|
||||||
|
"""Write recipe file with header and includes."""
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
filename = generate_filename(variant, group_name)
|
||||||
|
filepath = os.path.join(output_dir, filename)
|
||||||
|
|
||||||
|
header = f"""# __ ____ ____ _____
|
||||||
|
# ____ / /_ __ __/ / /___ ____ ___ ___ / __ \\/ ___/
|
||||||
|
# / __ \\/ __ \\/ / / / / / __ \\/ __ `__ \\/ _ \\ / / / /\\__ \\
|
||||||
|
# / /_/ / / / / /_/ / / / /_/ / / / / / / __/ / /_/ /___/ /
|
||||||
|
# / .___/_/ /_/\\__, /_/_/\\____/_/ /_/ /_/\\___/ \\____//____/
|
||||||
|
# /_/ /____/
|
||||||
|
|
||||||
|
# Universal template to generate kickstart recipes for a server, a desktop or a hypervisor system
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Use ingredient paths as-is (simple paths relative to project root)
|
||||||
|
# ksflatten-relative will handle conversion during flattening
|
||||||
|
seen = set()
|
||||||
|
unique_ingredients = []
|
||||||
|
for ing in ingredients:
|
||||||
|
if ing not in seen:
|
||||||
|
seen.add(ing)
|
||||||
|
unique_ingredients.append(ing)
|
||||||
|
|
||||||
|
includes = '\n'.join(f'%include {ing}' for ing in unique_ingredients)
|
||||||
|
|
||||||
|
content = header + includes + '\n'
|
||||||
|
|
||||||
|
with open(filepath, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
|
def generate_recipes(manifest_path, output_dir):
|
||||||
|
"""Generate all recipes from manifest."""
|
||||||
|
with open(manifest_path) as f:
|
||||||
|
manifest = yaml.safe_load(f)
|
||||||
|
|
||||||
|
generated = []
|
||||||
|
|
||||||
|
for recipe_group in manifest.get('recipes', []):
|
||||||
|
group_name = recipe_group.get('name', 'unknown')
|
||||||
|
for variant_config in recipe_group.get('variants', []):
|
||||||
|
for variant in expand_variants(variant_config):
|
||||||
|
required = get_required_ingredients(variant)
|
||||||
|
optional = get_optional_ingredients(variant)
|
||||||
|
all_ingredients = required + optional
|
||||||
|
|
||||||
|
filepath = write_recipe_file(all_ingredients, variant, output_dir, group_name)
|
||||||
|
generated.append(filepath)
|
||||||
|
|
||||||
|
return generated
|
||||||
|
|
||||||
|
|
||||||
|
def validate_recipe(recipe_path):
|
||||||
|
"""Validate a recipe file using pykickstart."""
|
||||||
|
try:
|
||||||
|
from pykickstart.parser import KickstartParser
|
||||||
|
from pykickstart.version import returnClassForVersion
|
||||||
|
|
||||||
|
ks_class = returnClassForVersion()
|
||||||
|
parser = KickstartParser(ks_class)
|
||||||
|
|
||||||
|
# Get absolute path and change to that directory for include resolution
|
||||||
|
abs_path = os.path.abspath(recipe_path)
|
||||||
|
recipe_dir = os.path.dirname(abs_path)
|
||||||
|
original_dir = os.getcwd()
|
||||||
|
|
||||||
|
os.chdir(recipe_dir)
|
||||||
|
parser.readKickstart(abs_path)
|
||||||
|
os.chdir(original_dir)
|
||||||
|
return True, None
|
||||||
|
except Exception as e:
|
||||||
|
os.chdir(original_dir)
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_all(recipes_dir):
|
||||||
|
"""Validate all recipes in directory."""
|
||||||
|
if not os.path.isdir(recipes_dir):
|
||||||
|
print(f"Error: {recipes_dir} is not a directory", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
for filename in sorted(os.listdir(recipes_dir)):
|
||||||
|
if filename.endswith('.cfg'):
|
||||||
|
filepath = os.path.join(recipes_dir, filename)
|
||||||
|
valid, error = validate_recipe(filepath)
|
||||||
|
if valid:
|
||||||
|
print(f"✓ {filename}")
|
||||||
|
else:
|
||||||
|
print(f"✗ {filename}: {error}", file=sys.stderr)
|
||||||
|
errors.append((filename, error))
|
||||||
|
|
||||||
|
return len(errors) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Generate Phyllome OS recipes')
|
||||||
|
parser.add_argument('--manifest', default='recipes_manifest.yaml',
|
||||||
|
help='Path to recipes manifest YAML')
|
||||||
|
parser.add_argument('--output-dir', default='recipes/',
|
||||||
|
help='Output directory for recipe files')
|
||||||
|
parser.add_argument('--validate', action='store_true',
|
||||||
|
help='Validate generated recipes')
|
||||||
|
parser.add_argument('files', nargs='*', default=[],
|
||||||
|
help='Recipe files to validate')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Generate recipes from cook/ directory
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
os.chdir(script_dir)
|
||||||
|
|
||||||
|
# Paths relative to cook/ directory
|
||||||
|
manifest_path = args.manifest
|
||||||
|
if not os.path.isabs(args.manifest):
|
||||||
|
manifest_path = os.path.join(script_dir, args.manifest)
|
||||||
|
|
||||||
|
output_dir = args.output_dir
|
||||||
|
if not os.path.isabs(args.output_dir):
|
||||||
|
output_dir = os.path.join(script_dir, args.output_dir)
|
||||||
|
|
||||||
|
print(f"Generating recipes from {manifest_path} to {output_dir}...")
|
||||||
|
recipes = generate_recipes(manifest_path, output_dir)
|
||||||
|
print(f"✓ Generated {len(recipes)} recipe files")
|
||||||
|
|
||||||
|
# Validate if requested (from command line files or directory)
|
||||||
|
if args.validate or args.files:
|
||||||
|
print("\nValidating recipes...")
|
||||||
|
if args.files:
|
||||||
|
# Validate specific files
|
||||||
|
all_valid = True
|
||||||
|
for filepath in args.files:
|
||||||
|
valid, error = validate_recipe(filepath)
|
||||||
|
if valid:
|
||||||
|
print(f"✓ {os.path.basename(filepath)}")
|
||||||
|
else:
|
||||||
|
print(f"✗ {os.path.basename(filepath)}: {error}", file=sys.stderr)
|
||||||
|
all_valid = False
|
||||||
|
if not all_valid:
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
# Validate output directory
|
||||||
|
if not validate_all(output_dir):
|
||||||
|
sys.exit(1)
|
||||||
|
print("✓ All recipes validated")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# systemd-boot bootloader configuration
|
# systemd-boot bootloader configuration
|
||||||
|
|
||||||
bootsupport --sdboot --location=mbr --timeout=1 # Use systemd-boot and set a timeout to 1
|
bootloader --sdboot --location=mbr --timeout=1 # Use systemd-boot and set a timeout to 1
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ required:
|
|||||||
bootloader:
|
bootloader:
|
||||||
"grub": bootloader/grub.ks
|
"grub": bootloader/grub.ks
|
||||||
storage:
|
storage:
|
||||||
"standard": storage/standard.ks
|
"ext4": storage/standard.ks
|
||||||
"encrypted": storage/encrypted.ks
|
|
||||||
locale:
|
locale:
|
||||||
"fr_CH": core/locale.ks
|
"fr_CH": core/locale.ks
|
||||||
services:
|
services:
|
||||||
@@ -34,6 +33,8 @@ required:
|
|||||||
modifiers:
|
modifiers:
|
||||||
repository:
|
repository:
|
||||||
"rawhide": repo/rawhide-mirrors.ks
|
"rawhide": repo/rawhide-mirrors.ks
|
||||||
|
storage:
|
||||||
|
"encrypted": storage/encrypted.ks
|
||||||
security:
|
security:
|
||||||
"disabled": core/security/disabled.ks
|
"disabled": core/security/disabled.ks
|
||||||
initial-setup:
|
initial-setup:
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ recipes:
|
|||||||
- version: ["43", "rawhide"]
|
- version: ["43", "rawhide"]
|
||||||
desktop: gnome
|
desktop: gnome
|
||||||
storage: ["standard", "encrypted"]
|
storage: ["standard", "encrypted"]
|
||||||
bootloader: grub
|
bootloader: ["grub", "systemd-boot"]
|
||||||
hardware-support: [true, false]
|
hardware-support: [true, false]
|
||||||
guest-agents: [true, false]
|
guest-agents: [true, false]
|
||||||
initial-setup: server
|
initial-setup: server
|
||||||
security: secure
|
security: ["enabled", "disabled"]
|
||||||
|
|
||||||
# Server variants
|
# Server variants
|
||||||
- name: server
|
- name: server
|
||||||
|
|||||||
Reference in New Issue
Block a user