From 71c0c3d029f1366b7c5faa4f5f078210abe5f5b9 Mon Sep 17 00:00:00 2001 From: Guofang Date: Fri, 30 Oct 2026 16:12:23 +0800 Subject: [PATCH] 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 --- README.md | 19 ++++ build-windows-base.sh | 250 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100755 build-windows-base.sh diff --git a/README.md b/README.md index 1965269..298fb2c 100644 --- a/README.md +++ b/README.md @@ -109,3 +109,22 @@ image is built without root. The ISO has to be read as UDF (its ISO 9660 side on holds a README): macOS uses `hdiutil`, Linux uses `mount -t udf` as root, otherwise `udisksctl`, otherwise `7z` extracts it into a temporary directory (which needs the free space of the whole ISO). The `*.img` file is ignored by Git. + +## Windows base image + +`run-windows11.py` creates a missing system disk as a QCOW2 overlay on the base +image that `./download-images.sh --print-path windows11` prints, so a new VM does +not run Windows Setup again. `build-windows-base.sh` makes that base image: it +installs Windows unattended from the ISO in `install-iso/`, generalizes it with +`sysprep` over SSH, and stores the shut-down disk read-only. + +```bash +./build-windows-base.sh [--force] [--no-sysprep] [--compress] [OUTPUT.qcow2] +``` + +It refuses to replace an existing base image without `--force`, because the +overlays of existing VMs still point at that file. Build to another `OUTPUT` +(for example with a version in its name) and select it with +`WIN11_VM_BASE_IMAGE` instead. The installation runs in a scratch directory +`/var/tmp/build-windows-base.*` (set `BASE_BUILD_TMPDIR` to use another parent; about 25 GB are needed) and needs a private key that matches +the public key put on the answer media. diff --git a/build-windows-base.sh b/build-windows-base.sh new file mode 100755 index 0000000..e3ad824 --- /dev/null +++ b/build-windows-base.sh @@ -0,0 +1,250 @@ +#!/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 <&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" < + + + + WIN11 + China Standard Time + + + false + + + + + en-US + en-US + en-US + en-US + + + + true + true + true + true + true + 3 + + + true + guofang + + wang + 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"