From 29b53a8f56814499490c96169464103b89174365 Mon Sep 17 00:00:00 2001 From: Lena Date: Thu, 1 Jan 2026 00:00:00 +0000 Subject: alpine-avf: replace the Android Linux Terminal VM with Alpine The Terminal app ships Debian and offers no supported way to change the guest. Android root is not available, so the only writable surface is the payload directory the app exposes to the guest over virtiofs, and the app has to keep working unmodified against whatever replaces its image. Those constraints force the two-stage install. A live root cannot be overwritten in place, and crosvm only exposes the partitions listed in vm_config.json when the VM starts, so a second partition has to be staged and the VM rebooted before there is a block device to write the new root through. The app also inspects the payload in undocumented ways. The README records what it expects and why the image is built to match. --- install-alpine-avf | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100755 install-alpine-avf (limited to 'install-alpine-avf') diff --git a/install-alpine-avf b/install-alpine-avf new file mode 100755 index 0000000..f5b1dea --- /dev/null +++ b/install-alpine-avf @@ -0,0 +1,117 @@ +#!/bin/sh +set -eu + +vm="/mnt/internal/linux" +marker="alpine-avf-install" +extras="vmlinuz initrd.img build_id vm_config.json" # everything but root_part + +die() { echo "$*" >&2; exit 1; } + +[ "$(id -u)" = 0 ] || die "run as root" +[ -e /sdcard ] && die "run this INSIDE the Terminal VM, not Android" +[ -d "$vm" ] || die "$vm not found; is this the Terminal VM?" +for c in python3 truncate stat blockdev findmnt e2fsck cmp dd sync reboot; do + command -v "$c" >/dev/null 2>&1 || die "need $c" +done + +if [ -f "$marker" ]; then + stage=2; img="$(cat "$marker")" +else + stage=1; img="$(CDPATH='' cd -- alpine-avf && pwd)" +fi + +for f in root_part $extras; do + [ -f "$img/$f" ] || die "missing $img/$f" +done + +if [ "$stage" = 1 ]; then + echo "stage 1: staging $img" + # Size the new partition to the larger of our image and the outgoing Debian + # root_part, so Alpine inherits the full disk. The file is sparse, so the + # larger apparent size costs no real space; avf-resize grows ext4 into it. + size="$(stat -c %s "$img/root_part")" + cur="$(stat -c %s "$vm/root_part" 2>/dev/null || echo 0)" + [ "$cur" -gt "$size" ] && size="$cur" + guid="$(cat /proc/sys/kernel/random/uuid)" + # Hide the stock backup while the install is in flight, so the app cannot + # restore it over the staged root; stage 2 puts it back after the swap. + [ -e "$vm/root_part_backup" ] && mv -f "$vm/root_part_backup" "$vm/root_part_backup.aside" + truncate -s "$size" "$vm/alpine_root" + # Append a temporary ALPINE partition; crosvm exposes it as a new /dev/vdaN. + python3 - "$vm/vm_config.json" "$guid" <<'PY' +import json, os, sys +p, guid = sys.argv[1], sys.argv[2] +c = json.load(open(p)); parts = c["disks"][0]["partitions"] +if not any(str(x.get("path", "")).endswith("/alpine_root") for x in parts): + parts.append({"label": "ALPINE", "path": "$PAYLOAD_DIR/alpine_root", "writable": True, "guid": guid}) +t = p + ".new" +with open(t, "w") as f: + json.dump(c, f, indent=4) + f.write("\n") +os.replace(t, p) +PY + printf '%s\n' "$img" > "$marker" + echo "staged; rebooting. Reopen Terminal, then run this again." + sync; reboot + exit 0 +fi + +echo "stage 2: installing $img" +target="$(python3 - "$vm/vm_config.json" <<'PY' +import json, sys +parts = json.load(open(sys.argv[1]))["disks"][0]["partitions"] +i = next((n for n, x in enumerate(parts, 1) if str(x.get("path", "")).endswith("/alpine_root")), None) +if i is None: + sys.exit("no ALPINE partition in vm_config.json; rm ./alpine-avf-install and rerun stage 1") +print("/dev/vda%d" % i) +PY +)" +echo "target: $target" +n=0; while [ ! -b "$target" ] && [ "$n" -lt 20 ]; do sleep 1; n=$((n + 1)); done +[ -b "$target" ] || die "$target absent; reboot and rerun, or rm ./$marker and rerun stage 1" +[ "$target" != "$(findmnt -no SOURCE /)" ] || die "$target is the live root; refusing" +want="$(stat -c %s "$img/root_part")" +[ "$(blockdev --getsize64 "$target")" -ge "$want" ] || die "target smaller than source" + +# dd through the block device (writing the file over virtiofs corrupts). +# Chunked with a sync per chunk to bound dirty page cache in the VM's RAM. +echo "copying root_part -> $target" +chunk=$((250 * 1024 * 1024)); n=0 +while [ $((n * chunk)) -lt "$want" ]; do + dd if="$img/root_part" of="$target" bs="$chunk" count=1 skip="$n" seek="$n" conv=notrunc iflag=fullblock status=none + sync; n=$((n + 1)) +done +# The copy of a freshly built image must check out clean; even "errors +# corrected" (rc 1/2) means the copy diverged from the verified source. +e2fsck -fy "$target" || die "e2fsck rc $? on $target: copy diverged; rerun stage 2" + +# Stage kernel/initrd/config over virtiofs, verified; fail before the swap. +for f in $extras; do + rm -f "$vm/$f.new" + n=0 + until cmp -s "$img/$f" "$vm/$f.new"; do + [ "$n" -lt 3 ] || die "could not write $vm/$f.new reliably; aborted before swap" + dd if="$img/$f" of="$vm/$f.new" bs=1M conv=fsync status=none + sync + n=$((n + 1)) + done +done + +echo "swapping root" +rm -f "$vm/root_part.previous" +mv -f "$vm/root_part" "$vm/root_part.previous" +mv -f "$vm/alpine_root" "$vm/root_part" || { + mv -f "$vm/root_part.previous" "$vm/root_part" + die "could not replace root_part" +} +for f in $extras; do + mv -f "$vm/$f.new" "$vm/$f" +done +rm -f "$vm/root_part.previous" +# stock Debian's extra-modules image; the new vm_config.json does not use it +rm -f "$vm/kernel_extras_part" +[ -e "$vm/root_part_backup.aside" ] && mv -f "$vm/root_part_backup.aside" "$vm/root_part_backup" +rm -f "$marker" +echo "installed; rebooting into Alpine. Reopen the Terminal app." +# the pause lets virtiofs settle the renames host-side before the VM dies +sync; sleep 2; reboot -- cgit v1.2.3