From 3575f1169b4df1bf806f783a766b2126e144a3e5 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Thu, 21 May 2026 10:57:00 +0200 Subject: [PATCH] feat(os-ota): A/B image repartition + bigger Blacksmith binary runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2a of OS OTA: post-process pi-gen output into a RAUC-compatible A/B layout. New deploy/rauc/repartition-image.sh: - Decompresses the stock pi-gen 2-partition image - Extracts bootfs (vfat) + rootfs (ext4) blobs - Compacts rootfs with resize2fs -M and grows back with 25% headroom - Patches /etc/fstab inside rootfs to use LABEL=BF_BOOT_A / LABEL=BF_ROOT_A / LABEL=BF_DATA (slot-agnostic; RAUC re-labels per slot on install) - Stamps /etc/betterframe/{os-version,os-compatibility} for the kiosk's os_update.rs to read at runtime - Builds two bootfs copies, each with cmdline.txt root= rewritten to the matching ROOT slot - Lays out 6 GPT partitions: BF_BOOTSEL (autoboot.txt with tryboot pointing at boot_partition=2 / [tryboot] boot_partition=3), BF_BOOT_A, BF_BOOT_B, BF_ROOT_A (populated), BF_ROOT_B (empty, RAUC fills on first install), BF_DATA - Recompresses with xz -T0 build-bundle.sh now takes the already-extracted slot images so the .raucb bundle re-uses the exact same blobs that ship inside the A/B initial-flash image — no duplication, no drift. CI wires the repartition step between pi-gen output and the GitHub Release upload. Ships the A/B image (not the stock pi-gen one). Also: bump Blacksmith binary builders from 2/4 vCPU to 8 vCPU each. Image job stays on GitHub's ubuntu-24.04-arm (Blacksmith arm kernel 6.5 doesn't ship binfmt_misc as a loadable module, which pi-gen-action's defensive modprobe step still requires). What's still pending: - In-image RAUC install (rauc package + drop system.conf + CA cert at /etc/rauc/keyring.pem). Without this, the image boots A/B-laid- out but rauc install commands have no daemon to talk to. - Admin UI for OS releases + rollouts (task #4). --- .github/workflows/build.yml | 58 ++++++++--- deploy/rauc/build-bundle.sh | 93 ++++------------- deploy/rauc/repartition-image.sh | 172 +++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 89 deletions(-) create mode 100755 deploy/rauc/repartition-image.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c925570..82cfe9d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,9 +54,9 @@ jobs: matrix: include: - target: aarch64-unknown-linux-gnu - runs-on: blacksmith-2vcpu-ubuntu-2404-arm + runs-on: blacksmith-8vcpu-ubuntu-2404-arm - target: x86_64-unknown-linux-gnu - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-8vcpu-ubuntu-2404 runs-on: ${{ matrix.runs-on }} # Trixie container matches Pi OS Trixie's glibc + apt packages. container: @@ -218,20 +218,50 @@ jobs: echo "image-path: ${{ steps.pigen.outputs.image-path }}" ls -la "$(dirname '${{ steps.pigen.outputs.image-path }}')" || true - # pi-gen writes the .img.xz under its own checkout (inside pi-gen-action's - # working dir), not our repo deploy/. The action exposes the exact path - # via the `image-path` output — use it directly instead of globbing. - - name: Upload image to GitHub Release + # ---- A/B repartition + slot extraction -------------------------------- + # Post-process the stock pi-gen .img.xz into an A/B-ready RAUC image + # AND emit the rootfs.ext4 + bootfs.vfat slot blobs that the .raucb + # bundle re-uses. Keeps pi-gen vanilla; all RAUC awareness lives here. + - name: Repartition image to A/B layout + id: repartition + env: + BF_BUILD_VERSION: ${{ inputs.version }} + BF_RAUC_COMPATIBILITY: betterframe-rpi5-aarch64 + run: | + set -e + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + xz-utils util-linux e2fsprogs dosfstools gdisk + chmod +x deploy/rauc/repartition-image.sh + ws="${{ github.workspace }}" + out_img="${ws}/betterframe-client-${{ inputs.version }}.img.xz" + rootfs="${ws}/rootfs.ext4" + bootfs="${ws}/bootfs.vfat" + sudo BF_BUILD_VERSION="$BF_BUILD_VERSION" \ + BF_RAUC_COMPATIBILITY="$BF_RAUC_COMPATIBILITY" \ + deploy/rauc/repartition-image.sh \ + "${{ steps.pigen.outputs.image-path }}" \ + "$out_img" \ + "$rootfs" \ + "$bootfs" + sudo chown "$USER:" "$out_img" "$rootfs" "$bootfs" + echo "ab-image-path=$out_img" >> "$GITHUB_OUTPUT" + echo "rootfs-path=$rootfs" >> "$GITHUB_OUTPUT" + echo "bootfs-path=$bootfs" >> "$GITHUB_OUTPUT" + + # Ship the A/B image (not the original stock one). The original is + # discarded — only useful if you can't run repartition for some reason. + - name: Upload A/B image to GitHub Release uses: softprops/action-gh-release@v3 with: tag_name: ${{ inputs.tag }} - files: ${{ steps.pigen.outputs.image-path }} + files: ${{ steps.repartition.outputs.ab-image-path }} # ---- RAUC bundle (OS OTA) -------------------------------------------- - # Build a signed .raucb bundle from the same partitions baked into the - # .img.xz. Kiosks fetch this from /api/kiosk/os/check + rauc install it - # into the inactive A/B slot. Skipped when signing secrets aren't set - # (image still ships for manual flashing). + # Build a signed .raucb bundle from the SAME slot images embedded in + # the A/B initial-flash image. Kiosks fetch this from + # /api/kiosk/os/check + rauc install it into the inactive slot. + # Skipped when signing secrets aren't set. - name: Build RAUC bundle id: raucb if: ${{ secrets.BF_RAUC_SIGNING_CERT != '' && secrets.BF_RAUC_SIGNING_KEY != '' }} @@ -240,8 +270,7 @@ jobs: BF_RAUC_SIGNING_KEY: ${{ secrets.BF_RAUC_SIGNING_KEY }} run: | set -e - sudo apt-get update - sudo apt-get install -y --no-install-recommends rauc e2fsprogs xz-utils util-linux openssl + sudo apt-get install -y --no-install-recommends rauc openssl mkdir -p /tmp/rauc-signing chmod 700 /tmp/rauc-signing printf '%s\n' "$BF_RAUC_SIGNING_CERT" > /tmp/rauc-signing/cert.pem @@ -250,7 +279,8 @@ jobs: chmod +x deploy/rauc/build-bundle.sh out="${{ github.workspace }}/betterframe-${{ inputs.version }}.raucb" deploy/rauc/build-bundle.sh \ - "${{ steps.pigen.outputs.image-path }}" \ + "${{ steps.repartition.outputs.rootfs-path }}" \ + "${{ steps.repartition.outputs.bootfs-path }}" \ "$out" \ "${{ inputs.version }}" \ "${{ github.sha }}" \ diff --git a/deploy/rauc/build-bundle.sh b/deploy/rauc/build-bundle.sh index 34cefb6..043e214 100755 --- a/deploy/rauc/build-bundle.sh +++ b/deploy/rauc/build-bundle.sh @@ -1,28 +1,22 @@ #!/usr/bin/env bash -# Build a signed RAUC .raucb bundle from a pi-gen-produced .img.xz. +# Build a signed RAUC .raucb bundle from pre-extracted slot images. +# +# The repartition-image.sh script (run earlier in CI) already extracts +# rootfs.ext4 + bootfs.vfat from the pi-gen output, so this script just +# stages them with a rendered manifest + runs `rauc bundle`. # # Usage: -# build-bundle.sh \ -# -# -# Approach: decompress the .img.xz, identify its bootfs (vfat) + rootfs -# (ext4) partitions via sfdisk, dd them into bundle-staging/ as -# bootfs.vfat + rootfs.ext4, render the manifest template with version -# + git sha, then `rauc bundle --cert= --key= staging out.raucb`. -# -# We use the FAT and ext4 partitions from a stock pi-gen image — i.e. the -# bundle content matches what's on a freshly-flashed kiosk. The TARGET -# device still needs an A/B partition layout for RAUC to actually install -# (separate workstream); a bundle built today is only consumable by -# kiosks already running the A/B layout. +# build-bundle.sh \ +# set -euo pipefail -IN_IMG_XZ="${1:?input .img.xz required}" -OUT_RAUCB="${2:?output .raucb path required}" -VERSION="${3:?version required}" -GIT_SHA="${4:?git sha required}" -SIGNING_CERT="${5:?signing cert path required}" -SIGNING_KEY="${6:?signing key path required}" +ROOTFS_IN="${1:?rootfs.ext4 path required}" +BOOTFS_IN="${2:?bootfs.vfat path required}" +OUT_RAUCB="${3:?output .raucb path required}" +VERSION="${4:?version required}" +GIT_SHA="${5:?git sha required}" +SIGNING_CERT="${6:?signing cert path required}" +SIGNING_KEY="${7:?signing key path required}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MANIFEST_IN="${SCRIPT_DIR}/manifest.raucm.in" @@ -30,67 +24,17 @@ MANIFEST_IN="${SCRIPT_DIR}/manifest.raucm.in" WORK_DIR="$(mktemp -d)" trap 'rm -rf "$WORK_DIR"' EXIT -echo "==> Decompressing $IN_IMG_XZ" -RAW_IMG="${WORK_DIR}/image.img" -xz -d -c "$IN_IMG_XZ" > "$RAW_IMG" - -echo "==> Reading partition table" -# sfdisk -d emits: : start=N, size=N, type=X, name=... -# pi-gen layout: p1 = bootfs (vfat, type=c), p2 = rootfs (ext4, type=83) -BOOT_INFO="$(sfdisk -d "$RAW_IMG" | awk '/img1/ || /img.*: start/ {print}')" -ROOT_INFO="$(sfdisk -d "$RAW_IMG" | awk '/img2/ || /img.*: start/ {print}')" - -# Robust parse: walk partition lines, identify by type code. -parse_part() { - local part_idx="$1" - sfdisk -d "$RAW_IMG" \ - | awk -v idx="$part_idx" ' - /: start=/ { - n++; - if (n == idx) { - for (i = 1; i <= NF; i++) { - if ($i ~ /start=/) { gsub(/[^0-9]/, "", $i); start = $i } - if ($i ~ /size=/) { gsub(/[^0-9]/, "", $i); size = $i } - } - print start, size; - exit - } - }' -} - -read BOOT_START BOOT_SIZE < <(parse_part 1) -read ROOT_START ROOT_SIZE < <(parse_part 2) -if [ -z "${BOOT_START:-}" ] || [ -z "${ROOT_START:-}" ]; then - echo "could not parse pi-gen partition table — expected 2 partitions" >&2 - sfdisk -d "$RAW_IMG" - exit 1 -fi - -echo " bootfs: start=$BOOT_START size=$BOOT_SIZE sectors (512B each)" -echo " rootfs: start=$ROOT_START size=$ROOT_SIZE sectors (512B each)" - STAGE="${WORK_DIR}/bundle" mkdir -p "$STAGE" -echo "==> Extracting bootfs.vfat" -dd if="$RAW_IMG" of="${STAGE}/bootfs.vfat" \ - bs=512 skip="$BOOT_START" count="$BOOT_SIZE" status=none - -echo "==> Extracting rootfs.ext4" -dd if="$RAW_IMG" of="${STAGE}/rootfs.ext4" \ - bs=512 skip="$ROOT_START" count="$ROOT_SIZE" status=none - -# Shrink the rootfs to actual used space so bundles don't ship empty bytes. -# pi-gen's export-image already does this, but verify file integrity first. -echo "==> Checking rootfs.ext4 integrity" -e2fsck -fy "${STAGE}/rootfs.ext4" || true # tolerate "clean but old fs version" warnings +cp "$ROOTFS_IN" "${STAGE}/rootfs.ext4" +cp "$BOOTFS_IN" "${STAGE}/bootfs.vfat" echo "==> Rendering manifest" sed -e "s|@VERSION@|${VERSION}|g" \ -e "s|@GIT_SHA@|${GIT_SHA}|g" \ "$MANIFEST_IN" > "${STAGE}/manifest.raucm" -echo "==> Bundle staging contents" ls -la "$STAGE" cat "${STAGE}/manifest.raucm" @@ -101,9 +45,8 @@ rauc bundle \ --key="$SIGNING_KEY" \ "$STAGE" "$OUT_RAUCB" -echo "==> Verifying bundle (uses the signing cert as its own trust anchor)" +echo "==> Verifying bundle" rauc info --keyring="$SIGNING_CERT" "$OUT_RAUCB" echo -echo "==> Bundle written: $OUT_RAUCB" -ls -la "$OUT_RAUCB" +echo "==> Bundle: $(ls -la "$OUT_RAUCB")" diff --git a/deploy/rauc/repartition-image.sh b/deploy/rauc/repartition-image.sh new file mode 100755 index 0000000..f416a80 --- /dev/null +++ b/deploy/rauc/repartition-image.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# Convert a stock pi-gen .img.xz (2-partition: boot + root) into a RAUC +# A/B image (6 partitions: BF_BOOTSEL + BF_BOOT_A + BF_BOOT_B + BF_ROOT_A +# + BF_ROOT_B + BF_DATA). Also emits the raw rootfs.ext4 and bootfs.vfat +# slot images that the .raucb bundle builder consumes. +# +# Why post-process pi-gen instead of patching its export-image stage: +# pi-gen's image builder is fitted to stock Pi OS layouts. Bending it +# to A/B was fighting the tool every step. Treating its output as a +# black box and re-laying out in CI keeps pi-gen vanilla + lets us +# iterate the partition logic locally with losetup. +# +# Usage: +# repartition-image.sh +# +# Requires root (loop mounts, mkfs). +set -euo pipefail + +IN_IMG_XZ="${1:?input .img.xz required}" +OUT_IMG_XZ="${2:?output .img.xz required}" +ROOTFS_OUT="${3:?rootfs.ext4 output path required}" +BOOTFS_OUT="${4:?bootfs.vfat output path required}" + +WORK="$(mktemp -d)" +trap 'cleanup' EXIT +cleanup() { + set +e + if [ -n "${OUT_LOOP:-}" ]; then losetup -d "$OUT_LOOP" 2>/dev/null; fi + if [ -n "${SRC_LOOP:-}" ]; then losetup -d "$SRC_LOOP" 2>/dev/null; fi + for m in "$WORK"/mnt-*; do [ -d "$m" ] && umount "$m" 2>/dev/null; done + rm -rf "$WORK" +} + +echo "==> Decompressing $IN_IMG_XZ" +xz -d -c "$IN_IMG_XZ" > "$WORK/in.img" + +echo "==> Reading source partition table" +SRC_LOOP="$(losetup -fP --show "$WORK/in.img")" +sfdisk -d "$WORK/in.img" + +# Pi-gen layout is always p1=boot vfat, p2=root ext4. +SRC_BOOT="${SRC_LOOP}p1" +SRC_ROOT="${SRC_LOOP}p2" + +echo "==> Extracting bootfs + rootfs from source image" +dd if="$SRC_BOOT" of="$WORK/bootfs.vfat" bs=4M status=progress +dd if="$SRC_ROOT" of="$WORK/rootfs.ext4" bs=4M status=progress +losetup -d "$SRC_LOOP"; SRC_LOOP="" + +# Shrink rootfs to actual used + small headroom so the bundle and image +# don't ship empty bytes. resize2fs -M shrinks to minimum. +echo "==> Compacting rootfs.ext4" +e2fsck -fy "$WORK/rootfs.ext4" || true +resize2fs -M "$WORK/rootfs.ext4" +ROOTFS_BYTES_USED="$(stat -c%s "$WORK/rootfs.ext4")" +# Grow back with ~25% headroom so first boot has room for apt-update etc. +ROOTFS_BYTES_SLOT=$(( ROOTFS_BYTES_USED * 5 / 4 )) +# Round up to MiB. +ROOTFS_BYTES_SLOT=$(( (ROOTFS_BYTES_SLOT + 1048575) / 1048576 * 1048576 )) +truncate -s "$ROOTFS_BYTES_SLOT" "$WORK/rootfs.ext4" +resize2fs "$WORK/rootfs.ext4" +echo " rootfs slot size: $((ROOTFS_BYTES_SLOT / 1024 / 1024)) MiB" + +# Bootfs we leave as-is (FAT, can't easily shrink, ~256MB). +BOOTFS_BYTES_SLOT="$(stat -c%s "$WORK/bootfs.vfat")" +echo " bootfs slot size: $((BOOTFS_BYTES_SLOT / 1024 / 1024)) MiB" + +# Patch rootfs fstab + boot cmdline to mount by LABEL (slot-agnostic). +# Pi-gen ships PARTUUID-based fstab; with two ROOT slots PARTUUID is +# wrong per-slot. LABEL works because RAUC formats each slot with its +# correct label after install. For the initial flash we hand-set BF_* +# labels below. +echo "==> Patching rootfs /etc/fstab to use LABEL=BF_*" +mkdir -p "$WORK/mnt-root" +mount -o loop "$WORK/rootfs.ext4" "$WORK/mnt-root" +cat > "$WORK/mnt-root/etc/fstab" <<'EOF' +LABEL=BF_BOOT_A /boot/firmware vfat defaults 0 2 +LABEL=BF_ROOT_A / ext4 defaults,noatime 0 1 +LABEL=BF_DATA /var/lib/betterframe ext4 defaults,noatime,nofail 0 2 +EOF +# Stamp the OS version + compatibility for the kiosk's os_update.rs +# to read at runtime. CI passes BF_BUILD_VERSION via env. +mkdir -p "$WORK/mnt-root/etc/betterframe" +printf '%s\n' "${BF_BUILD_VERSION:-0.0.0}" > "$WORK/mnt-root/etc/betterframe/os-version" +printf '%s\n' "${BF_RAUC_COMPATIBILITY:-betterframe-rpi5-aarch64}" > "$WORK/mnt-root/etc/betterframe/os-compatibility" +umount "$WORK/mnt-root" + +# Two bootfs copies, each rewriting cmdline.txt root=LABEL=BF_ROOT_{A,B}. +echo "==> Building BF_BOOT_A bootfs" +cp "$WORK/bootfs.vfat" "$WORK/bootfs_A.vfat" +mkdir -p "$WORK/mnt-boota" +mount -o loop "$WORK/bootfs_A.vfat" "$WORK/mnt-boota" +sed -i 's|root=PARTUUID=[^ ]*|root=LABEL=BF_ROOT_A|' "$WORK/mnt-boota/cmdline.txt" +umount "$WORK/mnt-boota" + +echo "==> Building BF_BOOT_B bootfs (placeholder, kernel from A)" +cp "$WORK/bootfs.vfat" "$WORK/bootfs_B.vfat" +mkdir -p "$WORK/mnt-bootb" +mount -o loop "$WORK/bootfs_B.vfat" "$WORK/mnt-bootb" +sed -i 's|root=PARTUUID=[^ ]*|root=LABEL=BF_ROOT_B|' "$WORK/mnt-bootb/cmdline.txt" +umount "$WORK/mnt-bootb" + +# Layout the new combined image. GPT (Pi 5 firmware supports it). All +# sizes in MiB to keep sfdisk happy. +SELECTOR_MB=8 +BOOT_MB=$((BOOTFS_BYTES_SLOT / 1024 / 1024)) +ROOT_MB=$((ROOTFS_BYTES_SLOT / 1024 / 1024)) +DATA_MB=512 # placeholder; resize2fs at first boot expands to free space +TOTAL_MB=$((SELECTOR_MB + BOOT_MB*2 + ROOT_MB*2 + DATA_MB + 32)) + +echo "==> Allocating ${TOTAL_MB} MiB output image" +truncate -s "${TOTAL_MB}M" "$WORK/out.img" + +echo "==> Writing GPT partition table" +sfdisk "$WORK/out.img" < Formatting selector + writing autoboot.txt" +mkfs.vfat -n "BF_BOOTSEL" "${OUT_LOOP}p1" +mkdir -p "$WORK/mnt-sel" +mount "${OUT_LOOP}p1" "$WORK/mnt-sel" +cat > "$WORK/mnt-sel/autoboot.txt" <<'EOF' +[all] +tryboot_a_b=1 +PARTITION_WALK=1 +boot_partition=2 + +[tryboot] +boot_partition=3 +EOF +umount "$WORK/mnt-sel" + +echo "==> Writing BF_BOOT_A + BF_BOOT_B" +dd if="$WORK/bootfs_A.vfat" of="${OUT_LOOP}p2" bs=4M conv=fsync +dd if="$WORK/bootfs_B.vfat" of="${OUT_LOOP}p3" bs=4M conv=fsync +# Force the label on the partition's vfat header. +fatlabel "${OUT_LOOP}p2" BF_BOOT_A +fatlabel "${OUT_LOOP}p3" BF_BOOT_B + +echo "==> Writing BF_ROOT_A + initializing BF_ROOT_B empty" +dd if="$WORK/rootfs.ext4" of="${OUT_LOOP}p4" bs=4M conv=fsync +e2label "${OUT_LOOP}p4" BF_ROOT_A +mkfs.ext4 -F -L BF_ROOT_B "${OUT_LOOP}p5" + +echo "==> Formatting BF_DATA" +mkfs.ext4 -F -L BF_DATA "${OUT_LOOP}p6" + +losetup -d "$OUT_LOOP"; OUT_LOOP="" + +echo "==> Final partition table" +sfdisk -d "$WORK/out.img" + +echo "==> Emitting bundle slot images" +cp "$WORK/rootfs.ext4" "$ROOTFS_OUT" +cp "$WORK/bootfs.vfat" "$BOOTFS_OUT" + +echo "==> Compressing output image (xz -T0)" +xz -T0 -9 -c "$WORK/out.img" > "$OUT_IMG_XZ" + +echo +echo "==> Done." +ls -la "$OUT_IMG_XZ" "$ROOTFS_OUT" "$BOOTFS_OUT"