Files
steamify-cachyos-dev/scripts/vminstall.sh
T

227 lines
13 KiB
Bash
Executable File

#!/bin/bash
# Install a fresh, ssh-ready test VM unattended: CachyOS (KDE Plasma,
# plasma-login-manager, btrfs, Limine, linux-cachyos + -lts) from the ISO's
# headless installer, then sshd with your key, passwordless sudo and the test
# autologin into Plasma. Ends with the snapshots `clean` and `ssh-ready`.
# scripts/vminstall.sh [--force] [--iso <file>] [run.sh flags...] e.g. --fremont
# --force replace an existing disk.qcow2 in $VM_DIR (asks first)
# --iso <file> the live ISO (default $VM_DIR/cachyos.iso, e.g. a
# Steamify CachyOS build later; it must be archiso-based and
# have cachyos-installer)
# Env: VM_DIR (see common.sh), VM_USER (default: your host username; theupriser when that is root),
# VM_PASSWORD (steamify), VM_SSH_KEY (private key to authorize; default
# ~/.ssh/steamify-vm_ed25519, asked once when that doesn't exist), REPO (shared as 9p `repo`, default ../steamify-cachyos),
# VM_INSTALL_MEM (RAM of the live installer VM only; default VM_MEM, else run.sh's 8G),
# VM_CACHE (host dir for the packages, default ~/vms/pkg-cache; empty = none),
# VM_STEAMIFY (a Steamify ISO also runs steamify-install: the Steamify page's ids,
# empty = defaults, skip = plain CachyOS), VM_BOOTLOADER (limine; or systemd-boot, grub), VM_TIMEZONE (the host's), VM_HOST_IP (the host as the guest sees it,
# 10.0.2.2 with QEMU's user networking).
# The VM's user and key are recorded in $VM_DIR (vm-user, ssh-key) for the
# other scripts. Never touches your existing SSH keys or ~/.ssh/known_hosts.
set -euo pipefail
[[ -n "${CI:-}" ]] && export VM_HEADLESS=1 # no display in CI (QEMU fails with "OpenGL is not supported by the display")
here="$(cd "$(dirname "$0")" && pwd)"
repo="$(cd "$here/.." && pwd)"
[[ -n "${VM_USER:-}" ]] || VM_USER="$(id -un)"
[[ "$VM_USER" != root ]] || VM_USER=theupriser # root can't be the guest user (WSL runs as root)
export VM_USER
. "$here/common.sh"
# The pacman package cache, on the host and shared by every VM you install
# (9p tag `cache`, see run.sh): a second install downloads nothing. VM_CACHE= (empty) turns it off.
export VM_CACHE="${VM_CACHE-$HOME/vms/pkg-cache}"
force=false iso="" run_flags=()
while [[ $# -gt 0 ]]; do
case "$1" in
--force) force=true; shift ;;
--iso) iso="${2:?--iso needs a file}"; shift 2 ;;
*) run_flags+=("$1"); shift ;;
esac
done
password="${VM_PASSWORD:-steamify}"
timezone="${VM_TIMEZONE:-$(timedatectl show -p Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || echo UTC)}"
hostname=steamify-vm
REPO="${REPO:-$(dirname "$repo")/steamify-cachyos}"
[[ -d "$REPO" ]] || { echo "REPO $REPO doesn't exist (the Steamify checkout shared into the VM); set REPO=." >&2; exit 1; }
export REPO
mkdir -p "$VM_DIR"
iso="${iso:-$VM_DIR/cachyos.iso}"
[[ -f "$iso" ]] || { echo "No ISO at $iso: run get-iso.sh (in $VM_DIR) or pass --iso <file>." >&2; exit 1; }
iso="$(cd "$(dirname "$iso")" && pwd)/$(basename "$iso")"
pgrep -f "[h]ostfwd=tcp::$VM_PORT-" >/dev/null && { echo "A VM is running on port $VM_PORT; power it off first." >&2; exit 1; }
if [[ -f "$VM_DIR/disk.qcow2" ]]; then
[[ "$force" == true ]] || { echo "$VM_DIR already has a disk.qcow2; use --force to replace it, or another VM_DIR." >&2; exit 1; }
read -r -p "Replace $VM_DIR/disk.qcow2 and all its snapshots? Type YES: " answer < /dev/tty
[[ "$answer" == YES ]] || { echo "Nothing changed."; exit 1; }
fi
# --- The SSH key to authorize: a new one just for the VM, or one of yours.
# The VM's own key is made once and then always used: asked only when it
# doesn't exist yet.
dedicated="$HOME/.ssh/steamify-vm_ed25519"
[[ -z "${VM_SSH_KEY:-}" && -f "$dedicated" ]] && VM_SSH_KEY="$dedicated"
if [[ -z "${VM_SSH_KEY:-}" ]]; then
mapfile -t pubs < <(ls "$HOME"/.ssh/*.pub 2>/dev/null | grep -vxF "$dedicated.pub")
echo "Which SSH key should log in to the VM?"
echo " 1) a new key just for the VM, used from now on: $dedicated (no passphrase)"
for i in "${!pubs[@]}"; do echo " $((i + 2))) your key ${pubs[$i]%.pub}"; done
read -r -p "Choice [1]: " choice < /dev/tty
choice="${choice:-1}"
if [[ "$choice" == 1 ]]; then
VM_SSH_KEY="$dedicated"
# Never overwrite a key: it doesn't exist here.
ssh-keygen -q -t ed25519 -N "" -C "steamify-vm" -f "$dedicated"
elif [[ "$choice" =~ ^[0-9]+$ && -n "${pubs[$((choice - 2))]:-}" ]]; then
VM_SSH_KEY="${pubs[$((choice - 2))]%.pub}"
else
echo "Unknown choice: $choice" >&2; exit 1
fi
fi
[[ -f "$VM_SSH_KEY.pub" ]] || { echo "No public key $VM_SSH_KEY.pub." >&2; exit 1; }
pubkey="$(cat "$VM_SSH_KEY.pub")"
echo "VM: $VM_DIR user: $VM_USER password: $password hostname: $hostname timezone: $timezone"
echo "ISO: $iso key: $VM_SSH_KEY"
# --- The ISO's kernel and initramfs, to boot it with our parameters.
boot="$VM_DIR/iso-boot"
mkdir -p "$boot"
# One loop setup at a time: parallel installs (vmtest.sh --install) otherwise get "Device or resource busy".
exec 9> /tmp/vminstall-iso.lock; flock 9
if command -v bsdtar >/dev/null; then
# No loop device, no udisks, no root: libarchive reads the ISO9660 image directly (a CI container has no
# loop partition nodes, so the mounts below can't work there).
mnt="$(mktemp -d)"
cleanup_loop() { chmod -R u+rwX "$mnt" 2>/dev/null || true; rm -rf "$mnt" 2>/dev/null || true; } # the ISO's files come out read-only
trap cleanup_loop EXIT
bsdtar -xf "$iso" -C "$mnt" arch/boot/x86_64/vmlinuz-linux-cachyos arch/boot/x86_64/initramfs-linux-cachyos.img boot EFI 2>/dev/null || true
[[ -f "$mnt/arch/boot/x86_64/vmlinuz-linux-cachyos" ]] || { echo "bsdtar found no kernel in the ISO." >&2; exit 1; }
elif ! command -v udisksctl >/dev/null && [[ $EUID -eq 0 ]]; then
# No udisks (the WSL box, as root): a plain loop device and mount.
loop="$(losetup -f -r -P --show "$iso")"
mnt="$(mktemp -d)"
cleanup_loop() { umount "$mnt" 2>/dev/null || true; rmdir "$mnt" 2>/dev/null || true; losetup -d "$loop" 2>/dev/null || true; }
trap cleanup_loop EXIT
mount -r "${loop}p1" "$mnt" 2>/dev/null || mnt=""
else
loop="$(udisksctl loop-setup --no-user-interaction -r -f "$iso" | grep -o '/dev/loop[0-9]*')"
cleanup_loop() { udisksctl unmount --no-user-interaction -b "${loop}p1" >/dev/null 2>&1 || true; udisksctl loop-delete --no-user-interaction -b "$loop" >/dev/null 2>&1 || true; }
trap cleanup_loop EXIT
mnt=""
for _ in $(seq 10); do
mnt="$(findmnt -nro TARGET "${loop}p1" 2>/dev/null || true)"
[[ -n "$mnt" ]] && break
udisksctl mount --no-user-interaction -b "${loop}p1" >/dev/null 2>&1 || true; sleep 1
done
fi
[[ -n "$mnt" ]] || { echo "Couldn't mount the ISO." >&2; exit 1; }
rm -f "$boot"/* # copied read-only from the ISO
install -m 644 "$mnt/arch/boot/x86_64/vmlinuz-linux-cachyos" "$mnt/arch/boot/x86_64/initramfs-linux-cachyos.img" "$boot/"
search="$(grep -rhoE 'archisosearchuuid=[^ ]+' "$mnt/boot" "$mnt/EFI" 2>/dev/null | head -1)"
[[ -n "$search" ]] || { echo "No archisosearchuuid in the ISO's boot menu." >&2; exit 1; }
cleanup_loop; trap - EXIT
flock -u 9; exec 9>&-
# --- What the live system fetches from the host.
www="$VM_DIR/install-www"
rm -rf "$www"; mkdir -p "$www"
# QEMU's user networking (-nic user in run.sh) always shows the host to the
# guest at 10.0.2.2, whatever network the host is on; only a bridged/tap
# setup would need another address.
host_ip="${VM_HOST_IP:-10.0.2.2}"
port="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])')"
sed -e "s/@PORT@/$port/" -e "s/@HOST_IP@/$host_ip/" "$repo/share/vminstall-live.sh" > "$www/vminstall-live.sh"
cp "$repo/share/vminstall-post.sh" "$www/"
{
printf 'VM_USER=%q\nVM_HOSTNAME=%q\nVM_PUBKEY=%q\nDEVICE=/dev/vda\nVM_STEAMIFY=%q\n' "$VM_USER" "$hostname" "$pubkey" "${VM_STEAMIFY:-}"
} > "$www/vminstall.env"
python3 - "$www/settings.json" "$VM_USER" "$password" "$timezone" "$hostname" "${VM_BOOTLOADER:-limine}" << 'PY'
import json, sys
out, user, pw, tz, host, bootloader = sys.argv[1:]
json.dump({
"install_type": "simple", "headless_mode": True,
"device": "/dev/vda", "fs_name": "btrfs", "subvolumes": "default",
"partitions": [
{"name": "/dev/vda1", "mountpoint": "/boot", "size": "2G", "fs_name": "vfat", "type": "boot"},
{"name": "/dev/vda2", "mountpoint": "/", "size": "100%", "type": "root"},
],
"hostname": host, "locale": "en_US.UTF-8", "xkbmap": "us", "timezone": tz,
"user_name": user, "user_pass": pw, "user_shell": "/bin/fish", "root_pass": pw,
"kernel": "linux-cachyos linux-cachyos-lts", "desktop": "kde", "bootloader": bootloader,
}, open(out, "w"), indent=2)
PY
python3 "$here/vminstall-server.py" "$www" "$port" &
server=$!
trap 'kill $server 2>/dev/null || true' EXIT
# --- Install.
cd "$VM_DIR"
cp "$repo/run.sh" run.sh # always this repo's: the install needs its kernel/serial options
[[ -e share ]] || ln -s "$repo/share" share
rm -f disk.qcow2 vars.fd vars.clean.fd vars.ssh-ready.fd known_hosts
qemu-img create -q -f qcow2 disk.qcow2 60G
echo "$VM_USER" > vm-user
echo "$REPO" > repo-path
echo "$VM_SSH_KEY" > ssh-key
# systemd.run= only generates kernel-command-line.service; systemd.wants=
# starts it next to the normal boot (desktop, network). It fetches and runs
# the live script. (Root logs in on the serial console without a password:
# VM_SERIAL.sock, for debugging.)
append="$search archisobasedir=arch cow_spacesize=10G module_blacklist=pcspkr console=ttyS0,115200 console=tty0"
append+=" systemd.run=\"/usr/bin/bash -c 'curl -fsS --retry 150 --retry-all-errors --retry-delay 2 http://$host_ip:$port/vminstall-live.sh | bash'\""
append+=" systemd.run_success_action=none systemd.run_failure_action=none systemd.wants=kernel-command-line.service"
echo "Installing: watch the VM's window, or follow the console with tail -f $VM_DIR/serial.log (install log: $www/install.log)..."
VM_MEM="${VM_INSTALL_MEM:-${VM_MEM:-}}" VM_SERIAL="$VM_DIR/serial.log" VM_ISO="$iso" VM_KERNEL="$boot/vmlinuz-linux-cachyos" VM_INITRD="$boot/initramfs-linux-cachyos.img" VM_APPEND="$append" \
./run.sh install "${run_flags[@]}" > vm-install.log 2>&1 &
qemu=$!
# 90 minutes; a failure reported by the live system ends it at once (it stays up after one)
waited=0
for _ in $(seq 1080); do
kill -0 $qemu 2>/dev/null || break
# A sign of life every minute (CI shows nothing else for ~10 minutes): where the live installer is.
(( waited++ % 12 == 0 )) && echo " .. installing, $((waited * 5 / 60)) min: $(tail -n 1 "$www/install.log" 2>/dev/null | cut -c1-110)"
[[ -s "$www/status" && "$(cat "$www/status")" != ok ]] && { sleep 5; kill $qemu 2>/dev/null; wait $qemu 2>/dev/null; break; }
sleep 5
done
kill -0 $qemu 2>/dev/null && { echo "The install didn't finish in 90 minutes; see $www/install.log." >&2; kill $qemu; exit 1; }
status="$(cat "$www/status" 2>/dev/null || echo "no status (the live system never reported back)")"
cp "$www/install.log" install.log 2>/dev/null || true
if [[ "$status" != ok ]]; then
echo "Install failed: $status. Log: $VM_DIR/install.log" >&2
# In CI only the job log survives: show why.
for f in vm-install.log serial.log install.log; do [[ -s "$f" ]] && { echo "--- $f (last 40 lines)" >&2; tail -n 40 "$f" | sed 's/\x1b\[[0-9;]*[A-Za-z]//g' >&2; }; done
exit 1
fi
qemu-img snapshot -c clean disk.qcow2 && cp vars.fd vars.clean.fd
echo "Installed; snapshot 'clean' taken."
# --- First boot: check it's ssh-ready, then snapshot.
nohup ./run.sh "${run_flags[@]}" > vm.log 2>&1 &
vm=$!
for _ in $(seq 100); do vm_ssh -o ConnectTimeout=3 true 2>/dev/null && break; sleep 3; done
vm_ssh true || { echo "No SSH after the first boot; see $VM_DIR/vm.log." >&2; exit 1; }
# The poweroff at the end may drop the connection (ssh exits 255): judge by the ssh-ready line instead.
out="$(vm_ssh bash -s 2>&1 << 'REMOTE'
set -e
if [[ -f /etc/sddm.conf.d/10-gamescope-autologin.conf ]]; then
# Steamify's gaming mode (gamescope, no Plasma, and it doesn't render in the VM): the login manager has to be up.
for _ in $(seq 60); do systemctl is-active --quiet display-manager && break; sleep 3; done
systemctl is-active --quiet display-manager || { echo "The login manager didn't start." >&2; exit 1; }
else
for _ in $(seq 60); do pgrep -u "$USER" -x plasmashell >/dev/null && break; sleep 3; done
pgrep -u "$USER" -x plasmashell >/dev/null || { echo "Plasma didn't start." >&2; exit 1; }
fi
sudo -n true
echo "ssh-ready: $(id -un)@$(hostname), $(uname -r), $(localectl status | sed -n 's/.*LANG=//p'), $(timedatectl show -p Timezone --value)"
sudo systemctl poweroff
REMOTE
)" || true
printf '%s\n' "$out" | grep -v '^Connection to .* closed'
grep -q '^ssh-ready: ' <<< "$out" || { echo "The first boot check failed; see above and $VM_DIR/vm.log." >&2; kill $vm 2>/dev/null; exit 1; }
while kill -0 $vm 2>/dev/null; do sleep 2; done # this VM only: others may run in parallel
qemu-img snapshot -c ssh-ready disk.qcow2 && cp vars.fd vars.ssh-ready.fd
rm -rf "$www"
echo "Done: snapshots 'clean' and 'ssh-ready' in $VM_DIR."
echo "Log in by hand as $VM_USER / $password (root: $password)."