From 641612650c9063efb039a20164d86a38a5c3627b Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Mon, 3 Aug 2026 23:52:16 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(os):=20the=20migration=20hold=20?= =?UTF-8?q?=E2=80=94=20chain=20services=20start=20only=20after=20the=20slo?= =?UTF-8?q?t=20commits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deadlock rule's automatic-fallback half (the floor guard shipped earlier): a data_migration bundle's forward-only lmdb migration must not run before the A/B commit decision, or a failed health check leaves a box that can neither commit nor cleanly fall back. os-update now leaves a version-stamped marker on /data when a flagged bundle installs (and clears a stale one on a plain install). On the next boot, pithead-boot sees a marker matching its own version and brings the stack up without the chain services — monerod, tari and their wallets, the lmdb holders and the only containers anything depends on together — via PITHEAD_HOLD_CHAIN=1, which subsets the compose service list. doctor reads the same marker and judges the deliberately-held chain containers by the miners' sync-hold rule, so the commit gate gates on everything that IS running instead of deadlocking on the hold it gates. Post-commit the marker is consumed and a plain up starts the chain: the migration runs on a slot a fallback can no longer leave. A mismatched marker is a fallback boot onto untouched data and holds nothing. Found live while deploying: the appliance image ships no unsquashfs, so rauc info could not read any bundle's manifest and every os-update guard was dead on the appliance — CI never saw it because the stack tests stub rauc. squashfs-tools joins the rootfs shell-out set. The KVM battery's provision phase grows a migration leg: build a flagged bundle, install through the real os-update, and prove from the boot journal and a podman poll that monerod never runs pre-commit, starts post-release, and the marker is consumed. The db_schema manifest field stays out until something reads it. Co-Authored-By: Claude Fable 5 --- docs/dev/appliance-release.md | 22 +++++---- docs/dev/appliance-wizard.md | 15 +++--- os/overlay/pithead-boot | 35 +++++++++++++- os/rootfs/Dockerfile | 6 ++- pithead | 81 +++++++++++++++++++++++++++++--- tests/os/README.md | 4 +- tests/os/run.sh | 87 +++++++++++++++++++++++++++++++++++ tests/stack/run.sh | 79 +++++++++++++++++++++++++++++++ 8 files changed, 303 insertions(+), 26 deletions(-) diff --git a/docs/dev/appliance-release.md b/docs/dev/appliance-release.md index ecae7287..aeb80d7c 100644 --- a/docs/dev/appliance-release.md +++ b/docs/dev/appliance-release.md @@ -120,14 +120,20 @@ PITHEAD_DATA_MIGRATION=true PITHEAD_MIN_OS_VERSION=X.Y.Z os/rauc/mkbundle.sh ... migration — normally this release's own version. `mkbundle.sh` refuses to build a migrating bundle without it. -**Scoped follow-up — the migration runner.** Recording and enforcing the floor is done; -*executing* the forward-only migration is not. The remaining half of the deadlock rule -(`dual-distribution-plan.md` risk #6) is `pithead-boot` withholding the chain services -(monerod/tari) until the slot commits on a `data_migration` update, so automatic A/B -fallback stays pre-migration. Until that lands, the floor guard above prevents the *manual* -rollback path from stranding data; the automatic-fallback ordering is bench (tier-4) work, -asserted by `tests/os/run.sh --phase update`. The `db_schema` field the plan also lists -lands with that runner — nothing reads it yet. +**The migration hold — how a flagged update boots.** Installing a `data_migration` bundle +also leaves a marker on `/data` (`.os-migration-pending`, stamped with the bundle's +version). On the next boot, `pithead-boot` sees a marker matching its own version and +brings the stack up **without** the chain services (monerod, tari, and their wallets — the +holders of forward-only lmdb migrations): the A/B commit decision is made on everything +else first. `doctor` reads the same marker and judges the deliberately-held chain +containers by the sync-hold rule, so the commit gate gates on what is running instead of +deadlocking on the hold. Only after `mark-good` does the boot path remove the marker and +run a plain `up` — the chain services start, and the migration runs on a slot a fallback +can no longer leave. If health fails instead, the slot stays uncommitted, the machine +falls back, and the old OS boots normally — its data was never touched (the old boot path +ignores a marker for a version it isn't). A non-migrating install clears any stale marker. +The `db_schema` field the plan also lists stays out until something reads it — an +unread manifest field is a claim, not a contract. ## Development loop diff --git a/docs/dev/appliance-wizard.md b/docs/dev/appliance-wizard.md index a45e7a24..09b8077f 100644 --- a/docs/dev/appliance-wizard.md +++ b/docs/dev/appliance-wizard.md @@ -242,14 +242,13 @@ Five steps, each answering a hardware-validated failure: `hugepages_reserve_extra_mb` — RigForge's grow-only sysctl then sizes the shared pool as the single writer, and pithead's own HugePages write never shrinks a grown pool back. -**Known gap at step 4 — forward-only data migrations.** Today `up` (step 3) starts the whole -stack, monerod and tari included, *before* the commit at step 4. A release that runs a -forward-only lmdb migration would therefore migrate `/data` before the slot commits, so a -failed health check could leave the box unable to commit *or* fall back cleanly. The -migration-deadlock rule (`dual-distribution-plan.md` risk #6) closes this by withholding the -chain services until the slot commits on a `data_migration`-flagged update; that boot-path -change is scoped follow-up. Until it lands, `pithead os-update` already refuses a *manual* -rollback below the `/data` migration floor — see +**Step 4 and forward-only data migrations.** On the first boot of a `data_migration`-flagged +update, `up` (step 3) deliberately withholds the chain services — monerod, tari and their +wallets, the holders of forward-only lmdb migrations — so the commit at step 4 is decided +before any migration touches `/data`, and a failed health check still falls back onto data +the old OS can read. The chain services start, and the migration runs, only after the slot +commits. `pithead os-update` separately refuses a *manual* rollback below the `/data` +migration floor — both halves are described in [`appliance-release.md`](appliance-release.md#compatibility-metadata-and-the-data-migration-floor). **Rule for changes:** anything generated from `config.json` or the program is derived and must diff --git a/os/overlay/pithead-boot b/os/overlay/pithead-boot index 26d31b4d..d5c5a2ba 100755 --- a/os/overlay/pithead-boot +++ b/os/overlay/pithead-boot @@ -60,7 +60,27 @@ fi ./pithead load-images ./pithead render || exit 1 -./pithead up || exit 1 + +# The migration hold (#851): os-update leaves a marker when the installed bundle declares a +# forward-only /data migration, stamped with that bundle's version. A marker matching THIS +# slot's version means this is the migrating bundle's pre-commit boot — the chain services +# (the holders of irreversible lmdb migrations) must not start until the commit decision is +# made, so automatic A/B fallback always lands on data the old OS can still read. A marker +# that does not match is a fallback boot (the migrating slot failed health): the migration +# never ran, the data is untouched, and this boot proceeds normally. doctor reads the same +# marker and judges the deliberately-held chain containers by the sync-hold rule, so the +# commit gate below gates on everything that IS running instead of deadlocking on the hold. +hold_chain=0 +if [ -f .os-migration-pending ] && + [ "$(tr -d '[:space:]' <.os-migration-pending)" = "$(tr -d '[:space:]' /dev/null)" ]; then + hold_chain=1 +fi + +if [ "$hold_chain" = 1 ]; then + PITHEAD_HOLD_CHAIN=1 ./pithead up || exit 1 +else + ./pithead up || exit 1 +fi for _ in $(seq 90); do # localhost, not 127.0.0.1: the bare IP is not in the Caddyfile's site list, so it hits @@ -74,6 +94,19 @@ for _ in $(seq 90); do if [ "${code:-000}" != "000" ] && ./pithead doctor --json >/dev/null 2>&1; then command -v rauc >/dev/null 2>&1 && rauc status mark-good echo "pithead-boot: stack is serving (HTTP $code) and doctor reports healthy — booted slot committed" + # Post-commit, the held chain services start and the forward migration runs NOW — the + # slot is committed, so a fallback can no longer strand migrated data. Marker removal + # comes first: if the chain start fails, the slot stays committed, the next boot takes + # the normal path and retries a plain `up` — re-holding would gate on a commit that has + # already happened. `|| true` for the same reason: a chain service that cannot start is + # a fault to alert on, never grounds to fail a boot the commit already accepted. + if [ "$hold_chain" = 1 ]; then + rm -f .os-migration-pending + # Logged BEFORE the up: the release line marks the commit boundary for the journal + # (and the tier-4 battery), and the up that starts the chain can take minutes. + echo "pithead-boot: slot committed — chain services released, the data migration runs now" + ./pithead up || true + fi # The on-box miner comes LAST, after the slot commit: it needs the stack's stratum # listening, and a miner that cannot start must never delay or block the commit — the # stack serving is the product's health, the miner is a passenger. Idempotent every diff --git a/os/rootfs/Dockerfile b/os/rootfs/Dockerfile index fc5bce22..7af45757 100644 --- a/os/rootfs/Dockerfile +++ b/os/rootfs/Dockerfile @@ -23,7 +23,10 @@ ENV DEBIAN_FRONTEND=noninteractive # program container networking, and pithead-install calls sgdisk (gdisk), partprobe (parted), # jq, lsblk, mkfs and the grub tools ON THE APPLIANCE — a tool the build host happens to have is # not a tool the image has. Each absence surfaces at runtime, so the set is audited against the -# built image rather than added one failure at a time. +# built image rather than added one failure at a time. squashfs-tools is in that set too: +# `rauc info` extracts a bundle's manifest with unsquashfs, and without it `pithead os-update` +# cannot read any bundle's compatibility metadata — every guard it carries was dead on the +# appliance (found live on the bench, not by CI, whose stack tests stub rauc). RUN apt-get update && apt-get install -y --no-install-recommends \ systemd systemd-sysv systemd-timesyncd systemd-resolved dbus udev \ linux-image-amd64 initramfs-tools zstd \ @@ -33,6 +36,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ fdisk gdisk parted e2fsprogs dosfstools \ nftables iptables systemd-repart \ avahi-daemon libnss-mdns \ + squashfs-tools \ openssh-server sudo less nano htpdate # NB: apt lists are kept on purpose — the Rugix core recipes run `apt-get install` in the build # chroot (fdisk/parted etc.) and need resolvable metadata; deleting them fails the bake. diff --git a/pithead b/pithead index 1552c5d1..4c0dc240 100755 --- a/pithead +++ b/pithead @@ -230,7 +230,25 @@ stack_up() { # proceeds (signing is opt-in, #461), a present key that fails aborts before anything starts. verify_release_images # Docker Compose automatically picks up COMPOSE_PROFILES from .env - if ! compose_up_checked -d; then + # + # PITHEAD_HOLD_CHAIN=1 is the migration hold (#851), set only by the appliance boot path on + # the first boot of a data_migration bundle: bring up everything EXCEPT the chain services + # (monerod/tari and their wallets — the holders of forward-only lmdb migrations, and the + # only containers anything depends on together), so the A/B commit decision is made before + # any migration touches /data. The chain services start with a plain `up` after the commit. + if [ "${PITHEAD_HOLD_CHAIN:-0}" = 1 ]; then + local services c + local filter=() + for c in $REVENUE_CHAIN_CONTAINERS; do filter+=(-e "$c"); done + services=$(docker compose config --services 2>/dev/null) + [ -n "$services" ] || error "Could not list compose services for the chain hold." + log "Data migration pending — holding chain services ($REVENUE_CHAIN_CONTAINERS) until the slot commits." + services=$(printf '%s\n' "$services" | grep -vxF "${filter[@]}") + # shellcheck disable=SC2086 # word-splitting the service list is the point + if ! compose_up_checked -d $services; then + error "Stack failed to start — see the error above." + fi + elif ! compose_up_checked -d; then error "Stack failed to start — see the error above." fi log "Stack started successfully!" @@ -2452,6 +2470,23 @@ os_raise_data_floor() { # $1: new floor — raise it, never lower (a migration o fi } +# The migration-pending marker (#851): written by os-update when a data_migration bundle installs, +# holding the version that bundle carries. On the next boot, pithead-boot and doctor read it back: +# a marker matching the RUNNING version means "this boot must not start the chain services until +# the slot commits" — the lmdb migration only runs once A/B fallback can no longer need the +# pre-migration data. A marker that does NOT match the running version is a fallback boot (the +# migrating slot failed health and the old OS is back); the old data was never touched, so it is +# ignored. pithead-boot removes the marker once the migrating slot commits. +os_migration_marker_file() { printf '%s' "${PITHEAD_MIGRATION_MARKER_FILE:-/data/pithead/.os-migration-pending}"; } + +os_migration_hold_active() { # rc 0 when this boot is the held, pre-commit boot of a migrating bundle + local f v + f=$(os_migration_marker_file) + [ -f "$f" ] || return 1 + v=$(tr -d ' \t\r\n' <"$f" 2>/dev/null) + [ -n "$v" ] && [ "$v" = "$(os_running_version)" ] +} + os_update_needs_confirmation() { # $1: running variant, $2: bundle variant — rc 0 = confirm first # Consent is needed whenever an install flips the box's shell/SSH posture, in EITHER direction, # or when the bundle's posture can't be verified. A debug image bakes a standing root @@ -2561,11 +2596,20 @@ os_update() { # A data_migration bundle raises the /data floor so a later rollback below it is refused. Armed # at install (conservative: before the migration actually runs on the new slot's next boot) — # the safe direction, and the only floor pithead can guarantee without executing the migration - # itself. Executing the forward-only migration + withholding chain services until the slot - # commits is the remaining half of the deadlock rule, tracked as follow-up (see appliance-release.md). + # itself. The marker beside it is the other half of the deadlock rule (#851): the next boot + # reads it and withholds the chain services until the slot commits, so automatic A/B fallback + # always lands on pre-migration data. A NON-migrating install clears any stale marker — it + # supersedes a migrating install that never booted, and its own boot must not hold anything. + local marker + marker=$(os_migration_marker_file) if [ "$bundle_migrates" = "true" ] && os_semver_ok "$bundle_min"; then os_raise_data_floor "$bundle_min" log "Recorded /data migration floor: OS >= $bundle_min required to read chain data after this update commits." + mkdir -p "$(dirname "$marker")" 2>/dev/null || true + printf '%s\n' "$bundle_version" >"$marker" + log "Marked a data migration pending: the next boot starts the chain services only after the new slot commits." + else + rm -f "$marker" fi } @@ -3625,8 +3669,13 @@ REVENUE_MINER_CONTAINERS="p2pool xmrig-proxy" # Prints "ok" or "fail:". Kept pure — no engine calls — so the commit gate's honesty is # unit-testable without a running stack (tests/stack/run.sh). A non-revenue name is always "ok": # the rest of doctor covers those, and this must not judge containers outside its remit. -revenue_container_verdict() { # - local name="$1" state="$2" status="$3" running=0 +# +# chain_hold=1 is the migration hold (#851): pithead-boot deliberately withholds the chain +# containers until the slot commits, so — exactly like the miners' sync hold — a DOWN chain node +# is then the expected state, not a crash, and only a running-but-unhealthy one is a fault. +# Without this arm the commit gate would fail on the very hold it is gating, a deadlock. +revenue_container_verdict() { # [chain_hold] + local name="$1" state="$2" status="$3" chain_hold="${4:-0}" running=0 # "running" from EITHER signal: podman and docker both print an "Up …" status for a live # container, and `.State` is "running". Reading both is belt-and-suspenders — some docker CLI # versions leave the `.State` ps field empty, and a chain node judged down on that alone would @@ -3638,6 +3687,18 @@ revenue_container_verdict() { # fi case " $REVENUE_CHAIN_CONTAINERS " in *" $name "*) + # Under the migration hold, judge a chain node by the miners' rule: down is deliberate. + if [ "$chain_hold" = 1 ]; then + case "$status" in + *'(unhealthy)'*) + if [ "$running" = 1 ]; then + printf 'fail:%s is running but unhealthy (%s)\n' "$name" "$status" + else printf 'ok\n'; fi + ;; + *) printf 'ok\n' ;; + esac + return + fi # Chain node: it must be running and PAST its healthcheck. "starting"/"unhealthy" and any # stopped/exited/created state all mean not-ready-or-crashed. The boot gate loops, so a node # that is only starting simply gets retried rather than committed early. @@ -3680,15 +3741,21 @@ revenue_container_verdict() { # # honest gate: it rejects the healthy-looking-but-dead slot while staying quiet on the sync hold and # the long initial sync. Engine-aware (docker on DIY, podman on the appliance) and read-only. check_revenue_containers() { - local engine rows name state status verdict failed=0 + local engine rows name state status verdict failed=0 chain_hold=0 engine=$(container_engine) command -v "$engine" >/dev/null 2>&1 || return 0 rows=$("$engine" ps -a --format '{{.Names}}\t{{.State}}\t{{.Status}}' 2>/dev/null) || return 0 [ -n "$rows" ] || return 0 + # The migration hold (#851): while pithead-boot withholds the chain services pre-commit, + # their absence is deliberate — the gate must not deadlock on the hold it is gating. + if os_migration_hold_active; then + chain_hold=1 + dr_info "A data migration is pending — chain services are deliberately held until this slot commits." + fi # A here-string (not a pipe) keeps the loop in this shell, so `failed` survives it. while IFS=$'\t' read -r name state status; do [ -n "$name" ] || continue - verdict=$(revenue_container_verdict "$name" "$state" "$status") + verdict=$(revenue_container_verdict "$name" "$state" "$status" "$chain_hold") case "$verdict" in fail:*) dr_fail "${verdict#fail:}" diff --git a/tests/os/README.md b/tests/os/README.md index 853d80d9..37cf5900 100644 --- a/tests/os/README.md +++ b/tests/os/README.md @@ -37,7 +37,9 @@ runbook in [`docs/dev/release-server.md`](../../docs/dev/release-server.md). served, Tor-only egress actually enforced, built-in miner up. This is the phase that catches an appliance whose engine cannot run the product. Then the stack must return from a reboot with no hands on it, and the real commit gate — `pithead doctor --json` — must pass on that healthy - stack yet refuse once a revenue service is down. + stack yet refuse once a revenue service is down. The closing leg installs a `data_migration` + bundle through `pithead os-update` and proves the migration hold: the chain services stay down + until the slot commits, then start, with the pending marker consumed. - **rig** — answer `RigForge` on the same page and prove the other machine this image installs: it mines from the baked binary with no compile and no clearnet, starts no containers at all, and takes an A/B update — install, uncommitted rollback, self-commit, persistence — exactly diff --git a/tests/os/run.sh b/tests/os/run.sh index c65a5fa7..3932ea73 100755 --- a/tests/os/run.sh +++ b/tests/os/run.sh @@ -1424,6 +1424,93 @@ phase_provision() { fi _ssh "podman start monerod >/dev/null 2>&1" || true unset -f _gate + + # ---- migration hold (#851): a data_migration update starts the chain only POST-commit ---- + # The deadlock rule's automatic-fallback half: on the first boot of a flagged bundle, + # pithead-boot must bring the stack up WITHOUT the chain services, commit on that reduced + # stack, and only then start monerod — so a failed health check still falls back onto /data + # the old OS can read. The journal lines are the race-free evidence (the hold and the + # release are both logged); the podman poll additionally proves monerod never ran while the + # slot was uncommitted. + info "migration leg — build a data_migration bundle, install via os-update, boot it" + local mig_bundle + mig_bundle=$(PITHEAD_DATA_MIGRATION=true PITHEAD_MIN_OS_VERSION="$(tr -d ' \n' /dev/null" | tr -d ' \r\n') + if [ -n "$marker" ]; then + ok "os-update left the migration-pending marker ($marker)" + else + bad "no migration-pending marker after installing a data_migration bundle" + return + fi + _ssh reboot || true + sleep 10 + _wait_ssh 300 || { + bad "guest never returned after booting the migration bundle" + return + } + # Poll through the boot. The release line is logged at the commit boundary, BEFORE the + # post-commit up — so any monerod observed running before that line is a chain service + # beating the fallback decision, the exact ordering this rule exists to forbid. + local chain_ran_early=0 released=0 + for _ in $(seq 120); do + if _ssh "journalctl -u pithead-boot -b 2>/dev/null | grep -q 'chain services released'"; then + released=1 + break + fi + if _ssh "podman ps --format '{{.Names}}' 2>/dev/null | grep -qx monerod"; then + chain_ran_early=1 + fi + sleep 5 + done + if [ "$released" = 1 ]; then + ok "the migrating slot committed and released the chain services" + else + bad "the migrating slot never reached the post-commit release — the hold deadlocked the gate it was built not to" + return + fi + if [ "$chain_ran_early" = 0 ]; then + ok "monerod never ran while the slot was uncommitted" + else + bad "monerod ran BEFORE the commit — the migration would beat the fallback decision" + fi + if _ssh "journalctl -u pithead-boot -b | grep -q 'holding chain services'"; then + ok "boot journal shows the chain hold" + else + bad "no 'holding chain services' line in the boot journal — the hold path never ran" + fi + # After the release: monerod back up, marker consumed. + local mig_node_up=0 + for _ in $(seq 60); do + if _ssh "podman ps --format '{{.Names}}' 2>/dev/null | grep -qx monerod"; then + mig_node_up=1 + break + fi + sleep 5 + done + if [ "$mig_node_up" = 1 ]; then + ok "monerod is running again post-commit (the migration window is over)" + else + bad "monerod never came back after the commit" + fi + if _ssh "test -f /data/pithead/.os-migration-pending"; then + bad "the migration-pending marker survived the commit" + else + ok "the migration-pending marker was consumed" + fi } phase_rig() { diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 6635b321..3a746175 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -4051,6 +4051,41 @@ printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","n out="$(cd "$V" && DOCKER_LOG="$DOCKER_LOG" PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" assert_eq "tari mem_limit explicit propagated" "$(run_sourced "$V" env_get_file "$V/.env" TARI_MEM_LIMIT)" "3072m" +echo "== black-box: 'pithead up' under the migration hold starts everything but the chain (#851) ==" +# PITHEAD_HOLD_CHAIN=1 is set by the appliance boot path on the first boot of a data_migration +# bundle: the chain services (the lmdb holders) must not start before the A/B slot commits. The +# compose service list comes from a dedicated stub because the shared one answers nothing for +# `compose config --services`, and stack_status's tests rely on exactly that. +HCB="$SANDBOX/hold-chain-bin" +mkdir -p "$HCB" +cat >"$HCB/docker" <<'EOF' +#!/usr/bin/env bash +echo "[docker] $*" >> "${DOCKER_LOG:-/dev/null}" +case "$*" in +"compose config --services") printf 'tor\nmonerod\ntari\nwallet-rpc\ntari-wallet\np2pool\nxmrig-proxy\ncaddy\ndashboard\n' ;; +esac +exit 0 +EOF +chmod +x "$HCB/docker" +HOLD_LOG=$(mktemp) +seed_env +out="$(cd "$V" && DOCKER_LOG="$HOLD_LOG" PATH="$HCB:$V/bin:$PATH" PITHEAD_HOLD_CHAIN=1 ./pithead up 2>&1)" +assert_rc "up succeeds under the hold" "$?" "0" +assert_contains "the hold is announced for the journal" "$out" "holding chain services" +up_line=$(grep "compose up" "$HOLD_LOG" | tail -1) +assert_contains "tor still starts under the hold" "$up_line" "tor" +assert_contains "p2pool still starts under the hold" "$up_line" "p2pool" +assert_contains "the dashboard still starts under the hold" "$up_line" "dashboard" +assert_not_contains "monerod is withheld" "$up_line" "monerod" +assert_not_contains "tari and tari-wallet are withheld" "$up_line" "tari" +assert_not_contains "wallet-rpc is withheld" "$up_line" "wallet-rpc" +# Without the env the same sandbox starts the whole stack — the hold is opt-in per boot. +HOLD_LOG2=$(mktemp) +(cd "$V" && DOCKER_LOG="$HOLD_LOG2" PATH="$HCB:$V/bin:$PATH" ./pithead up >/dev/null 2>&1) +up_line2=$(grep "compose up" "$HOLD_LOG2" | tail -1) +assert_not_contains "a plain up names no service subset" "$up_line2" "p2pool" +rm -f "$HOLD_LOG" "$HOLD_LOG2" + # Healthchecks.io (#79): absent => no ping URL (off). seed_env printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"mini"}, "dashboard":{"secure":false,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json" @@ -8705,6 +8740,15 @@ assert_eq "xmrig-proxy down (sync hold) -> ok" "$(rcv xmrig-proxy exited 'Exited # Non-revenue containers are out of scope — the rest of doctor covers them. assert_eq "caddy (not revenue) -> ok" "$(rcv caddy running 'Up 5 minutes')" "ok" assert_eq "dashboard (not revenue) -> ok" "$(rcv dashboard running 'Up 5 minutes (healthy)')" "ok" +# The migration hold (#851): with chain_hold=1 a chain node is judged by the miners' rule — the +# boot path is deliberately withholding it, so down is expected and the commit must not deadlock +# on the very hold it gates. A RUNNING-but-unhealthy chain node is still a fault. +assert_eq "monerod down under the migration hold -> ok" "$(rcv monerod exited 'Exited (0) 1 minute ago' 1)" "ok" +assert_eq "tari never created under the migration hold -> ok" "$(rcv tari created 'Created' 1)" "ok" +assert_eq "wallet-rpc down under the migration hold -> ok" "$(rcv wallet-rpc exited 'Exited (0) 2 minutes ago' 1)" "ok" +assert_contains "monerod running+unhealthy under the hold -> still fail" "$(rcv monerod running 'Up 2 minutes (unhealthy)' 1)" "fail:monerod" +assert_eq "monerod up+healthy under the hold -> ok (an early manual start is not a fault)" "$(rcv monerod running 'Up 5 minutes (healthy)' 1)" "ok" +assert_contains "the hold changes nothing for a miner" "$(rcv p2pool running 'Up 30 seconds (unhealthy)' 1)" "fail:p2pool" unset -f rcv echo "== unit: pithead-sync's rigforge leg — program replaced, state preserved, prebuilt seeded ==" @@ -8956,6 +9000,7 @@ ourun_v() { # [os-update args...] source "$STACK" set +e PITHEAD_VERSION="$rv" PITHEAD_DATA_FLOOR_FILE="$ff" \ + PITHEAD_MIGRATION_MARKER_FILE="${MARKER_FILE:-$OUSB/marker-scratch}" \ RAUC_INFO_JSON="$ij" PITHEAD_VARIANT_FILE="$OUSB/variant-release" os_update "$@" /dev/null 2>&1 assert_eq "a non-migration bundle records no floor" "$([ -f "$FN" ] && echo present || echo absent)" "absent" +# #851 marker lifecycle: a migrating install leaves the pending marker (stamped with the bundle's +# version) for the next boot's chain hold; a non-migrating install clears a stale one — it +# supersedes a migrating install that never booted. +MK="$OUSB/marker-mig" +rm -f "$MK" +MARKER_FILE="$MK" ourun_v "1.10.0" "$OUSB/floor-scratch" "$OUSB/info-mig.json" bundle.raucb >/dev/null 2>&1 +assert_eq "a data_migration install writes the pending marker with the bundle version" "$(tr -d ' \n' <"$MK" 2>/dev/null)" "1.17.0" +MARKER_FILE="$MK" ourun_v "1.10.0" "$OUSB/floor-scratch" "$OUSB/info-1170.json" bundle.raucb >/dev/null 2>&1 +assert_eq "a non-migrating install clears a stale pending marker" "$([ -f "$MK" ] && echo present || echo absent)" "absent" + +# The hold query the boot path and doctor share: active only when the marker matches the RUNNING +# version — a mismatched marker is a fallback boot onto untouched data and must not hold anything. +omh() { # + local mf="$OUSB/marker-q" + rm -f "$mf" + [ "$1" != "ABSENT" ] && printf '%s\n' "$1" >"$mf" + ( + cd "$OUSB" || exit + # shellcheck disable=SC1090 + source "$STACK" + set +e + PITHEAD_MIGRATION_MARKER_FILE="$mf" PITHEAD_VERSION="$2" os_migration_hold_active + ) +} +omh "1.17.0" "1.17.0" +assert_rc "hold active: marker matches the running version" "$?" "0" +omh "1.17.0" "1.16.0" +assert_rc "no hold: marker for another version (fallback boot)" "$?" "1" +omh "ABSENT" "1.17.0" +assert_rc "no hold: no marker" "$?" "1" +omh "" "1.17.0" +assert_rc "no hold: empty marker is not a version match" "$?" "1" +unset -f omh + # Fail-closed: a version the comparator can't parse is NOT proof of safety. Releases DO use -prep # tags, so a pre-release bundle must not silently bypass the downgrade guard by parsing as "0". printf '{"manifest":{"meta":{"pithead":{"variant":"release","version":"1.17.0-prep","data_migration":"false","minimum_os_version":""}}}}\n' >"$OUSB/info-prep.json" From adf0a86473742a9ae90515f3039ac201accca5dc Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Tue, 4 Aug 2026 00:28:15 -0500 Subject: [PATCH 2/3] test(os): print os-update's output when the migration leg fails The first run of the leg failed exactly where the output was discarded. Co-Authored-By: Claude Fable 5 --- tests/os/run.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/os/run.sh b/tests/os/run.sh index 3932ea73..b250d963 100755 --- a/tests/os/run.sh +++ b/tests/os/run.sh @@ -1445,10 +1445,12 @@ phase_provision() { # os-update is the path that writes the pending marker (a bare rauc install does not) — and # this is also the first tier-4 exercise of os-update against a REAL bundle: it needs # unsquashfs on the appliance to read the manifest back, which CI's stubbed rauc never shows. - _ssh "cd /data/pithead && ./pithead os-update /data/update.bundle --yes" || { + local ou_out + if ! ou_out=$(_ssh "cd /data/pithead && ./pithead os-update /data/update.bundle --yes 2>&1"); then + printf ' os-update output: %s\n' "$(printf '%s' "$ou_out" | tail -8)" bad "pithead os-update failed on the guest (manifest unreadable, or a guard misfired)" return - } + fi marker=$(_ssh "cat /data/pithead/.os-migration-pending 2>/dev/null" | tr -d ' \r\n') if [ -n "$marker" ]; then ok "os-update left the migration-pending marker ($marker)" From 359ab52b93d4d905dc0801105269ff3de8a3084d Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Tue, 4 Aug 2026 01:02:47 -0500 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20read=20bundle=20meta=20from=20rauc's?= =?UTF-8?q?=20shell=20output=20=E2=80=94=20the=20JSON=20format=20omits=20i?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tier-4 migration leg's first live run refuted os_bundle_meta: RAUC 1.11's --output-format=json omits the manifest's [meta.*] sections entirely, so every real bundle read as unstamped and os-update refused it as a possible downgrade. The stack tests passed because their fixtures were synthetic JSON in a shape real rauc never emits — the second dead layer under the missing-unsquashfs one, and exactly why the leg installs through the real os-update. --output-format=shell carries the meta (RAUC_META_PITHEAD_*) and still verifies the bundle signature before printing. Fixtures now mirror the real format, and both os-update harnesses sandbox the migration-marker path so a test run can never touch a real /data. Co-Authored-By: Claude Fable 5 --- pithead | 14 ++++++--- tests/stack/run.sh | 78 ++++++++++++++++++++++++---------------------- 2 files changed, 49 insertions(+), 43 deletions(-) diff --git a/pithead b/pithead index 4c0dc240..ae3b1b2b 100755 --- a/pithead +++ b/pithead @@ -2428,11 +2428,15 @@ os_running_variant() { # echoes debug|release|unknown } os_bundle_meta() { # $1: bundle, $2: [meta.pithead] key — echoes its value or empty, never fails - # `rauc info --output-format=json` exposes the manifest meta. Parsed defensively: find any - # object carrying a `pithead` child (so a RAUC that nests the manifest differently still - # resolves) and read the key out of it. Anything unparseable degrades to empty. - rauc info --output-format=json "$1" 2>/dev/null | - jq -r --arg k "$2" '[.. | objects | select(has("pithead")) | .pithead | objects | .[$k]? // empty] | map(select(. != "")) | first // empty' 2>/dev/null + # `rauc info --output-format=shell` is the one machine-readable format that carries the + # manifest's [meta.*] sections on the RAUC the appliance actually ships (1.11's JSON output + # OMITS them entirely — the json parser here read real bundles as unstamped, found by the + # tier-4 migration leg). Verification still runs: rauc info checks the bundle signature + # against the system keyring before printing anything. Anything unparseable degrades to + # empty, which every consumer treats as fail-closed "unstamped". + rauc info --output-format=shell "$1" 2>/dev/null | + sed -n "s/^RAUC_META_PITHEAD_$(printf '%s' "$2" | tr '[:lower:]' '[:upper:]')='\(.*\)'\$/\1/p" | + head -1 } os_bundle_variant() { # $1: bundle path — echoes debug|release|unknown, never fails diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 3a746175..494c3aa1 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -8877,12 +8877,13 @@ assert_rc "unstamped system + release bundle passes — stays shell-less, no cha OUSB=$(mktemp -d) mkdir -p "$OUSB/bin" -# A fake rauc: logs every call, answers `info` with a canned JSON body. +# A fake rauc: logs every call, answers `info` with a canned shell-format body — +# the format the real os_bundle_meta parses (RAUC 1.11's JSON output omits [meta.*]). cat >"$OUSB/bin/rauc" <<'EOF' #!/usr/bin/env bash echo "[rauc] $*" >>"${RAUC_LOG:?}" case "$1" in -info) [ -s "${RAUC_INFO_JSON:-}" ] && cat "$RAUC_INFO_JSON" ;; +info) [ -s "${RAUC_INFO_OUT:-}" ] && cat "$RAUC_INFO_OUT" ;; install) exit 0 ;; esac exit 0 @@ -8910,59 +8911,60 @@ ourun() { # [os-update args...] — std # shellcheck disable=SC1090 source "$STACK" set +e - RAUC_INFO_JSON="$ij" PITHEAD_VARIANT_FILE="$vf" os_update "$@" "$OUSB/info-release.json" -printf '{"meta":{"pithead":{"variant":"debug"}}}\n' >"$OUSB/info-debug.json" -assert_eq "bundle variant parsed from rauc info JSON (nested manifest)" \ - "$(cd "$OUSB" && PATH="$OUSB/bin:$PATH" RAUC_INFO_JSON="$OUSB/info-release.json" run_sourced "$OUSB" os_bundle_variant bundle.raucb)" "release" -assert_eq "bundle variant parse tolerates a different nesting" \ - "$(cd "$OUSB" && PATH="$OUSB/bin:$PATH" RAUC_INFO_JSON="$OUSB/info-debug.json" run_sourced "$OUSB" os_bundle_variant bundle.raucb)" "debug" +printf "RAUC_META_PITHEAD_VARIANT='release'\n" >"$OUSB/info-release.txt" +printf "RAUC_META_PITHEAD_VARIANT='debug'\n" >"$OUSB/info-debug.txt" +assert_eq "bundle variant parsed from rauc info shell output" \ + "$(cd "$OUSB" && PATH="$OUSB/bin:$PATH" RAUC_INFO_OUT="$OUSB/info-release.txt" run_sourced "$OUSB" os_bundle_variant bundle.raucb)" "release" +assert_eq "bundle variant parse reads the debug stamp" \ + "$(cd "$OUSB" && PATH="$OUSB/bin:$PATH" RAUC_INFO_OUT="$OUSB/info-debug.txt" run_sourced "$OUSB" os_bundle_variant bundle.raucb)" "debug" assert_eq "an unstamped bundle is unknown" \ - "$(cd "$OUSB" && PATH="$OUSB/bin:$PATH" RAUC_INFO_JSON="" run_sourced "$OUSB" os_bundle_variant bundle.raucb)" "unknown" + "$(cd "$OUSB" && PATH="$OUSB/bin:$PATH" RAUC_INFO_OUT="" run_sourced "$OUSB" os_bundle_variant bundle.raucb)" "unknown" # The command end to end, with the daemon stubbed. Non-interactive stdin means the prompt reads # EOF -> cancelled: precisely the automation case where a silent install would strand the box. : >"$RAUC_LOG" -out=$(ourun "$OUSB/variant-debug" "$OUSB/info-release.json" bundle.raucb 2>&1) +out=$(ourun "$OUSB/variant-debug" "$OUSB/info-release.txt" bundle.raucb 2>&1) rc=$? assert_rc "debug box + release bundle, no --yes -> refused" "$rc" "1" assert_contains "the refusal names the SSH loss" "$out" "removes SSH" assert_not_contains "rauc install was NOT reached" "$(cat "$RAUC_LOG")" "install" : >"$RAUC_LOG" -ourun "$OUSB/variant-debug" "$OUSB/info-release.json" bundle.raucb --yes >/dev/null 2>&1 +ourun "$OUSB/variant-debug" "$OUSB/info-release.txt" bundle.raucb --yes >/dev/null 2>&1 assert_rc "--yes acknowledges the warning and proceeds" "$?" "0" assert_contains "rauc install ran with the bundle" "$(cat "$RAUC_LOG")" "install bundle.raucb" # The #854 direction: a hardened release box taking a debug bundle GAINS a root SSH backdoor. Non- # interactive stdin reads EOF -> refused, and rauc install must never be reached — the silent # install is exactly the backdoor this guards. : >"$RAUC_LOG" -out=$(ourun "$OUSB/variant-release" "$OUSB/info-debug.json" bundle.raucb 2>&1) +out=$(ourun "$OUSB/variant-release" "$OUSB/info-debug.txt" bundle.raucb 2>&1) rc=$? assert_rc "release box + debug bundle, no --yes -> refused" "$rc" "1" assert_contains "the refusal names the root SSH it would gain" "$out" "root SSH" assert_not_contains "rauc install was NOT reached on the gain-a-shell refusal" "$(cat "$RAUC_LOG")" "install" : >"$RAUC_LOG" -ourun "$OUSB/variant-release" "$OUSB/info-debug.json" bundle.raucb --yes >/dev/null 2>&1 +ourun "$OUSB/variant-release" "$OUSB/info-debug.txt" bundle.raucb --yes >/dev/null 2>&1 assert_rc "--yes acknowledges the backdoor warning and proceeds" "$?" "0" assert_contains "rauc install ran with the debug bundle after --yes" "$(cat "$RAUC_LOG")" "install bundle.raucb" : >"$RAUC_LOG" -ourun "$OUSB/variant-release" "$OUSB/info-release.json" bundle.raucb >/dev/null 2>&1 +ourun "$OUSB/variant-release" "$OUSB/info-release.txt" bundle.raucb >/dev/null 2>&1 assert_rc "release -> release installs with no prompt" "$?" "0" assert_contains "rauc install ran unprompted" "$(cat "$RAUC_LOG")" "install bundle.raucb" -out=$(ourun "$OUSB/variant-debug" "$OUSB/info-release.json" 2>&1) +out=$(ourun "$OUSB/variant-debug" "$OUSB/info-release.txt" 2>&1) assert_rc "a missing bundle path is an error, not an install" "$?" "1" # --- os-update version floor + data-migration guards (#856 downgrade, #851 migration deadlock) --- # A correctly-signed bundle is not automatically a safe one: an OLDER image re-opens fixed holes, # and an image below the /data migration floor cannot read the chain data a newer release migrated. -printf '{"manifest":{"meta":{"pithead":{"variant":"release","version":"1.17.0","data_migration":"false","minimum_os_version":""}}}}\n' >"$OUSB/info-1170.json" -printf '{"manifest":{"meta":{"pithead":{"variant":"release","version":"1.10.0","data_migration":"false","minimum_os_version":""}}}}\n' >"$OUSB/info-1100.json" -printf '{"manifest":{"meta":{"pithead":{"variant":"release","version":"1.17.0","data_migration":"true","minimum_os_version":"1.17.0"}}}}\n' >"$OUSB/info-mig.json" +printf "RAUC_META_PITHEAD_VARIANT='release'\nRAUC_META_PITHEAD_VERSION='1.17.0'\nRAUC_META_PITHEAD_DATA_MIGRATION='false'\n" >"$OUSB/info-1170.txt" +printf "RAUC_META_PITHEAD_VARIANT='release'\nRAUC_META_PITHEAD_VERSION='1.10.0'\nRAUC_META_PITHEAD_DATA_MIGRATION='false'\n" >"$OUSB/info-1100.txt" +printf "RAUC_META_PITHEAD_VARIANT='release'\nRAUC_META_PITHEAD_VERSION='1.17.0'\nRAUC_META_PITHEAD_DATA_MIGRATION='true'\nRAUC_META_PITHEAD_MINIMUM_OS_VERSION='1.17.0'\n" >"$OUSB/info-mig.txt" # os_bundle_meta: the manifest fields read back out of `rauc info` JSON. -ometa() { cd "$OUSB" && PATH="$OUSB/bin:$PATH" RAUC_INFO_JSON="$1" run_sourced "$OUSB" os_bundle_meta bundle.raucb "$2"; } +ometa() { cd "$OUSB" && PATH="$OUSB/bin:$PATH" RAUC_INFO_OUT="$1" run_sourced "$OUSB" os_bundle_meta bundle.raucb "$2"; } # render_bundle_manifest: the WRITE side of the manifest (the read side is os_bundle_meta below). # RAUC refuses a key with an empty value, so an ordinary non-migrating build must omit the floor # entirely — emitting `minimum_os_version=` unconditionally broke every plain bundle build, and @@ -8982,10 +8984,10 @@ else ok "no manifest key renders with an empty value" fi -assert_eq "os_bundle_meta reads version" "$(ometa "$OUSB/info-mig.json" version)" "1.17.0" -assert_eq "os_bundle_meta reads data_migration" "$(ometa "$OUSB/info-mig.json" data_migration)" "true" -assert_eq "os_bundle_meta reads minimum_os_version" "$(ometa "$OUSB/info-mig.json" minimum_os_version)" "1.17.0" -assert_eq "an absent meta key is empty, not an error" "$(ometa "$OUSB/info-mig.json" db_schema)" "" +assert_eq "os_bundle_meta reads version" "$(ometa "$OUSB/info-mig.txt" version)" "1.17.0" +assert_eq "os_bundle_meta reads data_migration" "$(ometa "$OUSB/info-mig.txt" data_migration)" "true" +assert_eq "os_bundle_meta reads minimum_os_version" "$(ometa "$OUSB/info-mig.txt" minimum_os_version)" "1.17.0" +assert_eq "an absent meta key is empty, not an error" "$(ometa "$OUSB/info-mig.txt" db_schema)" "" unset -f ometa # ourun_v: os_update with a set running version + a data-floor file, variant pinned to release @@ -9001,50 +9003,50 @@ ourun_v() { # [os-update args...] set +e PITHEAD_VERSION="$rv" PITHEAD_DATA_FLOOR_FILE="$ff" \ PITHEAD_MIGRATION_MARKER_FILE="${MARKER_FILE:-$OUSB/marker-scratch}" \ - RAUC_INFO_JSON="$ij" PITHEAD_VARIANT_FILE="$OUSB/variant-release" os_update "$@" "$RAUC_LOG" -out=$(ourun_v "1.17.0" "" "$OUSB/info-1100.json" bundle.raucb 2>&1) +out=$(ourun_v "1.17.0" "" "$OUSB/info-1100.txt" bundle.raucb 2>&1) assert_rc "a bundle older than running is refused" "$?" "1" assert_contains "the refusal names the downgrade" "$out" "possible downgrade" assert_not_contains "rauc install was NOT reached on a refused downgrade" "$(cat "$RAUC_LOG")" "install" # ...unless --allow-downgrade is passed on purpose. : >"$RAUC_LOG" -ourun_v "1.17.0" "" "$OUSB/info-1100.json" bundle.raucb --allow-downgrade >/dev/null 2>&1 +ourun_v "1.17.0" "" "$OUSB/info-1100.txt" bundle.raucb --allow-downgrade >/dev/null 2>&1 assert_rc "--allow-downgrade installs the older bundle" "$?" "0" assert_contains "rauc install ran under --allow-downgrade" "$(cat "$RAUC_LOG")" "install bundle.raucb" # A newer bundle installs with no ceremony. : >"$RAUC_LOG" -ourun_v "1.10.0" "" "$OUSB/info-1170.json" bundle.raucb >/dev/null 2>&1 +ourun_v "1.10.0" "" "$OUSB/info-1170.txt" bundle.raucb >/dev/null 2>&1 assert_rc "a newer bundle installs" "$?" "0" assert_contains "rauc install ran for the newer bundle" "$(cat "$RAUC_LOG")" "install bundle.raucb" # #851: below the /data migration floor is refused OUTRIGHT — --allow-downgrade does not override it. printf '2.0.0\n' >"$OUSB/floor-2" : >"$RAUC_LOG" -out=$(ourun_v "1.17.0" "$OUSB/floor-2" "$OUSB/info-1170.json" bundle.raucb --allow-downgrade 2>&1) +out=$(ourun_v "1.17.0" "$OUSB/floor-2" "$OUSB/info-1170.txt" bundle.raucb --allow-downgrade 2>&1) assert_rc "a bundle below the /data floor is refused even with --allow-downgrade" "$?" "1" assert_contains "the floor refusal warns about the chain data" "$out" "strand the chain data" assert_not_contains "rauc install was NOT reached below the floor" "$(cat "$RAUC_LOG")" "install" # A bundle at or above the floor installs. printf '1.17.0\n' >"$OUSB/floor-at" : >"$RAUC_LOG" -ourun_v "1.17.0" "$OUSB/floor-at" "$OUSB/info-1170.json" bundle.raucb >/dev/null 2>&1 +ourun_v "1.17.0" "$OUSB/floor-at" "$OUSB/info-1170.txt" bundle.raucb >/dev/null 2>&1 assert_rc "a bundle at the /data floor installs" "$?" "0" # #851: installing a data_migration bundle RECORDS the floor; a plain bundle does not. FW="$OUSB/floor-written" rm -f "$FW" : >"$RAUC_LOG" -ourun_v "1.17.0" "$FW" "$OUSB/info-mig.json" bundle.raucb >/dev/null 2>&1 +ourun_v "1.17.0" "$FW" "$OUSB/info-mig.txt" bundle.raucb >/dev/null 2>&1 assert_rc "a data_migration bundle installs" "$?" "0" assert_eq "installing a data_migration bundle records the /data floor" "$(tr -d ' \n' <"$FW" 2>/dev/null)" "1.17.0" FN="$OUSB/floor-none" rm -f "$FN" -ourun_v "1.10.0" "$FN" "$OUSB/info-1170.json" bundle.raucb >/dev/null 2>&1 +ourun_v "1.10.0" "$FN" "$OUSB/info-1170.txt" bundle.raucb >/dev/null 2>&1 assert_eq "a non-migration bundle records no floor" "$([ -f "$FN" ] && echo present || echo absent)" "absent" # #851 marker lifecycle: a migrating install leaves the pending marker (stamped with the bundle's @@ -9052,9 +9054,9 @@ assert_eq "a non-migration bundle records no floor" "$([ -f "$FN" ] && echo pres # supersedes a migrating install that never booted. MK="$OUSB/marker-mig" rm -f "$MK" -MARKER_FILE="$MK" ourun_v "1.10.0" "$OUSB/floor-scratch" "$OUSB/info-mig.json" bundle.raucb >/dev/null 2>&1 +MARKER_FILE="$MK" ourun_v "1.10.0" "$OUSB/floor-scratch" "$OUSB/info-mig.txt" bundle.raucb >/dev/null 2>&1 assert_eq "a data_migration install writes the pending marker with the bundle version" "$(tr -d ' \n' <"$MK" 2>/dev/null)" "1.17.0" -MARKER_FILE="$MK" ourun_v "1.10.0" "$OUSB/floor-scratch" "$OUSB/info-1170.json" bundle.raucb >/dev/null 2>&1 +MARKER_FILE="$MK" ourun_v "1.10.0" "$OUSB/floor-scratch" "$OUSB/info-1170.txt" bundle.raucb >/dev/null 2>&1 assert_eq "a non-migrating install clears a stale pending marker" "$([ -f "$MK" ] && echo present || echo absent)" "absent" # The hold query the boot path and doctor share: active only when the marker matches the RUNNING @@ -9083,21 +9085,21 @@ unset -f omh # Fail-closed: a version the comparator can't parse is NOT proof of safety. Releases DO use -prep # tags, so a pre-release bundle must not silently bypass the downgrade guard by parsing as "0". -printf '{"manifest":{"meta":{"pithead":{"variant":"release","version":"1.17.0-prep","data_migration":"false","minimum_os_version":""}}}}\n' >"$OUSB/info-prep.json" +printf "RAUC_META_PITHEAD_VARIANT='release'\nRAUC_META_PITHEAD_VERSION='1.17.0-prep'\nRAUC_META_PITHEAD_DATA_MIGRATION='false'\n" >"$OUSB/info-prep.txt" : >"$RAUC_LOG" -out=$(ourun_v "1.17.0" "" "$OUSB/info-prep.json" bundle.raucb 2>&1) +out=$(ourun_v "1.17.0" "" "$OUSB/info-prep.txt" bundle.raucb 2>&1) assert_rc "a pre-release (-prep) bundle is refused fail-closed" "$?" "1" assert_contains "the refusal names it a possible downgrade" "$out" "possible downgrade" assert_not_contains "rauc install was NOT reached for the pre-release bundle" "$(cat "$RAUC_LOG")" "install" : >"$RAUC_LOG" -ourun_v "1.17.0" "" "$OUSB/info-prep.json" bundle.raucb --allow-downgrade >/dev/null 2>&1 +ourun_v "1.17.0" "" "$OUSB/info-prep.txt" bundle.raucb --allow-downgrade >/dev/null 2>&1 assert_rc "--allow-downgrade installs the pre-release bundle on purpose" "$?" "0" assert_contains "rauc install ran for the pre-release under --allow-downgrade" "$(cat "$RAUC_LOG")" "install bundle.raucb" # Fail-closed: a corrupt floor file is NOT permission to downgrade past a migration. printf 'not-a-version\n' >"$OUSB/floor-corrupt" : >"$RAUC_LOG" -out=$(ourun_v "1.17.0" "$OUSB/floor-corrupt" "$OUSB/info-1170.json" bundle.raucb --allow-downgrade 2>&1) +out=$(ourun_v "1.17.0" "$OUSB/floor-corrupt" "$OUSB/info-1170.txt" bundle.raucb --allow-downgrade 2>&1) assert_rc "a corrupt /data floor is refused fail-closed, even with --allow-downgrade" "$?" "1" assert_contains "the corrupt-floor refusal says the floor is unreadable" "$out" "floor is unreadable" assert_not_contains "rauc install was NOT reached with a corrupt floor" "$(cat "$RAUC_LOG")" "install"