Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions test/qemu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
144 changes: 144 additions & 0 deletions test/qemu/lifecycle-fixtures/probe
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions test/qemu/lifecycle-fixtures/run
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions test/qemu/lifecycle-fixtures/v1/daemon
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions test/qemu/lifecycle-fixtures/v1/recipe.kdl
Original file line number Diff line number Diff line change
@@ -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
}
}
10 changes: 10 additions & 0 deletions test/qemu/lifecycle-fixtures/v2/daemon
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions test/qemu/lifecycle-fixtures/v2/recipe.kdl
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 5 additions & 0 deletions test/qemu/lifecycle-smoke
Original file line number Diff line number Diff line change
@@ -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" "$@"
38 changes: 38 additions & 0 deletions test/qemu/lifecycle.py
Original file line number Diff line number Diff line change
@@ -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")
Loading