Initial Commit
This commit is contained in:
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/bin/bash
|
||||
# =====================================================================
|
||||
# Puts installation media where build.sh can find it, having checked it.
|
||||
#
|
||||
# sudo ./fetch.sh ubuntu # download the pinned release
|
||||
# sudo ./fetch.sh ubuntu ~/Downloads/x.iso # use one already downloaded
|
||||
# sudo ./fetch.sh windows ~/Downloads/x.iso
|
||||
#
|
||||
# EITHER WAY IT IS CHECKSUMMED
|
||||
#
|
||||
# A local file is not trusted more than a downloaded one - it is the same
|
||||
# 6 GB of installer that becomes the machine every session runs from. For
|
||||
# Ubuntu the hash is compared against Canonical's published SHA256SUMS
|
||||
# for that series, fetched at the time, rather than a constant pasted in
|
||||
# here that goes stale at the next point release and gets deleted.
|
||||
#
|
||||
# Windows media has no published list to check against, so its hash is
|
||||
# only RECORDED. That is a real difference and it is not papered over:
|
||||
# the manifest says where the file came from and what it hashed to, and
|
||||
# that is all anyone can honestly claim about it.
|
||||
#
|
||||
# WHY UBUNTU IS NOT FETCHED WITH quickget
|
||||
#
|
||||
# quickget was adopted for image acquisition because the Windows side -
|
||||
# locating an ISO, pairing it with the right virtio-win build - is
|
||||
# genuinely fiddly and well solved there. Ubuntu is not that. Acquisition
|
||||
# is one download and one checksum, and doing it directly keeps a
|
||||
# 2,400-line bash dependency off the path that builds the image this
|
||||
# suite runs on every session.
|
||||
# =====================================================================
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$(readlink -f "$0")")"
|
||||
|
||||
ISO_DIR=/var/lib/owuivm/iso
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; }
|
||||
install -d -o owuivmadm -g owuivmadm -m 0755 "$ISO_DIR"
|
||||
|
||||
# The series to check against, and the release to download when no local
|
||||
# file is given. Bump both together.
|
||||
UBUNTU_SERIES="26.04"
|
||||
UBUNTU_ISO="ubuntu-26.04.1-desktop-amd64.iso"
|
||||
UBUNTU_BASE="https://releases.ubuntu.com/${UBUNTU_SERIES}"
|
||||
|
||||
verify_against_canonical() { # verify_against_canonical <path> <basename>
|
||||
local path="$1" name="$2" want got
|
||||
echo "==> checking $name against Canonical's SHA256SUMS"
|
||||
want=$(curl -fsSL "$UBUNTU_BASE/SHA256SUMS" | awk -v f="*$name" '$2==f{print $1}')
|
||||
[ -n "$want" ] || {
|
||||
echo "$name is not in the published list for $UBUNTU_SERIES." >&2
|
||||
echo "Either it is from another series, or the name has been changed." >&2
|
||||
exit 1
|
||||
}
|
||||
got=$(sha256sum "$path" | awk '{print $1}')
|
||||
[ "$want" = "$got" ] || {
|
||||
echo "CHECKSUM MISMATCH - refusing to use this file." >&2
|
||||
echo " want $want" >&2
|
||||
echo " got $got" >&2
|
||||
exit 1
|
||||
}
|
||||
echo " ok $got"
|
||||
printf '%s %s verified against %s/SHA256SUMS\n' "$name" "$got" "$UBUNTU_BASE" \
|
||||
> "$ISO_DIR/ubuntu.manifest"
|
||||
}
|
||||
|
||||
fetch_ubuntu() {
|
||||
local supplied="${1:-}"
|
||||
if [ -n "$supplied" ]; then
|
||||
[ -f "$supplied" ] || { echo "no such file: $supplied" >&2; exit 1; }
|
||||
local name; name=$(basename "$supplied")
|
||||
verify_against_canonical "$supplied" "$name"
|
||||
# Copied rather than symlinked: the source is in somebody's Downloads
|
||||
# folder, and a base image build that fails halfway through because
|
||||
# the ISO was tidied away is a bad afternoon.
|
||||
echo "==> copying into $ISO_DIR"
|
||||
install -o owuivmadm -g owuivmadm -m 0644 "$supplied" "$ISO_DIR/$name"
|
||||
else
|
||||
local dest="$ISO_DIR/$UBUNTU_ISO"
|
||||
if [ -f "$dest" ]; then
|
||||
echo "already present: $dest"
|
||||
else
|
||||
echo "==> downloading $UBUNTU_ISO"
|
||||
curl -fL --retry 3 --continue-at - -o "$dest.part" "$UBUNTU_BASE/$UBUNTU_ISO"
|
||||
mv -f "$dest.part" "$dest"
|
||||
chown owuivmadm:owuivmadm "$dest"
|
||||
fi
|
||||
verify_against_canonical "$dest" "$UBUNTU_ISO"
|
||||
fi
|
||||
echo
|
||||
cat "$ISO_DIR/ubuntu.manifest"
|
||||
}
|
||||
|
||||
# The virtio driver disk. Windows setup cannot see a virtio-blk disk
|
||||
# without it and will tell you there are no drives to install to, which
|
||||
# reads like a broken VM rather than a missing driver. Fetched from the
|
||||
# same place every Windows-on-KVM guide uses; it is not signed by anyone
|
||||
# whose signature we can check, so the hash is recorded rather than
|
||||
# verified - exactly like the Windows media itself.
|
||||
VIRTIO_URL="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso"
|
||||
|
||||
fetch_virtio() {
|
||||
local dest="$ISO_DIR/virtio-win.iso"
|
||||
if [ -f "$dest" ]; then
|
||||
echo "already present: $dest"
|
||||
else
|
||||
echo "==> downloading virtio-win.iso"
|
||||
curl -fL --retry 3 --continue-at - -o "$dest.part" "$VIRTIO_URL"
|
||||
mv -f "$dest.part" "$dest"
|
||||
chown owuivmadm:owuivmadm "$dest"; chmod 0644 "$dest"
|
||||
fi
|
||||
printf 'virtio-win.iso %s recorded only - from %s\n' \
|
||||
"$(sha256sum "$dest" | awk '{print $1}')" "$VIRTIO_URL" > "$ISO_DIR/virtio.manifest"
|
||||
cat "$ISO_DIR/virtio.manifest"
|
||||
}
|
||||
|
||||
fetch_windows() {
|
||||
local supplied="${1:-}"
|
||||
[ -n "$supplied" ] || { echo "Windows media must be supplied: $0 windows /path/to.iso" >&2; exit 2; }
|
||||
[ -f "$supplied" ] || { echo "no such file: $supplied" >&2; exit 1; }
|
||||
local name got
|
||||
name=$(basename "$supplied")
|
||||
# The maintenance boot picks Windows media by name, so a file that does
|
||||
# not look like Windows media would either be ignored or - worse - the
|
||||
# Ubuntu ISO would be picked instead. Checked here, once, where it can
|
||||
# still be fixed by renaming rather than debugged from a boot failure.
|
||||
case "$name" in
|
||||
[Ww]in*.iso|*[Ww]indows*.iso) ;;
|
||||
*) echo "refusing '$name': Windows media must have a name starting with Win or containing Windows," >&2
|
||||
echo "because that is how the maintenance boot tells it apart from the Ubuntu ISO." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
got=$(sha256sum "$supplied" | awk '{print $1}')
|
||||
install -o owuivmadm -g owuivmadm -m 0644 "$supplied" "$ISO_DIR/$name"
|
||||
# RECORDED, not verified. See the header.
|
||||
printf '%s %s recorded only - no published checksum to compare against\n' "$name" "$got" \
|
||||
> "$ISO_DIR/windows.manifest"
|
||||
cat "$ISO_DIR/windows.manifest"
|
||||
echo
|
||||
fetch_virtio
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
ubuntu) fetch_ubuntu "${2:-}" ;;
|
||||
windows) fetch_windows "${2:-}" ;;
|
||||
virtio) fetch_virtio ;;
|
||||
*) echo "usage: $0 {ubuntu|windows|virtio} [/path/to/local.iso]" >&2; exit 2 ;;
|
||||
esac
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/bin/bash
|
||||
# =====================================================================
|
||||
# Import a prebuilt base image instead of building one.
|
||||
#
|
||||
# sudo ./vm/images/import.sh ubuntu
|
||||
# sudo ./vm/images/import.sh windows
|
||||
# sudo ./vm/images/import.sh both
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# Building Ubuntu takes about 25 unattended minutes. Windows has no
|
||||
# unattended path at all - it is a person clicking through setup in a VNC
|
||||
# console, loading a virtio driver by hand, and knowing when to stop. For
|
||||
# anyone who just wants the sub-agent working, downloading a finished
|
||||
# image is the difference between an afternoon and a coffee.
|
||||
#
|
||||
# WHY IT IS A SCRIPT AND NOT A LIST OF STEPS
|
||||
#
|
||||
# Three things have to be exactly right or the result fails later, in
|
||||
# ways that do not point at their cause:
|
||||
#
|
||||
# * the owner must be owuivmadm, or the maintenance tool cannot thaw it
|
||||
# * the mode must be 0444, because FROZEN IS THE OWNER WRITE BIT and
|
||||
# nothing else - a 0644 base means vmd refuses every session with
|
||||
# "freeze it and try again"
|
||||
# * the .sha256 must land beside the disk, or every saved fast-start
|
||||
# machine is treated as stale and sessions silently boot from cold
|
||||
#
|
||||
# A checklist gets those wrong. This does not.
|
||||
# =====================================================================
|
||||
set -euo pipefail
|
||||
|
||||
BASE=${OWUIVM_BASE_DIR:-/var/lib/owuivm/base}
|
||||
# Override to host your own. Any web server will do: the script only ever
|
||||
# does a ranged GET, and it verifies what it gets.
|
||||
SOURCE=${OWUIVM_IMAGE_SOURCE:-https://nextcloud.grantlan.com/s/OWUIToolsVMDisks}
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || { echo "import.sh must run as root (it writes $BASE)" >&2; exit 1; }
|
||||
command -v curl >/dev/null || { echo "FAILED: curl is not installed" >&2; exit 1; }
|
||||
getent passwd owuivmadm >/dev/null || {
|
||||
echo "FAILED: there is no owuivmadm user - run vm/host/install.sh first" >&2; exit 1; }
|
||||
|
||||
# Roughly what each one needs, so a download does not die at 95% with the
|
||||
# filesystem full. qcow2 is sparse but these are not thin.
|
||||
declare -A NEED=( [ubuntu]=11 [windows]=44 )
|
||||
|
||||
fetch() { # fetch <remote-name> <destination>
|
||||
local name="$1" dest="$2"
|
||||
# Nextcloud's public-share download endpoint. `-C -` resumes, which
|
||||
# matters: windows.qcow2 is 40 GB and a dropped connection three
|
||||
# quarters of the way through should not mean starting again.
|
||||
curl -fSL --retry 3 --retry-delay 5 -C - \
|
||||
-o "$dest" "$SOURCE/download?path=%2F&files=$name"
|
||||
}
|
||||
|
||||
import_one() {
|
||||
local name="$1"
|
||||
local disk="$BASE/$name.qcow2" vars="$BASE/$name-OVMF_VARS.fd" sums="$BASE/$name.sha256"
|
||||
|
||||
# A FINISHED IMAGE IS ONE THAT IS FROZEN. A part-downloaded one is not.
|
||||
#
|
||||
# The obvious guard - refuse if the file exists - contradicts the promise
|
||||
# that this is resumable: a download interrupted at 30 GB could never be
|
||||
# re-run. So the test is the same one everything else here uses, the
|
||||
# owner write bit. import.sh sets it 0444 only after the checksum has
|
||||
# passed, so a writable disk is by definition an unfinished one.
|
||||
if [ -e "$disk" ] && [ "$(( 8#$(stat -c %a "$disk") & 8#200 ))" -eq 0 ]; then
|
||||
echo "FAILED: $disk is already here and frozen." >&2
|
||||
echo " Importing would replace a base image you may have changed. Delete it" >&2
|
||||
echo " first if that is what you want - and note that any saved fast-start" >&2
|
||||
echo " machine for it becomes stale." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -e "$disk" ]; then
|
||||
echo "==> $name: an unfinished download is here; resuming it"
|
||||
fi
|
||||
|
||||
local free_gb
|
||||
free_gb=$(df -BG --output=avail "$BASE" | tail -1 | tr -dc '0-9')
|
||||
if [ "$free_gb" -lt "${NEED[$name]}" ]; then
|
||||
echo "FAILED: $name needs about ${NEED[$name]} GB and $BASE has ${free_gb} GB free" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> $name: downloading (${NEED[$name]} GB or so; resumable, ^C and re-run is safe)"
|
||||
install -d -o owuivmadm -g owuivmadm -m 0775 "$BASE"
|
||||
fetch "$name.sha256" "$sums"
|
||||
fetch "$name-OVMF_VARS.fd" "$vars"
|
||||
fetch "$name.qcow2" "$disk"
|
||||
|
||||
echo "==> $name: verifying"
|
||||
# BY BASENAME, NOT `sha256sum -c`.
|
||||
#
|
||||
# The manifest carries absolute paths - /var/lib/owuivm/base/... - because
|
||||
# that is where the freeze wrote it. `sha256sum -c` would therefore check
|
||||
# the files at THOSE paths, which are the ones already installed, not the
|
||||
# ones just downloaded. At the default location they are the same file and
|
||||
# it passes for the wrong reason; with OWUIVM_BASE_DIR set it verifies
|
||||
# somebody else's disk, or fails on a file that is not there.
|
||||
#
|
||||
# Measured: a test import into a scratch directory reported both files OK
|
||||
# while the scratch copy was a quarter downloaded.
|
||||
local bad=0 want got file
|
||||
while read -r want file; do
|
||||
[ -n "$want" ] || continue
|
||||
got=$(sha256sum "$BASE/$(basename "$file")" | awk '{print $1}')
|
||||
if [ "$want" = "$got" ]; then
|
||||
echo " $(basename "$file"): OK"
|
||||
else
|
||||
echo " $(basename "$file"): MISMATCH" >&2
|
||||
bad=1
|
||||
fi
|
||||
done < "$sums"
|
||||
if [ "$bad" -ne 0 ]; then
|
||||
echo "FAILED: the download is corrupt or incomplete." >&2
|
||||
echo " Re-run to resume, or delete $BASE/$name.* and start again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> $name: freezing"
|
||||
chown owuivmadm:owuivmadm "$disk" "$vars" "$sums"
|
||||
# 0444 IS the freeze. See the header.
|
||||
chmod 0444 "$disk" "$vars"
|
||||
chmod 0644 "$sums"
|
||||
echo " $name imported and frozen"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
ubuntu|windows) import_one "$1" ;;
|
||||
both) import_one ubuntu; import_one windows ;;
|
||||
*) echo "usage: import.sh {ubuntu|windows|both}" >&2; exit 64 ;;
|
||||
esac
|
||||
|
||||
echo
|
||||
echo "Done. Open Tool Server VM Maintenance and the row should read 'frozen'."
|
||||
echo "Sessions boot these from cold; press 'Save fast start' to make them resume"
|
||||
echo "in seconds instead."
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
#!/bin/bash
|
||||
# =====================================================================
|
||||
# Builds the frozen Ubuntu base image, unattended, from the stock ISO.
|
||||
#
|
||||
# sudo ./build.sh # ~25 minutes, mostly apt
|
||||
#
|
||||
# HOW THE INSTALL IS MADE UNATTENDED
|
||||
#
|
||||
# Subiquity will read an autoinstall config from a NoCloud volume, but it
|
||||
# stops to ask for confirmation unless `autoinstall` is on the kernel
|
||||
# command line. There is no way to pass a kernel argument to a CD-ROM, so
|
||||
# the ISO is repacked with that one word added to its grub menu entry.
|
||||
# The alternative - booting the ISO's kernel directly with -kernel - was
|
||||
# rejected because it bypasses OVMF, and an image installed outside UEFI
|
||||
# will not boot under owuivm-launch, which uses it.
|
||||
#
|
||||
# The repack is `xorriso ... -boot_image any replay`, which copies the
|
||||
# original boot records across verbatim. Anything else produces an ISO
|
||||
# that is subtly not bootable, usually only under UEFI, usually only on
|
||||
# the second attempt.
|
||||
#
|
||||
# THE BUILD RUNS AS owuivm ON PURPOSE
|
||||
#
|
||||
# Same uid, same egress filter, same everything as a real session. If the
|
||||
# sandbox's network rules would break `apt`, this is where that is found
|
||||
# - not three phases later with an agent in the loop to confuse it.
|
||||
# =====================================================================
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$(readlink -f "$0")")"
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; }
|
||||
|
||||
ISO_DIR=/var/lib/owuivm/iso
|
||||
BASE_DIR=/var/lib/owuivm/base
|
||||
WORK=$(mktemp -d /var/tmp/owuivm-build.XXXXXX)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
SRC_ISO="$ISO_DIR/$(awk '{print $1}' "$ISO_DIR/ubuntu.manifest")"
|
||||
AUTO_ISO="$WORK/ubuntu-autoinstall.iso"
|
||||
SEED_ISO="$WORK/seed.iso"
|
||||
DISK="$BASE_DIR/ubuntu.qcow2"
|
||||
VARS="$BASE_DIR/ubuntu-OVMF_VARS.fd"
|
||||
LOG=/var/log/owuivm-build-ubuntu.log
|
||||
|
||||
[ -f "$SRC_ISO" ] || { echo "no source ISO - run ../fetch.sh ubuntu first" >&2; exit 1; }
|
||||
|
||||
if [ -e "$DISK" ]; then
|
||||
echo "$DISK already exists."
|
||||
echo "Refusing to overwrite a base image: unfreeze and edit it through the"
|
||||
echo "maintenance tool, or delete it deliberately and re-run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> patching the ISO's boot menu"
|
||||
xorriso -osirrox on -indev "$SRC_ISO" -extract /boot/grub/grub.cfg "$WORK/grub.cfg" >/dev/null 2>&1
|
||||
# `autoinstall` - skips subiquity's confirmation prompt.
|
||||
# `console=ttyS0`- so the install is watchable in $LOG instead of being a
|
||||
# 25-minute black box that either works or does not.
|
||||
sed -i -e 's|^\(\s*linux\s*/casper/vmlinuz\)\s*---|\1 autoinstall console=ttyS0,115200 ---|' \
|
||||
-e 's|^set timeout=.*|set timeout=1|' "$WORK/grub.cfg"
|
||||
grep -q 'autoinstall' "$WORK/grub.cfg" || { echo "grub.cfg patch did not apply" >&2; exit 1; }
|
||||
|
||||
xorriso -indev "$SRC_ISO" -outdev "$AUTO_ISO" \
|
||||
-boot_image any replay \
|
||||
-map "$WORK/grub.cfg" /boot/grub/grub.cfg \
|
||||
-commit >/dev/null 2>&1
|
||||
[ -s "$AUTO_ISO" ] || { echo "repack produced nothing" >&2; exit 1; }
|
||||
|
||||
echo "==> building the NoCloud seed"
|
||||
# cloud-init finds this by volume label alone; no ds= argument needed.
|
||||
mkdir -p "$WORK/seed" && cp user-data meta-data "$WORK/seed/"
|
||||
xorriso -as mkisofs -o "$SEED_ISO" -V CIDATA -J -r "$WORK/seed" >/dev/null 2>&1
|
||||
|
||||
echo "==> creating the disk"
|
||||
install -d -o owuivmadm -g owuivmadm -m 0755 "$BASE_DIR"
|
||||
qemu-img create -f qcow2 "$DISK" 40G >/dev/null
|
||||
cp /usr/share/OVMF/OVMF_VARS_4M.fd "$VARS"
|
||||
chown owuivm:owuivm "$DISK" "$VARS"
|
||||
chmod 0644 "$DISK" "$VARS"
|
||||
chmod 0777 "$WORK"; chmod 0644 "$AUTO_ISO" "$SEED_ISO"
|
||||
|
||||
echo "==> installing (watch: tail -f $LOG)"
|
||||
: > "$LOG"; chown owuivm:owuivm "$LOG"
|
||||
# -no-reboot: subiquity reboots when it is done, and that is our signal
|
||||
# that the install finished. Without it the fresh system boots and the
|
||||
# build never returns.
|
||||
setpriv --reuid=owuivm --regid=owuivm --groups=kvm --inh-caps=-all -- \
|
||||
qemu-system-x86_64 \
|
||||
-no-user-config -nodefaults -no-reboot \
|
||||
-machine q35,accel=kvm -cpu host -smp 4 -m 4096 \
|
||||
-drive "if=pflash,format=raw,unit=0,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd" \
|
||||
-drive "if=pflash,format=raw,unit=1,file=$VARS" \
|
||||
-drive "file=$DISK,if=none,id=disk0,format=qcow2,cache=unsafe" \
|
||||
-device virtio-blk-pci,drive=disk0,bootindex=1 \
|
||||
-drive "file=$AUTO_ISO,if=none,id=cd0,media=cdrom,readonly=on" \
|
||||
-device ide-cd,drive=cd0,bus=ide.0,bootindex=2 \
|
||||
-drive "file=$SEED_ISO,if=none,id=cd1,media=cdrom,readonly=on" \
|
||||
-device ide-cd,drive=cd1,bus=ide.1 \
|
||||
-netdev user,id=net0,ipv6=off -device virtio-net-pci,netdev=net0,romfile= \
|
||||
-device VGA -display none \
|
||||
-serial "file:$LOG"
|
||||
|
||||
echo "==> freezing"
|
||||
# The write bit IS the freeze. owuivm is neither the owner nor in the
|
||||
# group, so the uid that runs every session cannot clear it; only the
|
||||
# maintenance unit, which runs as owuivmadm behind a polkit check, can.
|
||||
chown owuivmadm:owuivmadm "$DISK" "$VARS"
|
||||
chmod 0444 "$DISK" "$VARS"
|
||||
sha256sum "$DISK" "$VARS" > "$BASE_DIR/ubuntu.sha256"
|
||||
chown owuivmadm:owuivmadm "$BASE_DIR/ubuntu.sha256"
|
||||
|
||||
echo
|
||||
echo "base image built and frozen:"
|
||||
ls -l "$DISK" "$VARS"
|
||||
cat "$BASE_DIR/ubuntu.sha256"
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/bin/bash
|
||||
# =====================================================================
|
||||
# Everything the autoinstall does to a base image, for an image that was
|
||||
# installed by hand instead.
|
||||
#
|
||||
# run INSIDE the Ubuntu guest, as root, during a maintenance boot:
|
||||
# sudo bash by-hand.sh
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# user-data builds an image unattended and this file is its late-commands
|
||||
# written out longhand. An image installed interactively - which is a
|
||||
# perfectly reasonable way to build one, and how this host's first Ubuntu
|
||||
# image was made - misses every one of them, and most of the misses are
|
||||
# silent: the machine works, and something about it is wrong once a
|
||||
# session is running and nobody is watching.
|
||||
#
|
||||
# Idempotent on purpose. It is meant to be run again after the operator
|
||||
# installs something else, without having to remember what it already did.
|
||||
# =====================================================================
|
||||
set -uo pipefail
|
||||
[ "$(id -u)" -eq 0 ] || { echo "run this with sudo" >&2; exit 1; }
|
||||
|
||||
say() { printf '\n== %s\n' "$*"; }
|
||||
user=$(id -nu 1000 2>/dev/null || echo "")
|
||||
|
||||
say "the control channel"
|
||||
# THE ONE THING WITHOUT WHICH NOTHING WORKS.
|
||||
# It is how every command reaches this machine. No agent, no session -
|
||||
# and the failure looks exactly like a machine that did not boot.
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq qemu-guest-agent
|
||||
systemctl enable --now qemu-guest-agent
|
||||
|
||||
say "what scripts under test tend to reach for"
|
||||
apt-get install -y -qq python3 python3-pip python3-venv git curl jq unzip ca-certificates
|
||||
|
||||
say "the project share"
|
||||
# The session mounts this itself over the guest channel, so the fstab
|
||||
# line is belt and braces - it makes the mount happen on a boot nobody
|
||||
# is driving, and `nofail` is what keeps a machine with no share from
|
||||
# dropping into an emergency shell where nothing can reach it.
|
||||
mkdir -p /mnt/project
|
||||
grep -q '^project /mnt/project virtiofs' /etc/fstab \
|
||||
|| printf 'project /mnt/project virtiofs rw,nofail,x-systemd.device-timeout=5 0 0\n' >> /etc/fstab
|
||||
|
||||
say "two minutes of every boot"
|
||||
# MEASURED on this image: systemd-networkd-wait-online 2min 0.072s, with
|
||||
# graphical.target reached at 2min 3s. The desktop is rendered by
|
||||
# NetworkManager, so networkd has no links to wait for and sits out its
|
||||
# entire timeout. The guest agent answers at multi-user, so a CLI session
|
||||
# never noticed - which is why this stayed invisible until a GUI session
|
||||
# spent two minutes photographing a black screen.
|
||||
systemctl mask systemd-networkd-wait-online.service NetworkManager-wait-online.service
|
||||
|
||||
say "a desktop with nobody at the keyboard"
|
||||
if [ -n "$user" ]; then
|
||||
mkdir -p /etc/gdm3
|
||||
cat > /etc/gdm3/custom.conf <<EOF
|
||||
[daemon]
|
||||
AutomaticLoginEnable=true
|
||||
AutomaticLogin=$user
|
||||
EOF
|
||||
# MEASURED ON 26.04: `WaylandEnable=false` used to be here, with an
|
||||
# xorg.conf.d file pinning the mode, because the agent clicks at
|
||||
# absolute coordinates and a screen that changes size makes every
|
||||
# learned position wrong.
|
||||
#
|
||||
# This release has no Xorg to fall back to - xserver-xorg-core is not
|
||||
# installed and /usr/share/xsessions is empty - so that line does not
|
||||
# choose X, it leaves GDM with no session to log in to at all, and an
|
||||
# autologin into nothing looks exactly like a machine that failed to
|
||||
# boot a desktop.
|
||||
#
|
||||
# Wayland costs nothing here: the agent's eyes and hands are QMP
|
||||
# `screendump` and `input-send-event`, which are the virtual hardware
|
||||
# and know nothing about the display server. The mode comes from the
|
||||
# virtio-vga device instead of from a config file, and it is stable
|
||||
# for the same reason the netdev is - the qemu argv is a constant.
|
||||
rm -f /etc/X11/xorg.conf.d/10-owuivm-resolution.conf
|
||||
# Every session IS the first one - the disk is restored from the same
|
||||
# frozen base every time - so the first-run wizard would open a modal
|
||||
# dialog over the desktop in front of the agent, every single run.
|
||||
install -d -o 1000 -g 1000 "/home/$user/.config"
|
||||
echo yes > "/home/$user/.config/gnome-initial-setup-done"
|
||||
chown 1000:1000 "/home/$user/.config/gnome-initial-setup-done"
|
||||
else
|
||||
echo " no uid 1000, skipping the desktop settings"
|
||||
fi
|
||||
|
||||
say "sources of background change"
|
||||
# Every session starts from this frozen disk. Anything that updates
|
||||
# itself in the background makes two runs of the same script differ, and
|
||||
# the difference shows up as a script that is flaky rather than as a
|
||||
# machine that changed underneath it.
|
||||
systemctl disable --now unattended-upgrades.service 2>/dev/null
|
||||
systemctl disable --now apt-daily.timer apt-daily-upgrade.timer 2>/dev/null
|
||||
# SNAPD IS HELD, NOT DISABLED, AND THAT IS A DELIBERATE DIFFERENCE.
|
||||
#
|
||||
# The autoinstall disables it outright. On a desktop image that also
|
||||
# takes Firefox away, because Firefox IS a snap here - and a GUI session
|
||||
# whose agent cannot open a browser has lost something real. What was
|
||||
# actually wanted is "stop changing underneath me", and holding refreshes
|
||||
# says exactly that while leaving the snaps that are installed working.
|
||||
snap refresh --hold >/dev/null 2>&1 && echo " snap refreshes held" \
|
||||
|| echo " could not hold snap refreshes (old snapd?); check 'snap refresh --hold'"
|
||||
# A remote desktop server inside the sandbox is a listening socket with
|
||||
# no route to it and no reason to exist; the operator's console comes
|
||||
# from QEMU's own VNC.
|
||||
systemctl disable --now gnome-remote-desktop.service 2>/dev/null
|
||||
[ -d /etc/cloud ] && touch /etc/cloud/cloud-init.disabled
|
||||
|
||||
say "what this machine looks like now"
|
||||
printf ' guest agent : %s\n' "$(systemctl is-active qemu-guest-agent)"
|
||||
printf ' autologin : %s\n' "$(grep -h '^AutomaticLogin=' /etc/gdm3/custom.conf 2>/dev/null || echo 'not set')"
|
||||
printf ' fstab : %s\n' "$(grep -h '^project ' /etc/fstab 2>/dev/null || echo 'no share line')"
|
||||
printf ' resolver : %s\n' "$(awk '/^nameserver/{printf "%s ", $2}' /etc/resolv.conf)"
|
||||
printf ' internet : %s\n' "$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 https://example.com/ 2>&1 || echo unreachable)"
|
||||
echo
|
||||
echo "Shut the machine down from Tool Server VM Maintenance, then press Freeze."
|
||||
@@ -0,0 +1,2 @@
|
||||
instance-id: owuivm-base
|
||||
local-hostname: owuivm
|
||||
@@ -0,0 +1,213 @@
|
||||
#cloud-config
|
||||
# =====================================================================
|
||||
# Unattended install for the Ubuntu base image.
|
||||
#
|
||||
# Read with vm/images/ubuntu/build.sh, which repacks the stock server ISO
|
||||
# with `autoinstall` on the kernel command line and hands this file over
|
||||
# on a second volume labelled CIDATA.
|
||||
#
|
||||
# Everything here exists to make the guest behave identically on every
|
||||
# session. A base image that drifts is a base image that makes a failing
|
||||
# script look like a failing agent.
|
||||
# =====================================================================
|
||||
autoinstall:
|
||||
version: 1
|
||||
interactive-sections: []
|
||||
|
||||
locale: en_US.UTF-8
|
||||
keyboard:
|
||||
layout: us
|
||||
|
||||
# STATIC, not DHCP, and this is load-bearing.
|
||||
#
|
||||
# SLIRP hands out 10.0.2.3 as the DNS server, which is a forwarder
|
||||
# inside the qemu process that relays to THIS HOST's resolver. The
|
||||
# sandbox's egress filter denies 127.0.0.0/8, so that relay is dead by
|
||||
# design - and correctly so, since the host's resolver is a LAN service.
|
||||
# A guest that took DHCP would therefore get a nameserver that never
|
||||
# answers, and every agent session would open with a confusing "no
|
||||
# internet". Pinning a public resolver here makes the guest's DNS a
|
||||
# thing the guest owns, which is what the isolation model assumes.
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
primary:
|
||||
match:
|
||||
name: "en*"
|
||||
addresses: [10.0.2.15/24]
|
||||
routes:
|
||||
- to: default
|
||||
via: 10.0.2.2
|
||||
nameservers:
|
||||
addresses: [1.1.1.1, 9.9.9.9]
|
||||
|
||||
storage:
|
||||
layout:
|
||||
name: direct
|
||||
|
||||
identity:
|
||||
hostname: owuivm
|
||||
realname: VM User
|
||||
username: vmuser
|
||||
# The password is "vmuser", and it is documented on purpose.
|
||||
#
|
||||
# There is no network path to this machine - no hostfwd, no sshd, no
|
||||
# bridge - so it guards a console nobody but the operator can reach,
|
||||
# through a VNC socket that exists only in maintenance mode. It is
|
||||
# written down rather than hidden because hiding it would invite
|
||||
# treating it as a secret, which it is not.
|
||||
#
|
||||
# It is still a KNOWN password, and this file is published, so if you
|
||||
# ever give this machine a listening socket, change it first:
|
||||
#
|
||||
# openssl passwd -6 'your-password'
|
||||
#
|
||||
# and replace the hash below, before running build.sh.
|
||||
password: "$6$owuivm$eqPoBTdzXEA8WHYyXtQOlQfw4uOYGw2N4vC.Hl9KVdKOFuKQIEc0zvyVmy6t5XgVLhKr5Q9sdaWLOWWjBCaRZ1"
|
||||
|
||||
# No sshd. The agent reaches this machine over virtio-serial, which
|
||||
# needs no listening socket and keeps working when a script breaks the
|
||||
# guest's networking - which is exactly when the agent needs it most.
|
||||
ssh:
|
||||
install-server: false
|
||||
|
||||
packages:
|
||||
- qemu-guest-agent
|
||||
- ubuntu-desktop-minimal
|
||||
- python3
|
||||
- python3-pip
|
||||
- python3-venv
|
||||
- git
|
||||
- curl
|
||||
- ca-certificates
|
||||
- jq
|
||||
- unzip
|
||||
|
||||
# Updates during a session would make two runs of the same script
|
||||
# differ for reasons that have nothing to do with the script.
|
||||
package_update: false
|
||||
package_upgrade: false
|
||||
|
||||
late-commands:
|
||||
# The guest agent is the whole control channel. Without it the
|
||||
# machine boots and is unreachable, which looks identical to a
|
||||
# machine that failed to boot.
|
||||
- curtin in-target -- systemctl enable qemu-guest-agent.service
|
||||
|
||||
# Debian and its derivatives have historically shipped
|
||||
# /etc/default/qemu-guest-agent restricting guest-exec and
|
||||
# guest-file-*. Those two ARE the control channel here, so the file
|
||||
# is written explicitly rather than left to the package default.
|
||||
- |
|
||||
cat > /target/etc/default/qemu-guest-agent <<'EOF'
|
||||
# BLACKLIST_RPC is deliberately empty: guest-exec and guest-file-*
|
||||
# are how this machine is driven. There is no network path in, so
|
||||
# the RPC surface is reachable only from the host process that owns
|
||||
# the VM.
|
||||
DAEMON_ARGS=""
|
||||
EOF
|
||||
|
||||
# GUI mode drives whatever is on the screen, so something has to be
|
||||
# on it. Auto-login means a session exists from boot with no human,
|
||||
# and it is the same arrangement the Windows image uses so both
|
||||
# behave the same way.
|
||||
- |
|
||||
mkdir -p /target/etc/gdm3
|
||||
cat > /target/etc/gdm3/custom.conf <<'EOF'
|
||||
[daemon]
|
||||
AutomaticLoginEnable=true
|
||||
AutomaticLogin=vmuser
|
||||
EOF
|
||||
|
||||
# NO `WaylandEnable=false`, AND NO xorg.conf, ON PURPOSE.
|
||||
#
|
||||
# Both were here. The reasoning was sound - the agent clicks at
|
||||
# absolute coordinates, so the screen must be the same size every
|
||||
# session - and the mechanism stopped existing: 26.04 ships no Xorg
|
||||
# (xserver-xorg-core is not installed, /usr/share/xsessions is
|
||||
# empty), so turning Wayland off leaves GDM with nothing to log in
|
||||
# to. Measured on this host's image before it was frozen.
|
||||
#
|
||||
# The size is fixed anyway, one layer down: it comes from the
|
||||
# virtio-vga device in a qemu argv that is a constant, not from a
|
||||
# display server`s config file.
|
||||
|
||||
# NETPLAN IS WRITTEN HERE, BY HAND, AND NOT LEFT TO THE INSTALLER.
|
||||
#
|
||||
# Subiquity does not write /etc/netplan itself - it hands the network
|
||||
# config to cloud-init, which renders it on FIRST BOOT. This image
|
||||
# disables cloud-init a few lines below (it must not re-run on every
|
||||
# boot of a disk restored from a frozen base thousands of times), so
|
||||
# the netplan was never written at all and the guest silently fell
|
||||
# back to DHCP.
|
||||
#
|
||||
# That is not a cosmetic difference. SLIRP's DHCP hands out 10.0.2.3
|
||||
# as the nameserver, which is a forwarder inside the qemu process
|
||||
# that relays to THIS HOST's resolver - and the sandbox's egress
|
||||
# filter denies 127.0.0.0/8, correctly, because the host's resolver
|
||||
# is a LAN service. Measured: the guest came up with working TCP to
|
||||
# 1.1.1.1 and no name resolution whatsoever, which reads as "the
|
||||
# internet is broken" rather than "DNS is misconfigured".
|
||||
- |
|
||||
cat > /target/etc/netplan/00-owuivm.yaml <<'EOF'
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
primary:
|
||||
match:
|
||||
name: "en*"
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
addresses: [10.0.2.15/24]
|
||||
routes:
|
||||
- to: default
|
||||
via: 10.0.2.2
|
||||
nameservers:
|
||||
addresses: [1.1.1.1, 9.9.9.9]
|
||||
EOF
|
||||
chmod 0600 /target/etc/netplan/00-owuivm.yaml
|
||||
|
||||
# The project share. `nofail` because a session may legitimately run
|
||||
# without one, and a missing share must not drop the machine into an
|
||||
# emergency shell where nothing can reach it.
|
||||
- |
|
||||
mkdir -p /target/mnt/project
|
||||
printf 'project /mnt/project virtiofs rw,nofail,x-systemd.device-timeout=5 0 0\n' >> /target/etc/fstab
|
||||
|
||||
# MEASURED: this one service cost two minutes of every boot.
|
||||
#
|
||||
# `systemd-analyze blame` on the first build: systemd-networkd-wait-online
|
||||
# 2min 0.072s, and graphical.target reached after 2min 3s. The desktop is
|
||||
# rendered by NetworkManager here (netplan's default renderer for a
|
||||
# desktop install), so networkd has no links to wait for and sits out its
|
||||
# full timeout before failing. The guest agent answers at multi-user, so a
|
||||
# CLI session was unaffected and fast - which is exactly why this was
|
||||
# invisible until a GUI session took a screenshot of a black screen for
|
||||
# two minutes.
|
||||
- curtin in-target -- systemctl mask systemd-networkd-wait-online.service
|
||||
- curtin in-target -- systemctl mask NetworkManager-wait-online.service
|
||||
|
||||
# The first-run wizard opens a modal dialog over the desktop on every
|
||||
# session, because every session is the first one - the disk is restored
|
||||
# from the same frozen base every time. An agent driving the screen would
|
||||
# have to dismiss it before it could do anything, every single time.
|
||||
- |
|
||||
mkdir -p /target/home/vmuser/.config
|
||||
echo yes > /target/home/vmuser/.config/gnome-initial-setup-done
|
||||
chown -R 1000:1000 /target/home/vmuser/.config
|
||||
|
||||
# A remote desktop server inside the sandbox is a listening socket with
|
||||
# no way to reach it and no reason to exist. The operator's console comes
|
||||
# from QEMU's own VNC in maintenance mode, not from inside the guest.
|
||||
- curtin in-target -- systemctl disable gnome-remote-desktop.service || true
|
||||
|
||||
# Three sources of background change, all of which would make two
|
||||
# runs of the same script differ.
|
||||
- curtin in-target -- systemctl disable unattended-upgrades.service || true
|
||||
- curtin in-target -- systemctl disable apt-daily.timer apt-daily-upgrade.timer || true
|
||||
- curtin in-target -- systemctl disable snapd.service snapd.socket snapd.seeded.service || true
|
||||
|
||||
# cloud-init has done its job; leaving it enabled means it re-runs on
|
||||
# every boot of a disk that is restored from the same frozen base
|
||||
# thousands of times.
|
||||
- touch /target/etc/cloud/cloud-init.disabled
|
||||
@@ -0,0 +1,114 @@
|
||||
# The Windows base image
|
||||
|
||||
Unlike Ubuntu, this one is installed by hand. There is no unattended path here
|
||||
and that is deliberate: Windows licensing, edition choice and the out-of-box
|
||||
experience are all decisions for the operator, not for a script.
|
||||
|
||||
## What is already prepared
|
||||
|
||||
/var/lib/owuivm/iso/Win11_25H2_English_x64_v2.iso the media you supplied
|
||||
/var/lib/owuivm/iso/virtio-win.iso the driver disc
|
||||
|
||||
Both are recorded in `windows.manifest` and `virtio.manifest` with their
|
||||
SHA-256. **Recorded, not verified** - unlike Ubuntu, there is no published
|
||||
checksum list to compare a Windows ISO against, so the manifest says what the
|
||||
file hashed to and nothing stronger. That difference is real and is not papered
|
||||
over.
|
||||
|
||||
## Three things Windows needs that Ubuntu does not
|
||||
|
||||
All three are already in the launcher, applied only when the image is named
|
||||
`windows`:
|
||||
|
||||
| Requirement | What is provided | What happens without it |
|
||||
|---|---|---|
|
||||
| **TPM 2.0** | `swtpm` on a unix socket, `tpm-tis` device | Setup stops at "This PC can't run Windows 11" |
|
||||
| **Secure Boot** | `OVMF_CODE_4M.secboot.fd` + a variable store seeded from `OVMF_VARS_4M.ms.fd`, with `property=secure,value=on` so the guest cannot rewrite it | Same message. Microsoft's keys have to be *enrolled*, not just the firmware present |
|
||||
| **virtio drivers** | `virtio-win.iso` attached as a second CD during the install only | Setup reports no drives to install to, which looks like a broken VM |
|
||||
| **Hyper-V enlightenments** | fourteen `hv-*` flags on `-cpu`, Windows only | The machine is simply slow, and the host looks busy while the guest looks idle - measured at 347% CPU on an idle installer with the cgroup neither short of memory nor throttled |
|
||||
| **`-rtc base=localtime`** | Windows only | The clock is hours out, so TLS verification fails and every download breaks in a way that reads as a network fault |
|
||||
|
||||
`swtpm` also needed an AppArmor exception: Ubuntu's shipped profile permits
|
||||
libvirt's paths and nothing else, so the socket was refused with
|
||||
`Could not open UnixIO socket: Permission denied` - a policy denial wearing a
|
||||
permission error's clothes, with nothing in dmesg. See
|
||||
`vm/host/apparmor-local-swtpm`.
|
||||
|
||||
## Installing it
|
||||
|
||||
1. Finish the Ubuntu image first, or close it. Only one maintenance machine
|
||||
runs at a time and the prepare step refuses the second.
|
||||
Starting a *frozen* image is always safe, at any time: it boots a throwaway
|
||||
copy and cannot change what is on disk. That is the way to check that an
|
||||
image still comes up before handing it to the agent.
|
||||
2. **Tool Server VM Maintenance** on the desktop -> the Windows row ->
|
||||
**Install from ISO**.
|
||||
A blank 60 GB disk is created, the media is attached, and the console opens.
|
||||
3. **Two prompts eat keystrokes before Windows does, in this order.** OVMF shows
|
||||
its own "press a key for boot options" window for about five seconds - any
|
||||
key there drops you into the firmware menu, not the installer (if that
|
||||
happens: **Boot Manager** -> **UEFI QEMU DVD-ROM QM00001**, which is the
|
||||
Windows ISO; QM00003 is virtio-win). Then the ISO itself shows "Press any
|
||||
key to boot from CD or DVD" for about five seconds, and *that* one has to be
|
||||
answered or the machine falls through to an EFI shell. So: let the firmware
|
||||
window pass untouched, then press a key.
|
||||
4. When setup asks where to install and shows no drives, choose **Load driver**,
|
||||
browse the second CD, and take `amd64\w11` (or `amd64\2k25`). The disk
|
||||
appears immediately afterwards.
|
||||
5. Install normally. A local account is simplest - the machine has no way to
|
||||
reach a Microsoft account sign-in that needs a phone, and nothing about this
|
||||
image should depend on one.
|
||||
|
||||
## Before you shut down
|
||||
|
||||
The equivalents of what the Ubuntu image needed, and for the same reasons:
|
||||
|
||||
- **`qemu-guest-agent`** from the driver disc (`virtio-win-guest-tools.exe`, or
|
||||
`guest-agent\qemu-ga-x86_64.msi`). This is the entire control channel; without
|
||||
it the machine boots and is unreachable, which looks identical to a machine
|
||||
that failed to boot.
|
||||
- **Static networking**: address `10.0.2.15/24`, gateway `10.0.2.2`, DNS
|
||||
`1.1.1.1`. DHCP hands out SLIRP's own resolver at `10.0.2.3`, which relays to
|
||||
*this host's* resolver and is correctly blocked by the sandbox filter - giving
|
||||
a machine with working TCP and no name resolution.
|
||||
- **Auto-logon** for the admin account, so a GUI session has a desktop from boot
|
||||
with no human. `netplwiz`, or `Set-LocalUser` plus the `AutoAdminLogon`
|
||||
registry values.
|
||||
- **The rest of virtio-win**, not just the storage driver setup needed: NetKVM
|
||||
for the network, and the display driver in particular. Until it is installed
|
||||
`virtio-vga` is a plain VGA framebuffer to Windows and every redraw is a full
|
||||
repaint - which is most of what "the VM feels slow" turns out to be once the
|
||||
enlightenments are on.
|
||||
- **Turn off Windows Update's automatic reboots** and anything else that changes
|
||||
the machine between sessions. Every session starts from this frozen disk, so
|
||||
background change here is change in every future run.
|
||||
|
||||
## Then
|
||||
|
||||
Press **Shut down**. The guest is asked to power off over ACPI and systemd waits
|
||||
for it, so this is the machine shutting itself down rather than the plug coming
|
||||
out. The variable store is copied back on the way - a UEFI boot entry added by
|
||||
setup is otherwise lost and the disk will not boot.
|
||||
|
||||
Then press **Freeze**, which is the operator's own button and is not done for
|
||||
you: nothing re-freezes an image behind your back any more. Freezing sets `0444`
|
||||
and records the checksum, and until it is done no agent session will start -
|
||||
vmd refuses to open one on a writable base and says so.
|
||||
|
||||
Then press **Installed — remove disc**, which appears on the row while an
|
||||
install is in progress and wakes up once the machine is stopped. That is what
|
||||
says the install is finished: until it is pressed the installer media is still
|
||||
attached at every boot, deliberately, because a machine shut down halfway
|
||||
through setup needs its disc to be finished and nothing here can tell the two
|
||||
cases apart. The disc first, the disk second - the boot order tries the disk and
|
||||
falls through to the media, so an installed system boots itself either way and
|
||||
the only cost of leaving it attached is the ISO staying open.
|
||||
|
||||
## Not yet done
|
||||
|
||||
Nothing on this page has been run. The Ubuntu path was measured end to end;
|
||||
this one is written from the same code paths plus the documented Windows
|
||||
requirements, and the first real install will find something. The argv it will
|
||||
be given *is* asserted, by `tests/test_vm_launch_argv.sh`, which checks that the
|
||||
Windows machine gets secure-boot firmware, a TPM and a protected variable store
|
||||
while the Ubuntu one gets none of them.
|
||||
Reference in New Issue
Block a user