Files
phyllomeos/cook/tests/test_generator.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

355 lines
14 KiB
Python

"""Tests for the Phyllome OS recipe generator (cook/generate_recipe.py)."""
import os
import pytest
import yaml
import generate_recipe as gen
COOK_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@pytest.fixture
def templates():
return gen.load_yaml(os.path.join(COOK_DIR, "recipe_templates.yaml"))
BASE_FRAGMENTS = [
"core/base.ks",
"core/locale.ks",
"core/network.ks",
"core/services.ks",
"packages/core-explicit.ks",
"packages/fedora-remix.ks",
"packages/hand-picked.ks",
]
def desktop_group(variants):
return {"recipes": [{"name": "desktop", "variants": variants}]}
# ---------------------------------------------------------------------------
# expand_variants
# ---------------------------------------------------------------------------
def test_expand_variants_cartesian_count():
config = {
"repository": ["43", "rawhide"],
"desktop": "gnome",
"storage": ["standard", "encrypted"],
"bootloader": ["grub", "systemd-boot"],
"hardware-support": [True, False],
"guest-agents": [True, False],
}
variants = gen.expand_variants(config)
assert len(variants) == 2 * 2 * 2 * 2 * 2
# Every variant keeps the scalar key and resolves one value per list key.
for variant in variants:
assert variant["desktop"] == "gnome"
assert variant["repository"] in ("43", "rawhide")
assert variant["storage"] in ("standard", "encrypted")
assert variant["bootloader"] in ("grub", "systemd-boot")
assert isinstance(variant["hardware-support"], bool)
assert isinstance(variant["guest-agents"], bool)
def test_expand_variants_all_combos_present():
config = {
"storage": ["standard", "encrypted"],
"bootloader": ["grub", "systemd-boot"],
}
combos = {(v["storage"], v["bootloader"]) for v in gen.expand_variants(config)}
assert combos == {
("standard", "grub"),
("standard", "systemd-boot"),
("encrypted", "grub"),
("encrypted", "systemd-boot"),
}
# ---------------------------------------------------------------------------
# collect_fragments
# ---------------------------------------------------------------------------
def test_collect_fragments_gnome_standard(templates):
variant = {
"repository": "43",
"desktop": "gnome",
"storage": "standard",
"bootloader": "grub",
"hardware-support": False,
"guest-agents": False,
}
fragments, problems = gen.collect_fragments(templates, variant)
assert problems == []
assert fragments[:7] == BASE_FRAGMENTS
assert "repo/fedora-43-mirrors.ks" in fragments
assert "storage/standard.ks" in fragments
assert "storage/encrypted.ks" not in fragments
assert "bootloader/grub.ks" in fragments
assert "core/security/enabled.ks" in fragments
assert "core/security/disabled.ks" not in fragments
assert "initial-setup/server/config.ks" in fragments
assert "guest-agents/base.ks" not in fragments
assert "packages/hardware-support.ks" not in fragments
for slug in ("desktop/gnome/config.ks", "desktop/gnome/packages.ks",
"desktop/gnome/post-scripts.ks"):
assert slug in fragments
@pytest.mark.parametrize("release", ["43", "44"])
def test_collect_fragments_release_repository(templates, release):
variant = {"repository": release}
fragments, problems = gen.collect_fragments(templates, variant)
assert problems == []
assert f"repo/fedora-{release}-mirrors.ks" in fragments
def test_collect_fragments_choices_default_to_first(templates):
variant = {"repository": "43"}
fragments, problems = gen.collect_fragments(templates, variant)
assert problems == []
# Omitted choices fall back to their first declared value.
assert "storage/standard.ks" in fragments
assert "bootloader/grub.ks" in fragments
assert "core/security/enabled.ks" in fragments
assert "initial-setup/server/config.ks" in fragments
def test_collect_fragments_flags(templates):
off, off_problems = gen.collect_fragments(
templates, {"repository": "43", "hardware-support": False, "guest-agents": False})
on, on_problems = gen.collect_fragments(
templates, {"repository": "43", "hardware-support": True, "guest-agents": True})
assert off_problems == [] and on_problems == []
assert "packages/hardware-support.ks" not in off
assert "guest-agents/base.ks" not in off
assert "packages/hardware-support.ks" in on
assert "guest-agents/base.ks" in on
def test_collect_fragments_unknown_choice_reports_problem(templates):
_, problems = gen.collect_fragments(templates, {"repository": "sid"})
assert any("repository" in p and "sid" in p for p in problems)
def test_collect_fragments_unknown_feature_reports_problem(templates):
_, problems = gen.collect_fragments(templates, {"repository": "43", "desktop": "kde"})
assert any("desktop" in p and "kde" in p for p in problems)
def test_collect_fragments_dedups(templates):
variant = {"repository": "43", "desktop": "gnome"}
fragments, _ = gen.collect_fragments(templates, variant)
assert len(fragments) == len(set(fragments))
# ---------------------------------------------------------------------------
# render_filename
# ---------------------------------------------------------------------------
def test_render_filename(templates):
manifest = desktop_group([
{"repository": "43", "desktop": "gnome", "storage": "standard",
"bootloader": "grub", "hardware-support": True, "guest-agents": False},
])
group = manifest["recipes"][0]
name = gen.render_filename(templates, group["name"],
gen.expand_variants(group["variants"][0])[0])
assert name == "desktop_43_standard_grub_gnome_hardware-support.cfg"
def test_render_filename_key_order_irrelevant(templates):
# Naming must not depend on the order keys appear in the manifest.
a = {"repository": "43", "storage": "standard", "bootloader": "grub",
"desktop": "gnome"}
b = {"desktop": "gnome", "bootloader": "grub", "storage": "standard",
"repository": "43"}
assert gen.render_filename(templates, "desktop", a) == \
gen.render_filename(templates, "desktop", b)
def test_render_filenames_unique_across_matrix(templates):
manifest = desktop_group([
{"repository": ["43", "rawhide"], "desktop": "gnome",
"storage": ["standard", "encrypted"],
"bootloader": ["grub", "systemd-boot"],
"hardware-support": [True, False], "guest-agents": [True, False]},
])
names = []
for group in manifest["recipes"]:
for config in group["variants"]:
for variant in gen.expand_variants(config):
names.append(gen.render_filename(templates, group["name"], variant))
assert len(names) == 32
assert len(set(names)) == 32
# ---------------------------------------------------------------------------
# lint_manifest
# ---------------------------------------------------------------------------
def test_lint_clean_manifest(templates):
manifest = desktop_group([
{"repository": "43", "desktop": "gnome", "storage": "standard",
"bootloader": "grub", "hardware-support": False, "guest-agents": False},
])
problems = gen.lint_manifest(manifest, templates, os.path.join(COOK_DIR, "ingredients"))
assert problems == []
def test_lint_catches_missing_ingredient(templates, tmp_path):
# Point lint at an empty ingredients dir so every fragment is missing.
manifest = desktop_group([
{"repository": "43", "desktop": "gnome", "storage": "standard",
"bootloader": "grub"},
])
problems = gen.lint_manifest(manifest, templates, str(tmp_path))
assert any("missing ingredient fragment" in p for p in problems)
def test_lint_catches_unknown_key_and_duplicates(templates):
manifest = {
"recipes": [
{"name": "desktop", "variants": [
{"repository": "43", "storage": "btrfs", "bootloader": "grub",
"desktop": "kde", "typo-key": True}]},
{"name": "desktop", "variants": [
{"repository": "43", "storage": "standard", "bootloader": "grub"}]},
]
}
problems = gen.lint_manifest(manifest, templates, os.path.join(COOK_DIR, "ingredients"))
text = "\n".join(problems)
assert "unknown variant key 'typo-key'" in text
assert "unknown value 'btrfs'" in text
assert "unknown value 'kde'" in text
assert "duplicate group name 'desktop'" in text
# ---------------------------------------------------------------------------
# flatten / validate via pykickstart
# ---------------------------------------------------------------------------
def test_flatten_and_validate(tmp_path):
ingredients = tmp_path / "ingredients"
recipes = tmp_path / "recipes"
ingredients.mkdir()
recipes.mkdir()
(ingredients / "core.ks").write_text("text\nrootpw --lock\n")
(ingredients / "locale.ks").write_text("lang en_US.UTF-8\n")
recipe = recipes / "desktop_43.cfg"
recipe.write_text(
"%include ../ingredients/core.ks\n%include ../ingredients/locale.ks\n")
dish = gen.flatten_recipe(str(recipe))
assert "text" in dish
assert "rootpw --lock" in dish
assert "lang en_US.UTF-8" in dish
# The flattened output is standalone: no %include may survive.
assert "%include" not in dish
dish_path = tmp_path / "desktop_43.dish"
dish_path.write_text(dish)
ok, error = gen.validate_dish(str(dish_path))
assert ok, error
def test_generate_writes_recipes_and_dishes(tmp_path, templates):
manifest = tmp_path / "manifest.yaml"
manifest.write_text(yaml.safe_dump(desktop_group([
{"repository": "43", "desktop": "gnome", "storage": "standard",
"bootloader": "grub", "hardware-support": False, "guest-agents": False},
])))
# recipes/ must be a sibling of ingredients/ for %include resolution, so
# symlink the real ingredients tree next to the generated recipes.
(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()
code = gen.generate(str(manifest), os.path.join(COOK_DIR, "recipe_templates.yaml"),
str(recipes), str(dishes), "ingredients")
assert code == 0
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
# ---------------------------------------------------------------------------
# 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()