diff options
| author | Lena <lena@omega> | 2026-01-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-01-01 00:00:00 +0000 |
| commit | 29b53a8f56814499490c96169464103b89174365 (patch) | |
| tree | cc728c9a5c65a6246acd043358d2d094c9b358b0 /install-alpine-avf | |
| download | alpine-avf-29b53a8f56814499490c96169464103b89174365.tar.gz | |
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.
Diffstat (limited to 'install-alpine-avf')
| -rwxr-xr-x | install-alpine-avf | 117 |
1 files changed, 117 insertions, 0 deletions
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 |