Files
phyllomeos/cook/generate_recipe.py
T
Lukas GreveandClaude Sonnet 5 67316d9852
build-image / validate (push) Successful in 8s
ci / cook (push) Successful in 10s
build-image / build-image (phyllomeos, 16384) (push) Failing after 1m13s
build-image / build-image (phyllomeos-headless, 8192) (push) Failing after 11m10s
cook: two default dishes (phyllomeos, phyllomeos-headless) + guest/experimental tiers; CI builds raw images
Manifest groups get a tier; only `default` is generated (make all TIER=...,
--tier). Default: phyllomeos (GNOME + virt-manager) and phyllomeos-headless,
Fedora 44, systemd-boot, CPU-agnostic hypervisor ingredient (hypervisor_type:
any). guest tier: guest-server, guest-desktop. experimental: biosboot/grub,
encrypted, rawhide. Generator clears stale recipes/dishes before writing.

build-image.sh: --dish and --tier; deploy.sh: --tier. build-iso.yaml replaced
by build-image.yaml: raw images for both editions on the fedora:host runner
(TMPDIR=/var/tmp, max-parallel 1), attached with the flattened kickstarts to
tagged releases. Live edition no longer built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 11:35:28 +02:00

388 lines
15 KiB
Python

#!/usr/bin/env python3
"""
Recipe and dish generator for Phyllome OS.
Reads recipes_manifest.yaml (the variant matrix) and recipe_templates.yaml
(the ingredient wiring) to:
1. compose kickstart "recipes" (%include lists) under recipes/,
2. flatten them into standalone "dishes" under dishes/ using pykickstart
in-process (no external ksflatten binary), and
3. lint the invariants pykickstart cannot check (exactly-one per choice
category, known keys, existing fragments, unique filenames) and
validate every generated dish.
Everything is derived from YAML; ingredient names, order and filenames are
pure functions of the manifest and templates.
"""
import argparse
import itertools
import os
import sys
import yaml
from pykickstart.version import DEVEL, returnClassForVersion
from pykickstart.parser import KickstartParser
HEADER = """# __ ____ ____ _____
# ____ / /_ __ __/ / /___ ____ ___ ___ / __ \\/ ___/
# / __ \\/ __ \\/ / / / / / __ \\/ __ `__ \\/ _ \\ / / / /\\__ \\
# / /_/ / / / / /_/ / / / /_/ / / / / / / __/ / /_/ /___/ /
# / .___/_/ /_/\\__, /_/_/\\____/_/ /_/ /_/\\___/ \\____//____/
# /_/ /____/
"""
def load_yaml(path):
with open(path) as f:
return yaml.safe_load(f)
def expand_variants(variant_config):
"""Expand a variant config into its cartesian product.
Keys holding a list of values expand to one variant per combination;
scalar and boolean keys are fixed across every combination.
"""
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 default_choice(choices):
"""Return the first declared value of a choices category."""
return next(iter(choices))
def collect_fragments(templates, variant):
"""Resolve a variant to the ordered list of ingredient fragments.
Returns (fragments, problems) where problems is a list of human-readable
lint messages. Choices default to their first declared value when the
variant omits them.
"""
fragments = list(templates.get('base', []))
problems = []
for category, mapping in templates.get('choices', {}).items():
value = variant.get(category)
if value is None:
value = default_choice(mapping)
path = mapping.get(value)
if path is None:
problems.append(
"choice '%s': unknown value '%s' (expected one of %s)"
% (category, value, ', '.join(sorted(mapping))))
else:
fragments.append(path)
for feature, body in templates.get('features', {}).items():
value = variant.get(feature)
if value is None or isinstance(value, bool) and not value:
continue
if isinstance(body, str):
fragments.append(body)
else:
rendered = body.get(value)
if rendered is None:
problems.append(
"feature '%s': unknown value '%s' (expected one of %s)"
% (feature, value, ', '.join(sorted(body))))
elif isinstance(rendered, list):
fragments.extend(rendered)
else:
fragments.append(rendered)
fragments = list(dict.fromkeys(fragments))
return fragments, problems
def render_filename(templates, group_name, variant):
"""Build a deterministic, collision-free dish name for a variant.
Tokens follow the canonical category order from the templates (choices
first, then features), independent of the key order the manifest uses, so
reordering the manifest -- or YAML tools that sort keys -- never renames
dishes. Boolean True renders the key itself, boolean False is omitted,
other values render str(value). The group name prefixes every dish,
making names unique across groups.
"""
order = list(templates.get('choices', {})) + list(templates.get('features', {}))
tokens = []
for key in order:
if key not in variant:
continue
value = variant[key]
if isinstance(value, bool):
if value:
tokens.append(key)
else:
tokens.append(str(value))
return "%s_%s.cfg" % (group_name, '_'.join(tokens))
def render_recipe(fragments):
"""Render a recipe (%include list) for the given ingredient fragments."""
includes = '\n'.join(f'%include ../ingredients/{slug}' for slug in fragments)
return HEADER + includes + '\n'
def lint_manifest(manifest, templates, ingredients_root):
"""Return a list of lint problems across the whole manifest."""
problems = []
group_names = set()
for group in manifest.get('recipes', []):
group_name = group.get('name', 'unknown')
if group_name in group_names:
problems.append("duplicate group name '%s'" % group_name)
group_names.add(group_name)
for config in group.get('variants', []):
for variant in expand_variants(config):
known = set(templates.get('choices')) | set(templates.get('features'))
for key in variant:
if key not in known:
problems.append(
"unknown variant key '%s' (group '%s')" % (key, group_name))
_, probs = collect_fragments(templates, variant)
problems.extend(probs)
name = render_filename(templates, group_name, variant)
problems.extend(check_fragments_exist(templates, variant, ingredients_root))
filenames = []
for group in manifest.get('recipes', []):
group_name = group.get('name', 'unknown')
for config in group.get('variants', []):
for variant in expand_variants(config):
filenames.append(render_filename(templates, group_name, variant))
seen = set()
for name in filenames:
if name in seen:
problems.append("duplicate dish name '%s'" % name)
seen.add(name)
return problems
def check_fragments_exist(templates, variant, ingredients_root):
fragments, _ = collect_fragments(templates, variant)
problems = []
for slug in fragments:
path = os.path.join(ingredients_root, slug)
if not os.path.isfile(path):
problems.append("missing ingredient fragment '%s'" % slug)
return problems
def flatten_recipe(recipe_path):
"""Flatten a recipe (%include list) into a standalone kickstart string."""
handler = returnClassForVersion(DEVEL)()
parser = KickstartParser(handler)
parser.readKickstart(recipe_path)
return str(handler)
def validate_dish(dish_path):
"""Return (ok, error) for a standalone kickstart dish."""
try:
handler = returnClassForVersion(DEVEL)()
parser = KickstartParser(handler)
parser.readKickstart(dish_path)
return True, None
except Exception as exc: # noqa: BLE001 - report any parse failure
return False, str(exc)
DEFAULT_TIER = 'default'
def group_tier(group):
"""Return the tier of a manifest group (groups default to 'default')."""
return group.get('tier', DEFAULT_TIER)
def select_groups(manifest, tiers):
"""Return the manifest groups belonging to the requested tiers.
tiers is a collection of tier names; 'all' selects every group.
"""
groups = manifest.get('recipes', [])
if 'all' in tiers:
return groups
return [g for g in groups if group_tier(g) in tiers]
def clean_output(directory):
"""Remove previously generated .cfg files so stale dishes never linger."""
if not os.path.isdir(directory):
return
for entry in os.listdir(directory):
if entry.endswith('.cfg'):
os.remove(os.path.join(directory, entry))
def generate(manifest_path, templates_path, recipes_dir, dishes_dir,
ingredients_dir, do_generate=True, do_lint=True, do_validate=True,
tiers=(DEFAULT_TIER,)):
"""Full cooking pipeline. Returns an exit code (0 on success).
Only groups in the requested tiers are written; lint always covers the
whole manifest so inactive tiers cannot rot.
"""
manifest = load_yaml(manifest_path)
templates = load_yaml(templates_path)
ingredients_root = os.path.join(os.path.dirname(os.path.abspath(manifest_path)),
ingredients_dir)
if do_lint:
problems = lint_manifest(manifest, templates, ingredients_root)
else:
problems = []
failures = []
generated = []
recipe_dir = os.path.abspath(recipes_dir)
dish_dir = os.path.abspath(dishes_dir)
if do_generate:
os.makedirs(recipe_dir, exist_ok=True)
os.makedirs(dish_dir, exist_ok=True)
clean_output(recipe_dir)
clean_output(dish_dir)
for group in select_groups(manifest, tiers):
group_name = group.get('name', 'unknown')
for config in group.get('variants', []):
for variant in expand_variants(config):
fragments, probs = collect_fragments(templates, variant)
problems.extend(probs)
name = render_filename(templates, group_name, variant)
generated.append(name)
if not do_generate:
continue
recipe_path = os.path.join(recipe_dir, name)
with open(recipe_path, 'w') as f:
f.write(render_recipe(fragments))
dish_path = os.path.join(dish_dir, name)
try:
dish = flatten_recipe(recipe_path)
with open(dish_path, 'w') as f:
f.write(dish)
except Exception as exc: # noqa: BLE001
failures.append('%s: flatten failed: %s' % (name, exc))
if do_validate and do_generate:
for name in generated:
dish_path = os.path.join(dish_dir, name)
if not os.path.isfile(dish_path):
continue
ok, error = validate_dish(dish_path)
if not ok:
problems.append('%s: invalid dish: %s' % (name, error))
if do_generate:
print("Generated %d recipes and %d dishes" % (len(generated), len(generated)))
if problems:
for problem in problems:
print("lint: %s" % problem, file=sys.stderr)
if failures:
for failure in failures:
print(failure, file=sys.stderr)
return 1 if (problems or failures) else 0
def print_inventory(templates_path, ingredients_dir):
templates = load_yaml(templates_path)
root = os.path.abspath(ingredients_dir)
out = []
out.append("Ingredient inventory for Phyllome OS (from %s)" % templates_path)
out.append("=" * 60)
out.append("\nBase (always included, in order):")
for slug in templates.get('base', []):
out.append(" %-40s %s" % (slug, os.path.exists(os.path.join(root, slug)) and "ok" or "MISSING"))
out.append("\nChoices (exactly one per category):")
for category, mapping in templates.get('choices', {}).items():
out.append(" %s:" % category)
for value, slug in mapping.items():
out.append(" %-12s %-30s %s" % (value, slug,
os.path.exists(os.path.join(root, slug)) and "ok" or "MISSING"))
out.append("\nFeatures (additive):")
for feature, body in templates.get('features', {}).items():
out.append(" %s:" % feature)
if isinstance(body, str):
out.append(" (flag) %-30s %s" % (body,
os.path.exists(os.path.join(root, body)) and "ok" or "MISSING"))
else:
for value, slugs in body.items():
if isinstance(slugs, list):
for slug in slugs:
out.append(" %-12s %-30s %s" % (value, slug,
os.path.exists(os.path.join(root, slug)) and "ok" or "MISSING"))
else:
out.append(" %-12s %-30s %s" % (value, slugs,
os.path.exists(os.path.join(root, slugs)) and "ok" or "MISSING"))
print('\n'.join(out))
def main(argv=None):
parser = argparse.ArgumentParser(
description='Generate Phyllome OS recipes and dishes')
parser.add_argument('--manifest', default='recipes_manifest.yaml',
help='recipe variant manifest (default: recipes_manifest.yaml)')
parser.add_argument('--templates', default='recipe_templates.yaml',
help='ingredient templates (default: recipe_templates.yaml)')
parser.add_argument('--recipes-dir', default='recipes',
help='output directory for recipe include lists (default: recipes)')
parser.add_argument('--dishes-dir', default='dishes',
help='output directory for flattened dishes (default: dishes)')
parser.add_argument('--ingredients-dir', default='ingredients',
help='ingredient fragments directory (default: ingredients)')
parser.add_argument('--tier', action='append', metavar='TIER',
help="manifest tier to generate: default, guest, experimental "
"or all; repeatable (default: default)")
parser.add_argument('--no-generate', action='store_true',
help='do not write recipes or dishes (lint/validate only)')
parser.add_argument('--no-lint', action='store_true',
help='skip manifest linting')
parser.add_argument('--no-validate', action='store_true',
help='skip dish validation')
parser.add_argument('--inventory', action='store_true',
help='print the ingredient inventory from templates and exit')
args = parser.parse_args(argv)
script_dir = os.path.dirname(os.path.abspath(__file__))
os.chdir(script_dir)
manifest_path = args.manifest if os.path.isabs(args.manifest) \
else os.path.join(script_dir, args.manifest)
templates_path = args.templates if os.path.isabs(args.templates) \
else os.path.join(script_dir, args.templates)
if args.inventory:
print_inventory(templates_path, args.ingredients_dir)
return 0
return generate(
manifest_path, templates_path, args.recipes_dir, args.dishes_dir,
args.ingredients_dir,
do_generate=not args.no_generate,
do_lint=not args.no_lint,
do_validate=not args.no_validate,
tiers=tuple(args.tier or (DEFAULT_TIER,)))
if __name__ == '__main__':
sys.exit(main())