Merge remote-tracking branch 'origin/main' into docs-reposition
This commit is contained in:
@@ -76,6 +76,21 @@ stage-2 init when present.
|
||||
(a 200 ms hold — an instantaneous press+release lands inside one LVGL poll
|
||||
and never clicks), and asserts the frame changed. `qmp.py` is the tiny QMP
|
||||
client.
|
||||
- `ota-apply.sh <zImage>` (needs `FLARE_EDGE`) — the FULL apply loop the
|
||||
portal scenario stops short of: a real signed tier-1 `.wfw` whose payload
|
||||
is a bootable rootfs is pulled, verified, WRITTEN to rootfs_b
|
||||
(`run.sh --allow-apply` gates it per boot), the AvbABData in `misc` is
|
||||
flipped (mkimage provisions real A/B metadata), and the harness reboots
|
||||
slot `_b` and asserts the applied version is running. The BCB slot CHOICE
|
||||
and the physical reset stay emulated by the harness (ADR-0006 boundary);
|
||||
the VM exports `WARDEN_HARD_RESET=0` so flared's post-apply reset surfaces
|
||||
as a clean reported error instead of a /dev/mem fault.
|
||||
- `real-image-boot.sh <zImage> <rootfs.img> <oem.img>` — the real-image
|
||||
milestone: an ACTUAL flare-edge build (matched pair, placed by
|
||||
`mkimage.sh --rootfs-image/--oem-image`) boots through its own init chain
|
||||
to a getty on the VM console; real daemons start. RV1106-only init steps
|
||||
degrade as documented, and binaries predating known fixes reproduce their
|
||||
bugs faithfully (a feature: the VM is a time machine for field issues).
|
||||
- Watchdog: `run.sh --watchdog`, arm `/dev/watchdog` in the guest, don't pet —
|
||||
the VM resets ~30 s later (verified). Do NOT combine with a flared payload
|
||||
expecting survival: flared pets only while the UI heartbeat is fresh.
|
||||
|
||||
+66
-5
@@ -10,6 +10,10 @@
|
||||
# sudo), then dd'd into a sparse raw image.
|
||||
#
|
||||
# Usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... [--fw-version V]
|
||||
# [--rootfs-image PATH] [--oem-image PATH]
|
||||
# --rootfs-image/--oem-image place REAL device images (raw ext4, e.g. a
|
||||
# flare-edge build's rootfs.img/oem.img matched pair) into slot A instead of
|
||||
# the busybox skeleton; slot B keeps the skeleton as a known-good fallback.
|
||||
# Env:
|
||||
# BUSYBOX path to a local busybox binary (skips the download; still verified)
|
||||
# OUT output dir (default: qemu/out); image at $OUT/disk.img
|
||||
@@ -27,6 +31,8 @@ PATH="$PATH:/usr/sbin:/sbin"
|
||||
PORTAL_URL=""
|
||||
STATE_KV=()
|
||||
FW_VERSION="0.0.1"
|
||||
ROOTFS_IMAGE=""
|
||||
OEM_IMAGE=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--portal-url) PORTAL_URL="${2:?--portal-url needs a value}"; shift 2 ;;
|
||||
@@ -45,6 +51,8 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
STATE_KV+=("$2"); shift 2 ;;
|
||||
--fw-version) FW_VERSION="${2:?--fw-version needs a value}"; shift 2 ;;
|
||||
--rootfs-image) ROOTFS_IMAGE="${2:?--rootfs-image needs a path}"; shift 2 ;;
|
||||
--oem-image) OEM_IMAGE="${2:?--oem-image needs a path}"; shift 2 ;;
|
||||
*) echo "FATAL: unknown argument '$1' (usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... [--fw-version V])" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
@@ -90,28 +98,79 @@ mkfs_part() {
|
||||
DISK="$OUT/disk.img"
|
||||
rm -f "$DISK"
|
||||
|
||||
# dd a REAL raw image into a partition window, fail-closed on overflow.
|
||||
place_real_image() { # $1 src image, $2 offset, $3 partition size, $4 name
|
||||
local srcsz
|
||||
srcsz=$(stat -c %s "$1")
|
||||
[ "$srcsz" -le "$3" ] || {
|
||||
echo "FATAL: $4 image $1 ($srcsz bytes) exceeds the $3-byte partition" >&2
|
||||
exit 1
|
||||
}
|
||||
dd if="$1" of="$DISK" bs=4096 seek=$(($2 / 4096)) conv=notrunc,sparse status=none
|
||||
qemu_log " $4: REAL image $(basename "$1") ($((srcsz / 1048576))M) @ $2"
|
||||
}
|
||||
|
||||
place_partition() {
|
||||
local name="$1" off="$2" size="$3" stage=""
|
||||
case "$name" in
|
||||
rootfs_a|rootfs_b) stage="$ROOT" ;;
|
||||
rootfs_a)
|
||||
if [ -n "$ROOTFS_IMAGE" ]; then
|
||||
[ -f "$ROOTFS_IMAGE" ] || { echo "FATAL: --rootfs-image $ROOTFS_IMAGE not found" >&2; exit 1; }
|
||||
DISK_END_TRACK "$off" "$size"
|
||||
place_real_image "$ROOTFS_IMAGE" "$off" "$size" "$name"
|
||||
return 0
|
||||
fi
|
||||
stage="$ROOT" ;;
|
||||
oem_a)
|
||||
if [ -n "$OEM_IMAGE" ]; then
|
||||
[ -f "$OEM_IMAGE" ] || { echo "FATAL: --oem-image $OEM_IMAGE not found" >&2; exit 1; }
|
||||
DISK_END_TRACK "$off" "$size"
|
||||
place_real_image "$OEM_IMAGE" "$off" "$size" "$name"
|
||||
return 0
|
||||
fi
|
||||
stage="$SCRATCH/empty" ;;
|
||||
rootfs_b) stage="$ROOT" ;;
|
||||
userdata) stage="$UDATA" ;;
|
||||
oem_a|oem_b) stage="$SCRATCH/empty" ;;
|
||||
oem_b) stage="$SCRATCH/empty" ;;
|
||||
# misc carries REAL AvbABData (byte 2048): flared's slotctl fail-closes
|
||||
# on a bad magic before any OTA write, so a zeroed misc blocks apply
|
||||
# scenarios. Bytes mirror flare-edge tools/mk-misc.py provisioning
|
||||
# defaults (A: prio 15 successful, B: prio 14 successful, CRC32-BE).
|
||||
misc) stage="__misc__" ;;
|
||||
# Boot-chain partitions the VM never reads: present at the right offsets,
|
||||
# left zeroed. Enumerated (not a wildcard) so a typo'd name in
|
||||
# blkdevparts.conf fails HERE, not as a confusing mount error at boot.
|
||||
env|idblock|uboot|misc|boot_a|boot_b|recovery) stage="" ;;
|
||||
env|idblock|uboot|boot_a|boot_b|recovery) stage="" ;;
|
||||
*) echo "FATAL: unknown partition name '$name' in blkdevparts.conf" >&2; exit 1 ;;
|
||||
esac
|
||||
DISK_END_TRACK "$off" "$size"
|
||||
# dd in 4K blocks — every offset in the canonical layout is 4K-aligned;
|
||||
# assert rather than assume, a misaligned write would corrupt a neighbor.
|
||||
if [ $((off % 4096)) -ne 0 ] || [ $((size % 4096)) -ne 0 ]; then
|
||||
echo "FATAL: partition $name not 4K-aligned (off=$off size=$size)" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Max, not last: blkdevparts grammar permits explicit @offsets out of order.
|
||||
[ $((off + size)) -gt "$DISK_END" ] && DISK_END=$((off + size))
|
||||
[ -z "$stage" ] && return 0
|
||||
local img="$SCRATCH/$name.img"
|
||||
if [ "$stage" = "__misc__" ]; then
|
||||
python3 - "$img" "$size" <<'PYMISC'
|
||||
import struct, sys, zlib
|
||||
img, size = sys.argv[1], int(sys.argv[2])
|
||||
s = bytearray(28)
|
||||
s[0:4] = b"\0AB0" # AB_MAGIC
|
||||
s[4] = 1 # major
|
||||
s[8:12] = bytes([15, 0, 1, 0]) # slot A: priority, tries, successful
|
||||
s[12:16] = bytes([14, 0, 1, 0]) # slot B
|
||||
meta = bytes(s) + struct.pack(">I", zlib.crc32(bytes(s)) & 0xFFFFFFFF)
|
||||
buf = bytearray(size)
|
||||
buf[2048:2048 + len(meta)] = meta
|
||||
open(img, "wb").write(buf)
|
||||
PYMISC
|
||||
dd if="$img" of="$DISK" bs=4096 seek=$((off / 4096)) \
|
||||
conv=notrunc,sparse status=none
|
||||
qemu_log " $name: AvbABData provisioned @ +2048"
|
||||
return 0
|
||||
fi
|
||||
mkfs_part "$stage" "$size" "$img"
|
||||
dd if="$img" of="$DISK" bs=4096 seek=$((off / 4096)) \
|
||||
conv=notrunc,sparse status=none
|
||||
@@ -119,6 +178,8 @@ place_partition() {
|
||||
}
|
||||
|
||||
DISK_END=0
|
||||
# Max, not last: blkdevparts grammar permits explicit @offsets out of order.
|
||||
DISK_END_TRACK() { [ $(($1 + $2)) -gt "$DISK_END" ] && DISK_END=$(($1 + $2)); return 0; }
|
||||
qemu_log "building $DISK ($WARDEN_BLKDEVPARTS)"
|
||||
truncate -s 0 "$DISK"
|
||||
qemu_each_partition place_partition
|
||||
|
||||
@@ -39,5 +39,12 @@ if ! command -v switch_root >/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Hand the live devtmpfs to the new root: busybox switch_root moves nothing,
|
||||
# and a REAL device rootfs's init expects /dev to already be there (its getty
|
||||
# opens /dev/console immediately). Our own skeleton init remounts devtmpfs
|
||||
# defensively either way.
|
||||
mkdir -p /mnt/dev
|
||||
mount -o move /dev /mnt/dev 2>/dev/null || mount --move /dev /mnt/dev
|
||||
|
||||
echo "rc: switching root to rootfs${slot} ($root)"
|
||||
exec switch_root /mnt /sbin/init
|
||||
|
||||
@@ -70,6 +70,16 @@ export WARDEN_FLARE_INSECURE=1
|
||||
# here, and flared's /dev/mem poke dies with an external abort (SIGBUS). The
|
||||
# SCR1 supervisor state machine is modeled in sim/src/hpmcu.rs instead.
|
||||
export WARDEN_HPMCU=0
|
||||
# Same class: the CRU reset ladder's /dev/mem poke is fatal on virt. A
|
||||
# post-apply "reboot" surfaces as a clean flared error; the scenario harness
|
||||
# performs the actual reboot into the applied slot.
|
||||
export WARDEN_HARD_RESET=0
|
||||
# OTA apply is opt-in per boot (run.sh --allow-apply): writing rootfs_b is
|
||||
# safe inside disk.img but must never be the default posture.
|
||||
if grep -qw warden.fwapply /proc/cmdline; then
|
||||
export WARDEN_FW_ALLOW_APPLY=1
|
||||
echo "init: OTA APPLY ENABLED (warden.fwapply)"
|
||||
fi
|
||||
for d in /usr/bin/warden-flared /usr/bin/warden-modbus; do
|
||||
if [ -x "$d" ]; then
|
||||
name="$(basename "$d")"
|
||||
|
||||
+5
-1
@@ -22,6 +22,8 @@
|
||||
# --http-port N hostfwd 127.0.0.1:N -> guest :80 (default 8080; 0 disables)
|
||||
# --api-port N hostfwd 127.0.0.1:N -> guest :28443 (default 28443; 0 disables)
|
||||
# --shell interactive shell in the guest instead of daemon hold
|
||||
# --allow-apply let flared ACTUALLY apply OTA firmware (writes rootfs_b
|
||||
# inside disk.img — safe in the VM, never the default)
|
||||
set -euo pipefail
|
||||
|
||||
QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -30,7 +32,7 @@ QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OUT="${OUT:-$QEMU_DIR/out}"
|
||||
|
||||
KERNEL="" INITRD="$OUT/initramfs.cpio.gz" DISK="" NO_DISK=0 SLOT="_a"
|
||||
RTC="" RS485="" WATCHDOG=0 QMP="" DISPLAY_MODE="off" SHELL_FLAG=0
|
||||
RTC="" RS485="" WATCHDOG=0 QMP="" DISPLAY_MODE="off" SHELL_FLAG=0 ALLOW_APPLY=0
|
||||
SSH_PORT=2222 HTTP_PORT=8080 API_PORT=28443
|
||||
EXTRA=()
|
||||
|
||||
@@ -50,6 +52,7 @@ while [ $# -gt 0 ]; do
|
||||
--http-port) HTTP_PORT="${2:?}"; shift 2 ;;
|
||||
--api-port) API_PORT="${2:?}"; shift 2 ;;
|
||||
--shell) SHELL_FLAG=1; shift ;;
|
||||
--allow-apply) ALLOW_APPLY=1; shift ;;
|
||||
--) shift; EXTRA=("$@"); break ;;
|
||||
*) echo "FATAL: unknown argument '$1' (see header of $0)" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -99,6 +102,7 @@ if [ -n "$DISK" ] && [ "$NO_DISK" -eq 0 ]; then
|
||||
-device "virtio-blk-device,drive=vd0" )
|
||||
fi
|
||||
[ "$SHELL_FLAG" -eq 1 ] && APPEND="$APPEND warden.shell"
|
||||
[ "$ALLOW_APPLY" -eq 1 ] && APPEND="$APPEND warden.fwapply"
|
||||
[ -n "$RTC" ] && ARGS+=( -rtc "base=$RTC" )
|
||||
[ "$WATCHDOG" -eq 1 ] && ARGS+=( -device i6300esb -action watchdog=reset )
|
||||
[ -n "$RS485" ] && ARGS+=( -chardev "socket,id=rs485,path=$RS485,server=on,wait=off"
|
||||
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# Clock-sanity scenario (issue #3 regression guard): boot the VM, run the
|
||||
# musl-static clockprobe in the guest, and assert the vDSO monotonic RATE
|
||||
# matches the kernel's /proc/uptime within 1%. Under QEMU -M virt this passes
|
||||
# on the current kernel (measured 0.99963) — a regression here means the
|
||||
# generic vDSO path broke. The RV1106 *board* leg of issue #3 is a separate,
|
||||
# bench-only measurement; this scenario cannot see board-specific CNTFRQ or
|
||||
# CNTVOFF misprogramming.
|
||||
#
|
||||
# FAILS CLOSED on missing prerequisites.
|
||||
#
|
||||
# Usage: clock-sanity.sh <zImage>
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
|
||||
QDIR="$(cd "$HERE/.." && pwd)" # qemu/
|
||||
|
||||
ZIMAGE="${1:-}"
|
||||
if [ -z "$ZIMAGE" ] || [ ! -f "$ZIMAGE" ]; then
|
||||
echo "FATAL: usage: $0 <zImage>" >&2
|
||||
exit 1
|
||||
fi
|
||||
command -v qemu-system-arm >/dev/null || {
|
||||
echo "FATAL: qemu-system-arm not on PATH — see qemu/README.md" >&2
|
||||
exit 1
|
||||
}
|
||||
command -v arm-linux-gnueabihf-gcc >/dev/null || {
|
||||
echo "FATAL: arm-linux-gnueabihf-gcc needed to cross-build the probe" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Build the probe for the guest (static musl armv7) and stage it as payload.
|
||||
( cd "$HERE/clockprobe" && \
|
||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-gcc \
|
||||
cargo build -q --release --target armv7-unknown-linux-musleabihf )
|
||||
install -m 0755 "$HERE/clockprobe/target/armv7-unknown-linux-musleabihf/release/clockprobe" \
|
||||
"$QDIR/payload/clockprobe"
|
||||
|
||||
WORK="$(mktemp -d /tmp/wqc.XXXXXX)"
|
||||
QEMU_PID=""
|
||||
cleanup() {
|
||||
if [ -n "$QEMU_PID" ]; then kill "$QEMU_PID" 2>/dev/null || true; fi
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
bash "$QDIR/mkinitramfs.sh"
|
||||
bash "$QDIR/mkimage.sh"
|
||||
|
||||
for _attempt in 1 2 3; do
|
||||
PORT=$((22000 + RANDOM % 20000))
|
||||
: > "$WORK/console.log"
|
||||
{ sleep 40; printf '/usr/bin/clockprobe\n'; sleep 16; printf 'poweroff -f\n'; sleep 8; } | \
|
||||
bash "$QDIR/run.sh" --kernel "$ZIMAGE" --shell \
|
||||
--ssh-port "$PORT" --http-port $((PORT + 1)) --api-port $((PORT + 2)) \
|
||||
> "$WORK/console.log" 2>&1 &
|
||||
QEMU_PID=$!
|
||||
sleep 3
|
||||
kill -0 "$QEMU_PID" 2>/dev/null && break
|
||||
if grep -aq 'Could not set up host forwarding' "$WORK/console.log"; then
|
||||
echo "== hostfwd port collision on base $PORT — retrying"
|
||||
QEMU_PID=""
|
||||
continue
|
||||
fi
|
||||
echo "FATAL: VM died at launch:" >&2
|
||||
tail -20 "$WORK/console.log" >&2
|
||||
exit 1
|
||||
done
|
||||
if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then
|
||||
echo "FATAL: could not launch the VM after 3 port attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
wait "$QEMU_PID" || true
|
||||
grep -a 'CLOCKPROBE' "$WORK/console.log" || {
|
||||
echo "FATAL: probe never ran; console tail:" >&2
|
||||
tail -25 "$WORK/console.log" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
python3 - "$WORK/console.log" <<'EOF'
|
||||
import re, sys
|
||||
text = open(sys.argv[1], errors="replace").read()
|
||||
m = re.search(r"ratio_mono=([0-9.]+) ratio_raw=([0-9.]+)", text)
|
||||
if not m:
|
||||
sys.exit("FATAL: no ratio line in console output")
|
||||
mono, raw = float(m.group(1)), float(m.group(2))
|
||||
ok = abs(mono - 1.0) < 0.01 and abs(raw - 1.0) < 0.01
|
||||
print(f"clock-sanity: ratio_mono={mono} ratio_raw={raw} -> {'PASS' if ok else 'FAIL'}")
|
||||
sys.exit(0 if ok else 1)
|
||||
EOF
|
||||
echo "CLOCK-SANITY-PASS"
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "clockprobe"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "clockprobe"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Interval-based monotonic-clock rate probe: compares musl/vDSO CLOCK_MONOTONIC(+_RAW) against /proc/uptime so rate errors and boot-time offsets (CNTVOFF) separate cleanly. Diagnostic for issue #3."
|
||||
license = "GPL-2.0-only"
|
||||
|
||||
[dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
@@ -0,0 +1,25 @@
|
||||
// Differential clock probe for warden-sdk issue #3: compares the musl/vDSO
|
||||
// CLOCK_MONOTONIC rate against the kernel's own /proc/uptime over a fixed
|
||||
// interval, so boot-time offsets cancel and only the RATE ratio remains.
|
||||
use std::{fs, thread, time::Duration};
|
||||
|
||||
fn mono(clock: libc::clockid_t) -> f64 {
|
||||
let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
|
||||
unsafe { libc::clock_gettime(clock, &mut ts) };
|
||||
ts.tv_sec as f64 + ts.tv_nsec as f64 / 1e9
|
||||
}
|
||||
|
||||
fn uptime() -> f64 {
|
||||
fs::read_to_string("/proc/uptime").unwrap()
|
||||
.split_whitespace().next().unwrap().parse().unwrap()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let interval = 10.0;
|
||||
let (m0, r0, u0) = (mono(libc::CLOCK_MONOTONIC), mono(libc::CLOCK_MONOTONIC_RAW), uptime());
|
||||
thread::sleep(Duration::from_secs_f64(interval));
|
||||
let (m1, r1, u1) = (mono(libc::CLOCK_MONOTONIC), mono(libc::CLOCK_MONOTONIC_RAW), uptime());
|
||||
let (dm, dr, du) = (m1 - m0, r1 - r0, u1 - u0);
|
||||
println!("CLOCKPROBE monotonic={dm:.4} raw={dr:.4} uptime={du:.4} ratio_mono={:.5} ratio_raw={:.5}", dm / du, dr / du);
|
||||
println!("CLOCKPROBE abs monotonic={m1:.2} uptime={u1:.2} abs_ratio={:.5}", m1 / u1);
|
||||
}
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
# FULL OTA apply scenario — the loop the desk e2e stops short of: the real
|
||||
# flared inside the VM downloads a real signed tier-1 .wfw whose payload is a
|
||||
# bootable rootfs, verifies it, and ACTUALLY WRITES rootfs_b (safe: it is a
|
||||
# region inside disk.img); the harness then reboots into slot _b and asserts
|
||||
# the applied firmware version is running.
|
||||
#
|
||||
# Documented emulation gaps (ADR-0006): the BCB slot CHOICE and the physical
|
||||
# reset are performed by the harness (cmdline slot + a fresh qemu boot), not
|
||||
# by U-Boot/CRU — those stay bench territory.
|
||||
#
|
||||
# FAILS CLOSED on missing prerequisites.
|
||||
#
|
||||
# Usage: ota-apply.sh <zImage-virt>
|
||||
# Env: FLARE_EDGE path to a flare-edge checkout (mock portal, mk-wfw, dev key)
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
|
||||
QDIR="$(cd "$HERE/.." && pwd)" # qemu/
|
||||
# mkfs.ext4 lives in sbin (not on user PATH on Debian).
|
||||
PATH="$PATH:/usr/sbin:/sbin"
|
||||
|
||||
ZIMAGE="${1:-}"
|
||||
if [ -z "$ZIMAGE" ] || [ ! -f "$ZIMAGE" ]; then
|
||||
echo "FATAL: usage: $0 <zImage> — the virt.fragment kernel variant" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${FLARE_EDGE:-}" ] || [ ! -f "$FLARE_EDGE/tools/mock-flare-portal.py" ]; then
|
||||
echo "FATAL: FLARE_EDGE must point at a flare-edge checkout" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -x "$QDIR/payload/warden-flared" ] || {
|
||||
echo "FATAL: no qemu/payload/warden-flared (needs the WARDEN_HARD_RESET-gated build)" >&2
|
||||
exit 1
|
||||
}
|
||||
command -v qemu-system-arm >/dev/null || {
|
||||
echo "FATAL: qemu-system-arm not on PATH — see qemu/README.md" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
WORK="$(mktemp -d /tmp/wqo.XXXXXX)"
|
||||
QEMU_PID="" MOCK_PID=""
|
||||
cleanup() {
|
||||
if [ -n "$QEMU_PID" ]; then kill "$QEMU_PID" 2>/dev/null || true; fi
|
||||
if [ -n "$MOCK_PID" ]; then kill "$MOCK_PID" 2>/dev/null || true; fi
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
DEVICE_ID="$(python3 -c 'import uuid; print(uuid.uuid4())')"
|
||||
API_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(24))')"
|
||||
PORT=$((20000 + RANDOM % 20000))
|
||||
|
||||
# 0. The offer's payload is a REAL bootable rootfs: the same skeleton the
|
||||
# disk uses, stamped with the NEW version — booting it is the proof.
|
||||
QEMU_DIR="$QDIR"
|
||||
OUT="$QDIR/out"
|
||||
# shellcheck source=qemu/lib.sh disable=SC1091
|
||||
. "$QDIR/lib.sh"
|
||||
qemu_get_busybox
|
||||
qemu_stage_rootfs "$WORK/newroot"
|
||||
printf '0.0.2\n' > "$WORK/newroot/etc/warden-firmware-version"
|
||||
printf 'applied-via-ota\n' > "$WORK/newroot/etc/ota-marker"
|
||||
truncate -s 64M "$WORK/rootfs-payload.img"
|
||||
mkfs.ext4 -F -q -d "$WORK/newroot" "$WORK/rootfs-payload.img"
|
||||
|
||||
FW_SIGNING_KEY_FILE="$FLARE_EDGE/tools/testdata/fw-dev-key.seed" \
|
||||
WARDEN_KERNEL_VERSION=6.18.46 WARDEN_BUILDROOT_VERSION=2025.02 \
|
||||
WARDEN_UBOOT_VERSION=2017.09 \
|
||||
bash "$FLARE_EDGE/tools/mk-wfw.sh" "$WORK/rootfs-payload.img" 1 0.0.2 "$WORK/offer.wfw"
|
||||
|
||||
# 1. mock portal offering it.
|
||||
python3 "$FLARE_EDGE/tools/mock-flare-portal.py" \
|
||||
--port "$PORT" --device "$DEVICE_ID:$API_KEY" \
|
||||
--wfw "$WORK/offer.wfw" > "$WORK/mock.log" 2>&1 &
|
||||
MOCK_PID=$!
|
||||
mock_ready=0
|
||||
for _ in $(seq 1 50); do
|
||||
curl -so /dev/null "http://127.0.0.1:$PORT/" && { mock_ready=1; break; }
|
||||
kill -0 "$MOCK_PID" 2>/dev/null || { echo "FATAL: mock portal died:" >&2; cat "$WORK/mock.log" >&2; exit 1; }
|
||||
sleep 0.2
|
||||
done
|
||||
[ "$mock_ready" = 1 ] || { echo "FATAL: mock never answered on :$PORT" >&2; exit 1; }
|
||||
echo "== mock portal on :$PORT offering 0.0.2 (payload: bootable rootfs, 64M)"
|
||||
|
||||
# 2. image at version 0.0.1, enrolment seeded.
|
||||
bash "$QDIR/mkinitramfs.sh"
|
||||
bash "$QDIR/mkimage.sh" \
|
||||
--portal-url "http://10.0.2.2:$PORT" \
|
||||
--state "flare.device_id=$DEVICE_ID" \
|
||||
--state "flare.api_key=$API_KEY" \
|
||||
--state "flare.site=qemu-devsim" \
|
||||
--fw-version 0.0.1
|
||||
|
||||
# 3. boot slot _a WITH APPLY ENABLED; flared should pull, verify, write
|
||||
# rootfs_b, and surface the (gated) reboot attempt.
|
||||
QEMU_PID=""
|
||||
for _attempt in 1 2 3; do
|
||||
VMBASE=$((20000 + RANDOM % 20000))
|
||||
: > "$WORK/console.log"
|
||||
bash "$QDIR/run.sh" --kernel "$ZIMAGE" --allow-apply \
|
||||
--ssh-port "$VMBASE" --http-port $((VMBASE + 1)) --api-port $((VMBASE + 2)) \
|
||||
> "$WORK/console.log" 2>&1 &
|
||||
QEMU_PID=$!
|
||||
sleep 3
|
||||
kill -0 "$QEMU_PID" 2>/dev/null && break
|
||||
if grep -aq 'Could not set up host forwarding' "$WORK/console.log"; then
|
||||
echo "== hostfwd port collision on base $VMBASE — retrying"
|
||||
QEMU_PID=""
|
||||
continue
|
||||
fi
|
||||
echo "FATAL: VM died at launch:" >&2; tail -20 "$WORK/console.log" >&2; exit 1
|
||||
done
|
||||
if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then
|
||||
echo "FATAL: could not launch the VM after 3 port attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. wait for the apply to conclude. flared logs to its in-guest file, not
|
||||
# the console — but the next check-in REPORTS the outcome to the portal:
|
||||
# detail "hard reset failed after slot flip" is the exact post-apply state
|
||||
# under the gated reset (write done, AvbABData flipped, reboot refused).
|
||||
deadline=$((SECONDS + 420))
|
||||
staged=0
|
||||
while [ $SECONDS -lt $deadline ]; do
|
||||
grep -aq 'hard reset failed after slot flip' "$WORK/mock.log" && { staged=1; break; }
|
||||
grep -aq 'WARDEN-QEMU-MOUNT-FAILED' "$WORK/console.log" && {
|
||||
echo "FATAL: guest mount failed" >&2; exit 1; }
|
||||
kill -0 "$QEMU_PID" 2>/dev/null || {
|
||||
echo "FATAL: VM exited early" >&2; tail -30 "$WORK/console.log" >&2; exit 1; }
|
||||
sleep 3
|
||||
done
|
||||
[ "$staged" = 1 ] || {
|
||||
echo "FATAL: apply never reached the post-flip state within 420s; tails:" >&2
|
||||
tail -20 "$WORK/console.log" >&2
|
||||
tail -10 "$WORK/mock.log" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -aq "GET /api/v1/devices/$DEVICE_ID/firmware/assets/.* -> 200" "$WORK/mock.log" || {
|
||||
echo "FATAL: staged without a portal asset download?!" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "== apply staged (asset downloaded, rootfs_b written, reset gated) — rebooting into _b"
|
||||
kill "$QEMU_PID" 2>/dev/null || true
|
||||
wait "$QEMU_PID" 2>/dev/null || true
|
||||
QEMU_PID=""
|
||||
|
||||
# 5. the harness performs the "reboot": boot slot _b, assert the OTA'd rootfs
|
||||
# is what runs.
|
||||
{ sleep 40; printf 'cat /etc/warden-firmware-version /etc/ota-marker\n'; sleep 3; printf 'poweroff -f\n'; sleep 8; } | \
|
||||
timeout 180 bash "$QDIR/run.sh" --kernel "$ZIMAGE" --slot _b --shell \
|
||||
--ssh-port 0 --http-port 0 --api-port 0 \
|
||||
> "$WORK/boot-b.log" 2>&1 || true
|
||||
grep -aq 'WARDEN-QEMU-ROOTFS-OK slot=_b' "$WORK/boot-b.log" || {
|
||||
echo "FATAL: slot _b did not boot; tail:" >&2; tail -25 "$WORK/boot-b.log" >&2; exit 1
|
||||
}
|
||||
grep -aq '^0.0.2' "$WORK/boot-b.log" || {
|
||||
echo "FATAL: _b is not running the applied 0.0.2 firmware" >&2
|
||||
grep -a 'warden-firmware-version' -A2 "$WORK/boot-b.log" >&2 || true
|
||||
exit 1
|
||||
}
|
||||
grep -aq 'applied-via-ota' "$WORK/boot-b.log" || {
|
||||
echo "FATAL: OTA marker missing on _b" >&2; exit 1
|
||||
}
|
||||
echo "OTA-APPLY-PASS: 0.0.1 -> 0.0.2 applied over the air and booted from slot _b"
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# Real-image milestone: boot an ACTUAL flare-edge build (rootfs.img + oem.img
|
||||
# matched pair) in the VM on the 6.18 kernel and assert its own init chain
|
||||
# reaches multi-user: the vendor rcS runs, real warden daemons start, and a
|
||||
# getty answers on the console.
|
||||
#
|
||||
# Documented caveats (this is a fidelity milestone, not full parity): the
|
||||
# RV1106-only init steps degrade on virt (backlight, goodix, npu, the 5.10
|
||||
# /oem modules fail vermagic), and binaries older than the flare-edge #106
|
||||
# fix reproduce that crash faithfully. Interactive login uses the image's own
|
||||
# credentials — deliberately not recorded here.
|
||||
#
|
||||
# FAILS CLOSED on missing prerequisites.
|
||||
#
|
||||
# Usage: real-image-boot.sh <zImage> <rootfs.img> <oem.img>
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
|
||||
QDIR="$(cd "$HERE/.." && pwd)" # qemu/
|
||||
|
||||
ZIMAGE="${1:-}"; ROOTFS="${2:-}"; OEM="${3:-}"
|
||||
for f in "$ZIMAGE" "$ROOTFS" "$OEM"; do
|
||||
if [ -z "$f" ] || [ ! -f "$f" ]; then
|
||||
echo "FATAL: usage: $0 <zImage> <rootfs.img> <oem.img> — '$f' missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
command -v qemu-system-arm >/dev/null || {
|
||||
echo "FATAL: qemu-system-arm not on PATH — see qemu/README.md" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
WORK="$(mktemp -d /tmp/wqr.XXXXXX)"
|
||||
QEMU_PID=""
|
||||
cleanup() {
|
||||
if [ -n "$QEMU_PID" ]; then kill "$QEMU_PID" 2>/dev/null || true; fi
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
bash "$QDIR/mkinitramfs.sh"
|
||||
bash "$QDIR/mkimage.sh" --rootfs-image "$ROOTFS" --oem-image "$OEM"
|
||||
|
||||
for _attempt in 1 2 3; do
|
||||
PORT=$((23000 + RANDOM % 20000))
|
||||
: > "$WORK/console.log"
|
||||
bash "$QDIR/run.sh" --kernel "$ZIMAGE" \
|
||||
--ssh-port "$PORT" --http-port $((PORT + 1)) --api-port $((PORT + 2)) \
|
||||
> "$WORK/console.log" 2>&1 &
|
||||
QEMU_PID=$!
|
||||
sleep 3
|
||||
kill -0 "$QEMU_PID" 2>/dev/null && break
|
||||
if grep -aq 'Could not set up host forwarding' "$WORK/console.log"; then
|
||||
echo "== hostfwd port collision on base $PORT — retrying"
|
||||
QEMU_PID=""
|
||||
continue
|
||||
fi
|
||||
echo "FATAL: VM died at launch:" >&2; tail -20 "$WORK/console.log" >&2; exit 1
|
||||
done
|
||||
if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then
|
||||
echo "FATAL: could not launch the VM after 3 port attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
deadline=$((SECONDS + 180))
|
||||
ok_switch=0 ok_daemons=0 ok_getty=0
|
||||
while [ $SECONDS -lt $deadline ]; do
|
||||
grep -aq 'rc: switching root to rootfs_a' "$WORK/console.log" && ok_switch=1
|
||||
[ "$(grep -ac 'Starting warden-' "$WORK/console.log")" -ge 2 ] && ok_daemons=1
|
||||
grep -aq 'login:' "$WORK/console.log" && ok_getty=1
|
||||
[ $ok_switch -eq 1 ] && [ $ok_daemons -eq 1 ] && [ $ok_getty -eq 1 ] && break
|
||||
kill -0 "$QEMU_PID" 2>/dev/null || {
|
||||
echo "FATAL: VM exited early" >&2; tail -30 "$WORK/console.log" >&2; exit 1; }
|
||||
sleep 3
|
||||
done
|
||||
|
||||
fail=0
|
||||
[ $ok_switch -eq 1 ] || { echo "FAIL: never switch_rooted into the real image"; fail=1; }
|
||||
[ $ok_daemons -eq 1 ] || { echo "FAIL: the image's own init never started warden daemons"; fail=1; }
|
||||
[ $ok_getty -eq 1 ] || { echo "FAIL: no getty login prompt on the console"; fail=1; }
|
||||
[ $fail -eq 0 ] || { tail -25 "$WORK/console.log" >&2; exit 1; }
|
||||
echo "REAL-IMAGE-BOOT-PASS: the flare-edge image reached multi-user on the VM"
|
||||
Reference in New Issue
Block a user