cook: two default dishes (phyllomeos, phyllomeos-headless) + guest/experimental tiers; CI builds raw images
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

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>
This commit is contained in:
Lukas Greve
2026-09-20 11:35:28 +02:00
co-authored by Claude Sonnet 5
parent 35d8879bcc
commit 67316d9852
11 changed files with 433 additions and 141 deletions
+6 -2
View File
@@ -1,6 +1,9 @@
.PHONY: help generate lint validate inventory test install-deps clean clean-recipes clean-dishes all
PYTHON ?= python3
# Manifest tier(s) to generate: default, guest, experimental, all (space-separated)
TIER ?= default
TIER_ARGS = $(foreach t,$(TIER),--tier $(t))
default: all
@@ -9,6 +12,7 @@ help:
@echo ""
@echo "Available targets:"
@echo " all - Generate recipes and dishes, lint, and validate (default)"
@echo " TIER=default|guest|experimental|all picks the manifest tier(s)"
@echo " generate - Same as 'all'"
@echo " lint - Lint manifest and templates without writing files"
@echo " validate - Validate existing dishes without regenerating"
@@ -22,13 +26,13 @@ help:
all: generate
generate:
@$(PYTHON) generate_recipe.py
@$(PYTHON) generate_recipe.py $(TIER_ARGS)
lint:
@$(PYTHON) generate_recipe.py --no-generate --no-validate
validate:
@$(PYTHON) generate_recipe.py --no-generate --no-lint
@$(PYTHON) generate_recipe.py $(TIER_ARGS) --no-generate --no-lint
inventory:
@$(PYTHON) generate_recipe.py --inventory
+43 -4
View File
@@ -204,9 +204,42 @@ def validate_dish(dish_path):
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):
"""Full cooking pipeline. Returns an exit code (0 on success)."""
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)),
@@ -225,8 +258,10 @@ def generate(manifest_path, templates_path, recipes_dir, 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 manifest.get('recipes', []):
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):
@@ -315,6 +350,9 @@ def main(argv=None):
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',
@@ -342,7 +380,8 @@ def main(argv=None):
args.ingredients_dir,
do_generate=not args.no_generate,
do_lint=not args.no_lint,
do_validate=not args.no_validate)
do_validate=not args.no_validate,
tiers=tuple(args.tier or (DEFAULT_TIER,)))
if __name__ == '__main__':
@@ -0,0 +1,33 @@
# CPU-agnostic optimization for hypervisor (Intel and AMD)
#
# One ingredient for both CPU vendors, so a single dish can be installed on either:
# * intel_iommu=on is ignored by AMD kernels (amd_iommu is enabled by default).
# * "options kvm_intel"/"options kvm_amd" only apply to the module that actually loads.
%post --nochroot --log=/mnt/sysimage/root/hypervisor-cpu-post.log # Beginning of %post section. Those commands are executed outside the chroot environment. Logging is enabled to help with post-installation troubleshooting
# Kernel arguments cannot be attached to a single canonical location at %post time:
# anaconda always creates /etc/kernel/cmdline (kernel-install source for both
# systemd-boot and GRUB+BLS layouts), but GRUB+BLS boot entries have already been
# generated from it by the kernel package scriptlets, and non-BLS flows still read
# /etc/default/grub. Update every surface idempotently, guarded by file existence.
IOMMU_ARGS="intel_iommu=on iommu=pt rd.driver.pre=vfio-pci"
if [ -f /mnt/sysimage/etc/kernel/cmdline ]; then # kernel-install source (systemd-boot now, GRUB+BLS for future kernels)
grep -q "iommu=pt" /mnt/sysimage/etc/kernel/cmdline || echo " $IOMMU_ARGS" >> /mnt/sysimage/etc/kernel/cmdline # Append IOMMU arguments once
fi
if [ -f /mnt/sysimage/etc/default/grub ]; then # GRUB: arguments for grub.cfg-based boot flows
grep -q "iommu=pt" /mnt/sysimage/etc/default/grub || sed -i "s/\(quiet\)/\1 $IOMMU_ARGS/i" /mnt/sysimage/etc/default/grub # Append IOMMU arguments once
fi
for bls_entry in /mnt/sysimage/boot/loader/entries/*.conf; do # Already-generated boot entries share one "options" line shape
[ -e "$bls_entry" ] || continue # Nothing matched: the glob stays literal
grep -q "iommu=pt" "$bls_entry" || sed -i "s/^\(options .*\)/\1 $IOMMU_ARGS/" "$bls_entry" # Patch the options line of each entry once
done
grep -q "kvm_intel" /mnt/sysimage/etc/modprobe.d/kvm.conf 2>/dev/null || echo "options kvm_intel nested=1" >> /mnt/sysimage/etc/modprobe.d/kvm.conf # Add support for nested virtualization on Intel CPUs, exactly once
grep -q "kvm_amd" /mnt/sysimage/etc/modprobe.d/kvm.conf 2>/dev/null || echo "options kvm_amd nested=1" >> /mnt/sysimage/etc/modprobe.d/kvm.conf # Add support for nested virtualization on AMD CPUs, exactly once
%end # End of the %post section
+1
View File
@@ -68,6 +68,7 @@ features:
- packages/virtual-machine-manager/packages.ks
- packages/virtual-machine-manager/post-scripts.ks
hypervisor_type:
any: hypervisor/cpu-agnostic.ks
amdcpu: hypervisor/amdcpu.ks
intelcpu: hypervisor/intelcpu.ks
intelgpu: hypervisor/intelgpu.ks
+89 -36
View File
@@ -2,52 +2,87 @@
# Define all recipe variants using ingredients from recipe_templates.yaml.
# Edit this file to add new variants, then run: make all
#
# Tiers: every group belongs to a tier (default: "default"). Only the requested
# tier is generated, so the default build stays tiny:
# default -- the two shipping editions (phyllomeos, phyllomeos-headless)
# guest -- Fedora VM guests to deploy on a Phyllome OS host
# experimental -- one-off variants for testing new dishes
# Build a tier with: make all TIER=guest (or: TIER="default guest", TIER=all)
# Lint always covers every tier.
#
# Syntax:
# - Single values: repository: "43"
# - List values: repository: ["43", "44", "rawhide"] (expands to multiple variants)
# - Single values: repository: "44"
# - List values: repository: ["43", "44"] (expands to multiple variants)
# - Booleans: hardware-support: true / false (feature flags)
#
# NOTE: List values create a cartesian product.
# Example: repository: ["43", "44", "rawhide"] + storage: ["standard", "encrypted"]
# creates 6 variants: (43,standard), (43,encrypted), (44,standard), (44,encrypted),
# (rawhide,standard), (rawhide,encrypted)
# NOTE: List values create a cartesian product. Keep it out of the default and
# guest tiers; prefer one hand-written variant per experimental dish.
#
# NOTE: The 'name' field prefixes every generated dish/recipe filename, e.g.
# the group below yields dishes like desktop_43_gnome_standard_grub_guest-agents.cfg.
# the "phyllomeos" group yields phyllomeos_44_standard_systemd-boot_...cfg.
recipes:
# Desktop variants
- name: desktop
variants:
- repository: ["43", "44", "rawhide"]
desktop: gnome
storage: ["standard", "encrypted"]
bootloader: ["grub", "systemd-boot"]
hardware-support: [true, false]
guest-agents: [true, false]
# --- default tier: the two shipping editions -------------------------------
# Server variants
- name: server
# Phyllome OS with a GUI (GNOME + virt-manager)
- name: phyllomeos
tier: default
variants:
- repository: ["44"]
- repository: "44"
desktop: gnome
storage: standard
bootloader: systemd-boot
security: enabled
initial-setup: gnome
hardware-support: true
guest-agents: true
hypervisor: desktop
hypervisor_type: any
# Headless Phyllome OS
- name: phyllomeos-headless
tier: default
variants:
- repository: "44"
storage: standard
bootloader: systemd-boot
security: enabled
initial-setup: server
hardware-support: true
guest-agents: true
hypervisor: base
hypervisor_type: any
# --- guest tier: VMs to deploy on a Phyllome OS host -------------------------
- name: guest-server
tier: guest
variants:
- repository: "44"
storage: standard
bootloader: systemd-boot
security: enabled
initial-setup: server
hardware-support: false
guest-agents: true
initial-setup: server
# Hypervisor variants
- name: hypervisor
- name: guest-desktop
tier: guest
variants:
- repository: ["44"]
- repository: "44"
desktop: gnome
storage: standard
bootloader: systemd-boot
hardware-support: true
guest-agents: false
hypervisor: base
hypervisor_type: intelcpu
# Desktop-hypervisor variants
- name: desktop-hypervisor
security: enabled
initial-setup: gnome
hardware-support: false
guest-agents: true
# --- experimental tier: single variants, no matrices -------------------------
# BIOS (legacy firmware) laptop: GPT + biosboot partition, GRUB
- name: exp-bios-desktop-hypervisor
tier: experimental
variants:
- repository: "44"
desktop: gnome
@@ -58,14 +93,32 @@ recipes:
hardware-support: true
guest-agents: false
hypervisor: desktop
hypervisor_type: intelcpu
hypervisor_type: any
# Live desktop ISO variants (built with livemedia-creator --make-iso)
- name: live-desktop
# Encrypted root
- name: exp-encrypted-headless
tier: experimental
variants:
- repository: "44"
desktop: gnome
storage: livefs
bootloader: grub
storage: encrypted
bootloader: systemd-boot
security: enabled
initial-setup: server
hardware-support: true
guest-agents: true
live: true
hypervisor: base
hypervisor_type: any
# Rawhide
- name: exp-rawhide-headless
tier: experimental
variants:
- repository: rawhide
storage: standard
bootloader: systemd-boot
security: enabled
initial-setup: server
hardware-support: true
guest-agents: true
hypervisor: base
hypervisor_type: any
+74 -1
View File
@@ -278,4 +278,77 @@ def test_generate_writes_recipes_and_dishes(tmp_path, templates):
assert len(list(recipes.glob("*.cfg"))) == 1
assert len(list(dishes.glob("*.cfg"))) == 1
dish = (dishes / "desktop_43_standard_grub_gnome.cfg").read_text()
assert "%include" not in dish
assert "%include" not in dish
# ---------------------------------------------------------------------------
# tiers and the real manifest
# ---------------------------------------------------------------------------
def real_manifest():
return gen.load_yaml(os.path.join(COOK_DIR, "recipes_manifest.yaml"))
def dish_names(manifest, templates, tiers):
return sorted(
gen.render_filename(templates, group["name"], variant)
for group in gen.select_groups(manifest, tiers)
for config in group["variants"]
for variant in gen.expand_variants(config))
def test_select_groups_defaults_untiered_groups_to_default():
manifest = {"recipes": [{"name": "a"}, {"name": "b", "tier": "guest"}]}
assert [g["name"] for g in gen.select_groups(manifest, ("default",))] == ["a"]
assert [g["name"] for g in gen.select_groups(manifest, ("guest",))] == ["b"]
assert [g["name"] for g in gen.select_groups(manifest, ("all",))] == ["a", "b"]
def test_real_manifest_lints_clean_across_all_tiers(templates):
problems = gen.lint_manifest(real_manifest(), templates,
os.path.join(COOK_DIR, "ingredients"))
assert problems == []
def test_default_tier_is_the_two_editions(templates):
names = dish_names(real_manifest(), templates, ("default",))
assert len(names) == 2
assert sorted(n.split("_")[0] for n in names) == ["phyllomeos", "phyllomeos-headless"]
def test_guest_tier_is_server_and_desktop(templates):
names = dish_names(real_manifest(), templates, ("guest",))
assert sorted(n.split("_")[0] for n in names) == ["guest-desktop", "guest-server"]
assert all("guest-agents" in n for n in names)
def test_shipping_dishes_contents(templates):
manifest = real_manifest()
for group in gen.select_groups(manifest, ("default",)):
variant = gen.expand_variants(group["variants"][0])[0]
fragments, problems = gen.collect_fragments(templates, variant)
assert problems == []
assert "hypervisor/cpu-agnostic.ks" in fragments
assert "bootloader/systemd-boot.ks" in fragments
assert "storage/standard.ks" in fragments
assert ("desktop/gnome/config.ks" in fragments) == (group["name"] == "phyllomeos")
def test_generate_only_writes_requested_tier_and_clears_stale(tmp_path):
(tmp_path / "ingredients").symlink_to(os.path.join(COOK_DIR, "ingredients"),
target_is_directory=True)
recipes = tmp_path / "recipes"
dishes = tmp_path / "dishes"
recipes.mkdir()
dishes.mkdir()
(dishes / "stale.cfg").write_text("stale")
(recipes / "stale.cfg").write_text("stale")
templates_path = os.path.join(COOK_DIR, "recipe_templates.yaml")
manifest_path = os.path.join(COOK_DIR, "recipes_manifest.yaml")
code = gen.generate(manifest_path, templates_path, str(recipes), str(dishes),
"ingredients", tiers=("guest",))
assert code == 0
names = sorted(p.name.split("_")[0] for p in dishes.glob("*.cfg"))
assert names == ["guest-desktop", "guest-server"]
assert not (dishes / "stale.cfg").exists()
assert not (recipes / "stale.cfg").exists()