From 04731a92757305515620d019b38fc9890e44a91a Mon Sep 17 00:00:00 2001 From: Jeremy Huntwork Date: Mon, 2 Nov 2026 08:25:04 -0500 Subject: [PATCH] Exercise dinit lifecycle and package generations in QEMU --- test/qemu/README.md | 51 ++++++++ test/qemu/lifecycle-fixtures/probe | 144 +++++++++++++++++++++ test/qemu/lifecycle-fixtures/run | 12 ++ test/qemu/lifecycle-fixtures/v1/daemon | 10 ++ test/qemu/lifecycle-fixtures/v1/recipe.kdl | 40 ++++++ test/qemu/lifecycle-fixtures/v2/daemon | 10 ++ test/qemu/lifecycle-fixtures/v2/recipe.kdl | 40 ++++++ test/qemu/lifecycle-smoke | 5 + test/qemu/lifecycle.py | 38 ++++++ test/qemu/packaged-boot.py | 29 ++++- 10 files changed, 375 insertions(+), 4 deletions(-) create mode 100644 test/qemu/lifecycle-fixtures/probe create mode 100644 test/qemu/lifecycle-fixtures/run create mode 100644 test/qemu/lifecycle-fixtures/v1/daemon create mode 100644 test/qemu/lifecycle-fixtures/v1/recipe.kdl create mode 100644 test/qemu/lifecycle-fixtures/v2/daemon create mode 100644 test/qemu/lifecycle-fixtures/v2/recipe.kdl create mode 100755 test/qemu/lifecycle-smoke create mode 100644 test/qemu/lifecycle.py diff --git a/test/qemu/README.md b/test/qemu/README.md index 6442b30c..0e8e5ec4 100644 --- a/test/qemu/README.md +++ b/test/qemu/README.md @@ -244,3 +244,54 @@ tools are recorded or supplied separately; replay does not pin them. Guest execution is offline. This covers the minimum packaged boot contract under #634; bootloader setup, package upgrade/removal/rollback, and broader service lifecycle behavior remain separate work. + +## Service and package lifecycle + +```sh +QEMU_TIMEOUT=600 sh test/qemu/lifecycle-smoke /absolute/path/to/static/mere \ + /absolute/new/lifecycle-results +``` + +This uses the published boot system above and runs the supplied static x86_64 +Mere binary inside the guest. Additional host dependency: `readelf`. Guest +preparation makes a file-backed mirror from the verified upstream metadata and +package cache. Only the repository transport URL changes; signature and key +fingerprint checks remain enabled. There is no guest network device. + +The guest generates a disposable signing key and builds two releases of the +`guest-lifecycle` fixture using `--no-cache`. It imports those signed archives +into a temporary local repository, then checks: + +- Install and upgrade realize the right native service definitions, including + services added and removed between package releases. +- Removal removes package-owned service definitions; generation activation + restores the earlier package version and service set. +- Administrator service overrides and an edited `/etc` configuration survive + upgrade, removal, and rollback. +- The upgraded daemon runs its new payload; the rolled-back daemon runs the + earlier payload. Services are explicitly stopped/unloaded before removal and + rollback; automatic restart or live reconciliation is not asserted. +- Start, stop, restart, SIGHUP reload, detailed status, and log retrieval work. + PID changes distinguish restart from reload. +- Enable changes persistent boot intent without starting a stopped daemon. + Disable leaves a running daemon's PID unchanged. Status reports persistent + intent independently of live state. +- Across two actual reboots, enabled services start and disabled services stay + stopped. The system completes three boot/shutdown cycles with the packaged + graph, preserved data, and mount-lifetime records. Read-only filesystem checks + bracket the complete three-cycle scenario. + +The launcher reports success only after every phase and the final power-off. +Failures shut the guest down and leave the failing command in `serial.log`; +missing markers or failed filesystem checks make the host command fail. The +per-scenario timeout includes package builds and all three boot cycles. + +The selected Mere binary must implement these service semantics; an older +release with a known lifecycle defect should fail, rather than skip the check. +The optional third argument replays the package identities and trust inputs, +with the same binary-digest and repository-retention requirements as the boot +launcher. The results also retain ELF headers, offline mirror hashes/config, +and lifecycle fixture sources. The private development key and guest-built +archives are discarded with the guest disk. This remains developer tooling, +not an installer, a bootloader test, or a claim of automatic running-service +replacement during package transactions. diff --git a/test/qemu/lifecycle-fixtures/probe b/test/qemu/lifecycle-fixtures/probe new file mode 100644 index 00000000..265f882d --- /dev/null +++ b/test/qemu/lifecycle-fixtures/probe @@ -0,0 +1,144 @@ +#!/bin/sh +set -eux +export PATH=/opt:/bin:/sbin:/usr/bin:/usr/sbin +export HOME=/root +cd / +/bin/sh /etc/boot-test/check +service=guest-lifecycle +native=/usr/share/dinit.d/$service +status() { + mere --no-color status "$service" > /run/lifecycle-status 2>&1 + cat /run/lifecycle-status + grep -q "OnBoot: $1" /run/lifecycle-status + mere --no-color status > /run/lifecycle-list 2>&1 + cat /run/lifecycle-list + grep -q "^guest-lifecycle[[:space:]].*[[:space:]]$1$" /run/lifecycle-list +} +stopped() { + if dinitctl is-started "$service"; then + printf 'Service unexpectedly running\n' >&2 + exit 1 + fi +} +pid_changed() { + attempts=0 + while [ ! -s /run/guest-lifecycle.pid ] || [ "$(cat /run/guest-lifecycle.pid)" = "$1" ]; do + attempts=$((attempts + 1)) + [ "$attempts" -lt 50 ] + sleep 0.1 + done +} +if [ "$1" != 0 ]; then + gen=$(cat /var/log/lifecycle-v1-generation) + [ "$(readlink /mere/profiles/system/current)" = "gen-$gen" ] + grep -qx admin-owned /etc/guest-lifecycle.conf + cmp /etc/dinit.d/guest-override /var/log/lifecycle-admin-service +fi +case $1 in +0) + mere dev key generate + fingerprint_output=$(mere --no-color dev key fingerprint /root/.mere/keys/mere.pub) + fingerprint=${fingerprint_output##*: } + [ "${#fingerprint}" -eq 64 ] + cp /root/.mere/keys/mere.pub /mere/keys/lifecycle.pub + # Each version is built from its own recipe and imported only when needed. + mere dev build --no-cache /etc/lifecycle/v1/recipe.kdl + set -- /mere/dev/build/guest-lifecycle-1.0-1-*/pkg/guest-lifecycle-1.0-1-*.pkg.tar.zst + [ "$#" -eq 1 ] && [ -f "$1" ] + mere dev import lifecycle "$1" + printf '\nrepo "lifecycle" {\n url "file:///mere/dev/repo/lifecycle"\n priority 200\n trusted-fingerprints "%s"\n}\n' "$fingerprint" >> /mere/config.kdl + mere install -p system guest-lifecycle + gen=$(readlink /mere/profiles/system/current) + gen=${gen#gen-} + printf '%s\n' "$gen" > /var/log/lifecycle-v1-generation + grep -q 'v1' "$native" + [ -f /usr/share/dinit.d/guest-old ] + [ ! -e /usr/share/dinit.d/guest-new ] + printf 'admin-owned\n' > /etc/guest-lifecycle.conf + printf 'type = scripted\ncommand = /bin/false\n' > /etc/dinit.d/guest-override + cp /etc/dinit.d/guest-override /var/log/lifecycle-admin-service + mere dev build --no-cache /etc/lifecycle/v2/recipe.kdl + set -- /mere/dev/build/guest-lifecycle-1.0-2-*/pkg/guest-lifecycle-1.0-2-*.pkg.tar.zst + [ "$#" -eq 1 ] && [ -f "$1" ] + mere dev import lifecycle "$1" + mere upgrade --sync guest-lifecycle + grep -q 'v2' "$native" + [ ! -e /usr/share/dinit.d/guest-old ] + [ -f /usr/share/dinit.d/guest-new ] + grep -qx admin-owned /etc/guest-lifecycle.conf + cmp /etc/dinit.d/guest-override /var/log/lifecycle-admin-service + mere start "$service" + pid_changed '' + grep -qx v2 /run/guest-lifecycle.version + mere stop "$service" + dinitctl unload "$service" + mere uninstall guest-lifecycle + for name in guest-lifecycle guest-override guest-new guest-old; do + [ ! -e "/usr/share/dinit.d/$name" ] + done + cmp /etc/dinit.d/guest-override /var/log/lifecycle-admin-service + mere store generation activate "$gen" + grep -q 'v1' "$native" + [ -f /usr/share/dinit.d/guest-old ] + [ ! -e /usr/share/dinit.d/guest-new ] + cmp /etc/dinit.d/guest-override /var/log/lifecycle-admin-service + grep -qx admin-owned /etc/guest-lifecycle.conf + printf 'MERE_%s_PASS\n' PACKAGE_LIFECYCLE + # The adapter must separate boot intent from current process state. + mere start "$service" + pid_changed '' + first=$(cat /run/guest-lifecycle.pid) + status disabled + grep -qx v1 /run/guest-lifecycle.version + mere reload "$service" + attempts=0 + while [ ! -f /run/guest-lifecycle.reload ]; do + attempts=$((attempts + 1)) + [ "$attempts" -lt 50 ] + sleep 0.1 + done + [ "$(cat /run/guest-lifecycle.pid)" = "$first" ] + mere restart "$service" + pid_changed "$first" + mere logs "$service" > /run/lifecycle-logs 2>&1 + cat /run/lifecycle-logs + grep -q 'guest-lifecycle v1 started' /run/lifecycle-logs + mere stop "$service" + stopped + status disabled + mere enable "$service" + stopped + [ -L /etc/dinit.d/boot.d/guest-lifecycle ] + status enabled + mere start "$service" + pid_changed '' + first=$(cat /run/guest-lifecycle.pid) + mere disable "$service" + dinitctl is-started "$service" + [ "$(cat /run/guest-lifecycle.pid)" = "$first" ] + status disabled + [ ! -L /etc/dinit.d/boot.d/guest-lifecycle ] + mere stop "$service" + stopped + mere enable "$service" + stopped + printf 'MERE_%s_PASS\n' SERVICE_LIFECYCLE + ;; +1) + # The previous enable must take effect on an actual reboot. + dinitctl is-started "$service" + status enabled + mere disable "$service" + dinitctl is-started "$service" + status disabled + printf 'MERE_%s_PASS\n' ENABLED_BOOT + ;; +2) + # Disable persisted without stopping the previous boot's live process. + stopped + [ ! -L /etc/dinit.d/boot.d/guest-lifecycle ] + grep -q 'v1' "$native" + printf 'MERE_%s_PASS\n' DISABLED_BOOT + ;; +*) exit 2 ;; +esac diff --git a/test/qemu/lifecycle-fixtures/run b/test/qemu/lifecycle-fixtures/run new file mode 100644 index 00000000..a1c1ab47 --- /dev/null +++ b/test/qemu/lifecycle-fixtures/run @@ -0,0 +1,12 @@ +#!/bin/sh +# Always power off on failure so the host can report the missing phase marker. +export PATH=/opt:/bin:/sbin:/usr/bin:/usr/sbin +if /bin/sh /etc/lifecycle/probe "$1"; then + printf 'LIFECYCLE_PHASE_%s_PASS\n' "$1" + if [ "$1" -lt 2 ]; then + exec /sbin/shutdown -r + fi +else + printf 'LIFECYCLE_PHASE_%s_FAIL\n' "$1" +fi +exec /sbin/shutdown -p diff --git a/test/qemu/lifecycle-fixtures/v1/daemon b/test/qemu/lifecycle-fixtures/v1/daemon new file mode 100644 index 00000000..216e55b9 --- /dev/null +++ b/test/qemu/lifecycle-fixtures/v1/daemon @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +export PATH=/bin:/sbin:/usr/bin:/usr/sbin +version=$1 +printf '%s\n' "$version" > /run/guest-lifecycle.version +trap 'rm /run/guest-lifecycle.pid; exit 0' TERM INT +trap 'printf reload > /run/guest-lifecycle.reload' HUP +printf '%s\n' "$$" > /run/guest-lifecycle.pid +printf 'guest-lifecycle %s started\n' "$version" +while :; do sleep 1 || :; done diff --git a/test/qemu/lifecycle-fixtures/v1/recipe.kdl b/test/qemu/lifecycle-fixtures/v1/recipe.kdl new file mode 100644 index 00000000..097f6924 --- /dev/null +++ b/test/qemu/lifecycle-fixtures/v1/recipe.kdl @@ -0,0 +1,40 @@ +recipe { + name "guest-lifecycle" + version "1.0" + release 1 + description "Disposable service lifecycle fixture" + url "https://merelinux.org" + licenses "MIT" + archs "x86_64" + depends "busybox" +} +source "daemon" { + blake3 "2f7e313e16791e8d304511c47e563ce34d2cd43cfd2e7a1d07867f9c887c0998" +} +install { + script r#" + mkdir -p "${DESTDIR}/usr/libexec" "${DESTDIR}/etc" + cp "${MERE_SOURCES_DIR}/daemon" "${DESTDIR}/usr/libexec/guest-lifecycle" + chmod 0755 "${DESTDIR}/usr/libexec/guest-lifecycle" + printf 'v1\n' > "${DESTDIR}/etc/guest-lifecycle.conf" + "# +} +package "guest-lifecycle" { + files "usr/" "etc/" + service "guest-lifecycle" { + type "daemon" + command "/usr/libexec/guest-lifecycle" "v1" + log true + depends-on "sysinit" + } + service "guest-override" { + type "oneshot" + up "/bin/true" + log false + } + service "guest-old" { + type "oneshot" + up "/bin/true" + log false + } +} diff --git a/test/qemu/lifecycle-fixtures/v2/daemon b/test/qemu/lifecycle-fixtures/v2/daemon new file mode 100644 index 00000000..216e55b9 --- /dev/null +++ b/test/qemu/lifecycle-fixtures/v2/daemon @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +export PATH=/bin:/sbin:/usr/bin:/usr/sbin +version=$1 +printf '%s\n' "$version" > /run/guest-lifecycle.version +trap 'rm /run/guest-lifecycle.pid; exit 0' TERM INT +trap 'printf reload > /run/guest-lifecycle.reload' HUP +printf '%s\n' "$$" > /run/guest-lifecycle.pid +printf 'guest-lifecycle %s started\n' "$version" +while :; do sleep 1 || :; done diff --git a/test/qemu/lifecycle-fixtures/v2/recipe.kdl b/test/qemu/lifecycle-fixtures/v2/recipe.kdl new file mode 100644 index 00000000..1799e23a --- /dev/null +++ b/test/qemu/lifecycle-fixtures/v2/recipe.kdl @@ -0,0 +1,40 @@ +recipe { + name "guest-lifecycle" + version "1.0" + release 2 + description "Disposable service lifecycle fixture" + url "https://merelinux.org" + licenses "MIT" + archs "x86_64" + depends "busybox" +} +source "daemon" { + blake3 "2f7e313e16791e8d304511c47e563ce34d2cd43cfd2e7a1d07867f9c887c0998" +} +install { + script r#" + mkdir -p "${DESTDIR}/usr/libexec" "${DESTDIR}/etc" + cp "${MERE_SOURCES_DIR}/daemon" "${DESTDIR}/usr/libexec/guest-lifecycle" + chmod 0755 "${DESTDIR}/usr/libexec/guest-lifecycle" + printf 'v2\n' > "${DESTDIR}/etc/guest-lifecycle.conf" + "# +} +package "guest-lifecycle" { + files "usr/" "etc/" + service "guest-lifecycle" { + type "daemon" + command "/usr/libexec/guest-lifecycle" "v2" + log true + depends-on "sysinit" + } + service "guest-override" { + type "oneshot" + up "/bin/true" + log false + } + service "guest-new" { + type "oneshot" + up "/bin/true" + log false + } +} diff --git a/test/qemu/lifecycle-smoke b/test/qemu/lifecycle-smoke new file mode 100755 index 00000000..9b75a1a9 --- /dev/null +++ b/test/qemu/lifecycle-smoke @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export MERE_LIFECYCLE_TESTS=1 +exec sh "$script_dir/packaged-boot-smoke" "$@" diff --git a/test/qemu/lifecycle.py b/test/qemu/lifecycle.py new file mode 100644 index 00000000..7858f8e7 --- /dev/null +++ b/test/qemu/lifecycle.py @@ -0,0 +1,38 @@ +"""Offline package inputs for the lifecycle scenario; no guest mounts on the host.""" +import hashlib +from pathlib import Path +import shutil +import subprocess + + +def prepare(mere, root, inputs, here): + headers = subprocess.check_output(["readelf", "-l", str(mere)]) + (inputs / "mere.elf").write_bytes(headers) + if b"INTERP" in headers: + raise RuntimeError("Lifecycle tests require a static ELF Mere binary") + (root / "opt").mkdir() + shutil.copy2(mere, root / "opt/mere") + repositories = list((root / "mere/cache/repos").glob("*/repo.db")) + if len(repositories) != 1: + raise RuntimeError("Lifecycle tests expect the single public repository") + mirror = root / "guest-repo" + (mirror / "packages").mkdir(parents=True) + for source in [repositories[0], Path(str(repositories[0]) + ".sig")]: + shutil.copy2(source, mirror / source.name) + for source in (root / "mere/cache/packages").glob("*.pkg.tar.zst"): + shutil.copy2(source, mirror / "packages" / source.name) + config = root / "mere/config.kdl" + text = config.read_text() + url = "https://pkgs.merelinux.org/mere" + if text.count(url) != 1: + raise RuntimeError("Expected public repository URL in lifecycle configuration") + text = text.replace(url, "file:///guest-repo") + config.write_text(text) + (inputs / "guest-config.kdl").write_text(text) + with (inputs / "guest-repo.sha256").open("w") as output: + for source in sorted(mirror.rglob("*")): + if source.is_file(): + output.write(f"{hashlib.sha256(source.read_bytes()).hexdigest()} {source.relative_to(mirror)}\n") + shutil.copytree(here / "lifecycle-fixtures", root / "etc/lifecycle") + shutil.copytree(here / "lifecycle-fixtures", inputs / "lifecycle-fixtures") + shutil.copy2(here / "lifecycle.py", inputs / "lifecycle.py") diff --git a/test/qemu/packaged-boot.py b/test/qemu/packaged-boot.py index b9100aea..350ed40f 100644 --- a/test/qemu/packaged-boot.py +++ b/test/qemu/packaged-boot.py @@ -40,7 +40,7 @@ def dialogue(qemu, kernel, disk, case, output, timeout): "-nic", "none", "-kernel", str(kernel), "-drive", f"file={disk},format=raw,if=ide", "-append", "console=ttyS0 root=/dev/sda rootfstype=ext4 ro init=/sbin/init panic=-1"] - if case != "reboot": + if case not in ("reboot", "lifecycle"): command += ["-no-reboot"] with output.open("wb") as log, subprocess.Popen( command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, @@ -115,6 +115,10 @@ def send(text): send("[ \"$(id -u)\" = 0 ] && dinitctl list >/dev/null && " "printf 'PACKAGED_%s_PASS\\n' RECOVERY && /sbin/shutdown -p\n") stage = "shutdown" + elif case == "lifecycle": + send(f"/bin/sh /etc/lifecycle/run {cycle}\n") + stage = "login" if cycle < 2 else "shutdown" + cycle += 1 elif case == "reboot" and cycle == 0: send("/bin/sh /etc/boot-test/check && " "printf 'persistent-data\\n' > /var/log/boot-test-data && " @@ -154,6 +158,15 @@ def send(text): required += ["PACKAGED_REBOOT_PASS", "reboot: Restarting system"] if text.count("PACKAGED_BOOT_PASS") != 2: raise RuntimeError("Reboot must pass both boot cycles") + if case == "lifecycle": + failed = re.search(r"LIFECYCLE_PHASE_[0-2]_FAIL", text) + if failed: + raise RuntimeError(f"Lifecycle guest reported {failed.group()}; see serial.log") + required += ["MERE_PACKAGE_LIFECYCLE_PASS", "MERE_SERVICE_LIFECYCLE_PASS", + "MERE_ENABLED_BOOT_PASS", "MERE_DISABLED_BOOT_PASS"] + required += [f"LIFECYCLE_PHASE_{phase}_PASS" for phase in range(3)] + if text.count("PACKAGED_BOOT_PASS") != 3 or text.count("reboot: Restarting system") != 2: + raise RuntimeError("Lifecycle checks require three successful boot cycles") for marker in required: if marker not in text: raise RuntimeError(f"{case}: missing {marker}") @@ -167,11 +180,14 @@ def main(): raise RuntimeError("Mere and results paths must be absolute") if os.uname().machine != "x86_64": raise RuntimeError("This launcher requires an x86_64 host and packages") + lifecycle = os.environ.get("MERE_LIFECYCLE_TESTS") == "1" qemu = os.environ.get("QEMU", "qemu-system-x86_64") for tool in (qemu, "sh", "curl", "sha256sum", "uname", "mke2fs", "e2fsck", "debugfs", "openssl"): if not shutil.which(tool): raise RuntimeError(f"Missing host utility: {tool}") - timeout = float(os.environ.get("QEMU_TIMEOUT", "180")) + if lifecycle and not shutil.which("readelf"): + raise RuntimeError("Missing host utility: readelf") + timeout = float(os.environ.get("QEMU_TIMEOUT", "600" if lifecycle else "180")) if not math.isfinite(timeout) or timeout <= 0: raise RuntimeError("QEMU_TIMEOUT must be finite and positive") disk_mib = int(os.environ.get("QEMU_DISK_MIB", "512")) @@ -218,7 +234,12 @@ def main(): shutil.copy2(HERE / "prepare-root", inputs / "prepare-root") (inputs / "qemu.version").write_bytes(subprocess.check_output([qemu, "--version"])) (inputs / "kernel.sha256").write_text(hashlib.sha256(kernel.read_bytes()).hexdigest() + "\n") - for case in ("boot", "reboot", "recovery"): + if lifecycle: + sys.dont_write_bytecode = True + from lifecycle import prepare + prepare(mere, root, inputs, HERE) + cases = ("lifecycle",) if lifecycle else ("boot", "reboot", "recovery") + for case in cases: case_dir = results / case case_dir.mkdir() fstab = "tmpfs /mnt/test tmpfs defaults 0 0\n" @@ -240,7 +261,7 @@ def main(): ["debugfs", "-R", "cat /var/log/boot-test-stops", str(disk)], stderr=subprocess.DEVNULL).decode() (case_dir / "stop.log").write_text(stops) - expected = 0 if case == "recovery" else (2 if case == "reboot" else 1) + expected = {"recovery": 0, "reboot": 2, "lifecycle": 3}.get(case, 1) if stops.splitlines() != ["mounts-present"] * expected: raise RuntimeError(f"{case}: service stop ordering evidence differs") disk.unlink()