Files
vm-images/build-windows-base.sh
T
GuofangandClaude Sonnet 5.5 71c0c3d029 Add build-windows-base.sh to build the Windows 11 base image
Installs Windows unattended from the ISO in a scratch VM (under /var/tmp
by default), generalizes it with sysprep over SSH and stores the shut-down
disk as the read-only base image that run-windows11.py clones.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 16:12:23 +08:00

251 lines
9.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# Build the Windows 11 base QCOW2 image that run-windows11.py clones.
#
# run-windows11.py creates a missing system disk as a QCOW2 overlay on this
# base image, so a new VM starts in seconds instead of running Windows Setup
# again. The base image is made once:
# 1. run-windows11.py --install-from-iso installs Windows unattended into a
# scratch state directory (Autounattend.xml, Configure-Windows.ps1: VirtIO
# drivers, OpenSSH, the administrator key from build-seed.sh);
# 2. once SSH answers, this script runs sysprep /generalize over SSH, which
# removes the machine identity and shuts the guest down;
# 3. the shut-down disk is converted into the output file and made read-only.
# The first boot of every clone then runs the specialize and OOBE passes of the
# answer file below (host name, account, automatic logon) without any prompt.
#
# Needs the Windows ISO in install-iso/ (see README.md), qemu-img, ssh, scp and
# a private key that matches the public key put on the answer media (the
# default ~/.ssh keys, or SSH_PUBLIC_KEY_FILE for the public half and
# SSH_PRIVATE_KEY_FILE for this script).
#
# Usage: build-windows-base.sh [--force] [--no-sysprep] [--keep-work] [--compress]
# [--ssh-port PORT] [OUTPUT.qcow2]
# OUTPUT defaults to the path that "download-images.sh --print-path windows11"
# prints, which is where run-windows11.py looks for the base image.
set -euo pipefail
images_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_dir="$(cd -- "${images_dir}/.." && pwd)"
launcher="${repo_dir}/run-windows11.py"
downloader="${images_dir}/download-images.sh"
usage() {
cat <<EOF
Usage: $(basename "$0") [--force] [--no-sysprep] [--keep-work] [--compress]
[--ssh-port PORT] [OUTPUT.qcow2]
Install Windows 11 from the ISO, generalize it with sysprep and store the disk
as the read-only base image that run-windows11.py clones.
--force Replace OUTPUT if it exists
--no-sysprep Skip sysprep and only shut the guest down; clones then share
the machine SID and host name (fine for stand-alone VMs)
--keep-work Keep the scratch state directory (also kept on failure)
--compress Compress the output (smaller, but clones read it slower)
--ssh-port PORT Host port forwarded to the guest's SSH during the build
(default: 2299; overrides BASE_BUILD_SSH_PORT)
-h, --help Show this help
Environment variables:
SSH_PRIVATE_KEY_FILE Private key to log in with (default: ssh's own choice)
SSH_PUBLIC_KEY_FILE Public key put on the answer media (see build-seed.sh)
VM_IMAGE_ARCH amd64 or arm64 (default: the host's)
BASE_BUILD_TMPDIR Parent of the scratch state directory, which needs about
25 GB (default: /var/tmp)
BASE_BUILD_TIMEOUT Seconds to wait for the installation (default: 5400)
EOF
}
die() {
printf 'Error: %s\n' "$1" >&2
exit 1
}
force=0
sysprep=1
keep_work=0
compress=0
ssh_port="${BASE_BUILD_SSH_PORT:-2299}"
output=""
while (($#)); do
case "$1" in
-h|--help) usage; exit 0 ;;
--force) force=1 ;;
--no-sysprep) sysprep=0 ;;
--keep-work) keep_work=1 ;;
--compress) compress=1 ;;
--ssh-port) shift; ssh_port="${1:?--ssh-port needs a value}" ;;
--ssh-port=*) ssh_port="${1#*=}" ;;
-*) usage >&2; exit 2 ;;
*) [[ -z "$output" ]] || { usage >&2; exit 2; }; output="$1" ;;
esac
shift
done
[[ "$ssh_port" =~ ^[0-9]+$ ]] || die "--ssh-port must be a number: ${ssh_port}"
for tool in qemu-img ssh scp python3; do
command -v "$tool" >/dev/null || die "${tool} is not installed."
done
[[ -x "$launcher" ]] || die "run-windows11.py was not found: ${launcher}"
[[ -x "$downloader" ]] || die "download-images.sh was not found: ${downloader}"
output="${output:-$("$downloader" --print-path windows11)}"
if [[ -e "$output" && $force -eq 0 ]]; then
die "${output} exists (it may be the backing file of existing VM disks); use --force or another OUTPUT."
fi
case "${VM_IMAGE_ARCH:-$(uname -m)}" in
arm64|aarch64) arch=arm64 ;;
x86_64|amd64) arch=amd64 ;;
*) die "unsupported architecture: ${VM_IMAGE_ARCH:-$(uname -m)}" ;;
esac
total_steps=5
step_number=0
step() {
step_number=$((step_number + 1))
printf '[%d/%d] %s\n' "$step_number" "$total_steps" "$*"
}
note() {
printf ' %s\n' "$*" >&2
}
mkdir -p "${BASE_BUILD_TMPDIR:-/var/tmp}"
work="$(mktemp -d "${BASE_BUILD_TMPDIR:-/var/tmp}/build-windows-base.XXXXXX")"
pid_file="${work}/qemu.pid"
succeeded=0
ssh_options=(-o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
-o LogLevel=ERROR -o ConnectTimeout=5)
[[ -z "${SSH_PRIVATE_KEY_FILE:-}" ]] || ssh_options+=(-i "$SSH_PRIVATE_KEY_FILE")
guest_ssh() { ssh -n "${ssh_options[@]}" -p "$ssh_port" guofang@127.0.0.1 "$@"; }
vm_running() {
[[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null
}
cleanup() {
if vm_running; then
kill "$(cat "$pid_file")" 2>/dev/null || true
fi
if ((succeeded && !keep_work)); then
rm -rf "$work"
else
note "Scratch directory kept: ${work}"
fi
}
trap cleanup EXIT
# Poll "$@" every 10 s until it succeeds; give up after $1 seconds.
wait_for() {
local limit="$1"
shift
local deadline=$((SECONDS + limit))
until "$@"; do
((SECONDS < deadline)) || return 1
sleep 10
done
}
# Only the SSH rule is forwarded so that a running VM's RDP/DSH ports do not clash.
printf 'SSH %s 22\n' "$ssh_port" >"${work}/forwards.conf"
step "Installing Windows 11 from the ISO (unattended; takes a while)"
WIN11_VM_FORWARDS="${work}/forwards.conf" "$launcher" \
--daemon --vnc=127.0.0.1:90 --install-from-iso --arch "$arch" --state-dir "$work"
step "Waiting for the guest to finish its first-logon setup (SSH answers)"
note "Screen: VNC 127.0.0.1:5990; console log: ${work}/console.log"
if ! wait_for "${BASE_BUILD_TIMEOUT:-5400}" guest_ssh 'echo ready' >/dev/null 2>&1; then
die "SSH did not answer in time; inspect the guest over VNC."
fi
# The answer file the clones use on their first boot. generalize removes the
# machine identity but keeps the guofang account, so OOBE only needs the
# language, the EULA and the automatic logon; the specialize pass restores the
# settings Autounattend.xml applies (host name, time zone, Remote Desktop).
write_sysprep_answer() {
cat >"$1" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="${arch}"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<ComputerName>WIN11</ComputerName>
<TimeZone>China Standard Time</TimeZone>
</component>
<component name="Microsoft-Windows-TerminalServices-LocalSessionManager"
processorArchitecture="${arch}" publicKeyToken="31bf3856ad364e35" language="neutral"
versionScope="nonSxS">
<fDenyTSConnections>false</fDenyTSConnections>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core" processorArchitecture="${arch}"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<InputLocale>en-US</InputLocale>
<SystemLocale>en-US</SystemLocale>
<UILanguage>en-US</UILanguage>
<UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="${arch}"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<SkipMachineOOBE>true</SkipMachineOOBE>
<SkipUserOOBE>true</SkipUserOOBE>
<ProtectYourPC>3</ProtectYourPC>
</OOBE>
<AutoLogon>
<Enabled>true</Enabled>
<Username>guofang</Username>
<Password>
<Value>wang</Value>
<PlainText>true</PlainText>
</Password>
</AutoLogon>
</component>
</settings>
</unattend>
EOF
}
if ((sysprep)); then
step "Generalizing the guest with sysprep"
write_sysprep_answer "${work}/sysprep-unattend.xml"
scp "${ssh_options[@]}" -P "$ssh_port" "${work}/sysprep-unattend.xml" \
guofang@127.0.0.1:C:/Windows/Temp/sysprep-unattend.xml
# The connection drops when the guest shuts down, which ssh reports as 255.
status=0
guest_ssh 'C:\Windows\System32\Sysprep\sysprep.exe /generalize /oobe /shutdown /quiet /unattend:C:\Windows\Temp\sysprep-unattend.xml' || status=$?
if ((status != 0 && status != 255)); then
guest_ssh 'type C:\Windows\System32\Sysprep\Panther\setuperr.log' >&2 || true
die "sysprep failed (exit code ${status}); the log is above."
fi
else
step "Shutting the guest down (sysprep skipped)"
guest_ssh 'shutdown /s /t 5' || true
fi
step "Waiting for the guest to power off"
if ! wait_for 900 bash -c '! kill -0 "$(cat "$1")" 2>/dev/null' _ "$pid_file"; then
if ((sysprep)); then
guest_ssh 'type C:\Windows\System32\Sysprep\Panther\setuperr.log' >&2 || true
fi
die "the guest did not power off; sysprep probably failed (log above, if SSH still answers)."
fi
step "Converting the disk to ${output}"
mkdir -p "$(dirname "$output")"
convert_options=(-f qcow2 -O qcow2)
((compress)) && convert_options+=(-c)
rm -f "${output}.part"
qemu-img convert -p "${convert_options[@]}" "${work}/disk.qcow2" "${output}.part"
[[ ! -e "$output" ]] || chmod u+w "$output"
mv -f "${output}.part" "$output"
chmod a-w "$output"
succeeded=1
printf 'Windows 11 base image is ready: %s\n' "$output"