Compare commits

...
23 Commits
Author SHA1 Message Date
GuofangandClaude Sonnet 5.5 fd79eaa407 Add a Rocky Linux 9 virtual machine
run-rocky9.py with its QEMU config, port forwards (SSH on 2226) and
cloud-init seed, modelled on the Fedora 44 launcher. Update vm-images for
the rocky9 download target.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 11:02:14 +08:00
GuofangandClaude Sonnet 5.5 007ba992af Point vm-images submodule at git.wangguofang.net
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 10:46:59 +08:00
GuofangandClaude Sonnet 5.5 70d595d61e Update vm-images: install prompts default to yes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 23:36:15 +08:00
GuofangandClaude Sonnet 5.5 428d99108b Download aqtinstall through the wangguofang.net GitHub proxy
Also update vm-images: download Win32-OpenSSH through the same proxy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 09:28:34 +08:00
guofangandClaude Sonnet 5.5 fc8134ecf9 Use virtio-net and virtio-gpu on arm64 again
The base image now carries the VirtIO drivers, which restores the 2304x1440
display that ramfb had lost.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:09:07 +08:00
guofangandClaude Sonnet 5.5 d639e1f785 Update vm-images: build the base image with a window by default
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 21:31:12 +08:00
guofangandClaude Sonnet 5.5 33dbc91fa0 Update vm-images: more download connections
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 21:00:51 +08:00
guofangandClaude Sonnet 5.5 152006f57d Update vm-images: step-by-step prompts for building the base image
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 20:53:53 +08:00
guofangandClaude Sonnet 5.5 0ca1390895 Update vm-images: clearer missing base image error
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 20:26:09 +08:00
GuofangandClaude Sonnet 5.5 48c4477ec4 Update vm-images: audit-mode base image
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 12:51:54 +08:00
GuofangandClaude Sonnet 5.5 a920ba0ca5 Windows launcher only creates a differential disk; seed via Unattend.xml
run-windows11.py no longer installs Windows or touches ISO/img files: it
creates a QCOW2 overlay on the base image (made by vm-images/build-windows-base.sh)
and, when the disk is created, mounts seed.iso. Windows reads the Unattend.xml
in its root during the specialize and OOBE passes (host name, account, auto
logon, Remote Desktop) and Configure-Windows.ps1 then installs OpenSSH with the
host's key, turns off Windows Update and copies the installer scripts.

The VM hardware uses devices with inbox Windows drivers (IDE/NVMe, e1000e,
ramfb on arm64), since the base image has no VirtIO drivers. Autounattend.xml
moves to vm-images; the vm-images pointer is updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 11:19:48 +08:00
GuofangandClaude Sonnet 5.5 fe7250870e Attach the cloud-init seed ISO only when the VM disk is created
cloud-init reads the seed on the first boot only, so the Linux launchers now
mount it just for the run that creates the overlay disk. The seed drive moves
from the config files to the launcher arguments.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 11:19:48 +08:00
GuofangandClaude Sonnet 5.5 b149dd4bc3 Press a key automatically at the Windows ISO boot prompt
bootmgr waits for a key ("Press any key to boot from CD or DVD...") and
gives up after a few seconds. For ISO installs, a detached helper now taps
the space bar through the QEMU monitor for the first 30 s. Also update the
vm-images submodule for the new --window option.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 10:05:55 +08:00
guofang 2a8d5a1bed Update submodule URL for vm-images to use the correct repository path 2026-11-01 21:13:48 +08:00
GuofangandClaude Sonnet 5.5 a81f0f6e81 Update vm-images to add build-windows-base.sh
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 16:12:48 +08:00
GuofangandClaude Sonnet 5.5 bea0dd4d70 Inject the SSH public key into the Fedora and AlmaLinux seed ISOs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 14:02:58 +08:00
GuofangandClaude Sonnet 5.5 83df64042c Forward host port 3080 to the Windows guest as DSH
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 14:58:38 +08:00
GuofangandClaude Sonnet 5.5 78f6fd424b Read port forwards from per-VM forwards.conf files
A QEMU config file section cannot hold more than one hostfwd entry, so
the forwards move out of the ini files into qemu/<vm>/forwards.conf, one
"LABEL HOST_PORT GUEST_PORT" rule per line. The new vmforwards.py parses
them, skips rules whose host port is taken and builds the -netdev
argument, which every launcher now passes instead of relying on a
netdev section in the ini or on -nic.

The ini files move to qemu/<vm>/config.ini and qemu/<vm>/<os>-<arch>.ini.
The *_SSH_PORT / *_RDP_PORT variables and --rdp-port / --ssh-port still
replace the host port of the SSH and RDP rules.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 14:55:30 +08:00
GuofangandClaude Sonnet 5.5 07a2c6db3e Skip kernel-irqchip=off when installing with --install-from-img
Move the setting from qemu/windows11-darwin-arm64.ini to a -machine option
that run-windows11.py adds on macOS arm64 unless --install-from-img is given.
The workaround is only needed for usb-storage, which the NVMe install disk
does not use, and Setup hangs with the emulated interrupt controller.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 13:33:06 +08:00
GuofangandClaude Sonnet 5.5 887da87340 Show the RDP and SSH forwards as listening on 0.0.0.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 11:28:56 +08:00
GuofangandClaude Sonnet 5.5 2b237bb8b3 Add a tool to switch the Windows display scale between 100% and 200%
81-Toggle-Scale.ps1 writes LogPixels, Win8DpiScaling and AppliedDPI for
the current user; the change needs a restart, so the .bat launcher offers
to restart at the end. It is picked up by the existing NN-*.bat/.ps1
globs and copied to Desktop\Tools.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 15:06:08 +08:00
guofangandClaude Sonnet 5.5 d2fe47c911 Add a --vnc option to run-windows11.py
Serves the guest screen over VNC instead of opening a display window.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:50:14 +08:00
GuofangandClaude Sonnet 5.5 72af276309 Add an update-status tool and copy numbered scripts to Desktop\Tools
80-Get-UpdateStatus-Admin.ps1 prints whether the Windows 11 guest is
upgrading or downloading updates (policy, pending markers, services,
processes, BITS jobs, update events, network use); the .bat launcher
elevates and keeps the window open.

The seed ISO and Configure-Windows.ps1 now take every NN-*.bat and
NN-*.ps1, and the first-logon copy goes to Desktop\Tools instead of
Desktop\Installers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:27:52 +08:00
61 changed files with 1634 additions and 918 deletions

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
[submodule "vm-images"] [submodule "vm-images"]
path = vm-images path = vm-images
url = https://wangguofang.net/git/vm-images.git url = https://git.wangguofang.net/guofang/vm-images.git
+91 -43
View File
@@ -5,6 +5,7 @@ Start the required operating system with its distribution-specific launcher:
```bash ```bash
./run-debian13.py ./run-debian13.py
./run-almalinux10.py ./run-almalinux10.py
./run-rocky9.py
./run-fedora44.py ./run-fedora44.py
./run-ubuntu2604.py ./run-ubuntu2604.py
./run-windows11.py ./run-windows11.py
@@ -26,12 +27,12 @@ original qcow2 image as its backing file. The base image therefore stays
unchanged. Run `./run-debian13.py --help` for the supported environment unchanged. Run `./run-debian13.py --help` for the supported environment
variables. variables.
The Debian VM hardware (8G memory, 8 CPUs, drives, network) is The Debian VM hardware (8G memory, 8 CPUs, drives) is
defined in the QEMU config file `qemu/debian13.ini`; the machine type and defined in the QEMU config file `qemu/debian13/config.ini`; the machine type and
architecture-specific devices are in `qemu/debian13-<os>-<arch>.ini` architecture-specific devices are in `qemu/debian13/<os>-<arch>.ini`
(os `linux` or `darwin`, arch `amd64` or `arm64`), together with the (os `linux` or `darwin`, arch `amd64` or `arm64`), together with the
accelerators. Edit these files to change the settings. The accelerators. Edit these files to change the settings. The
launcher fills in the `${...}` file path placeholders of `qemu/debian13.ini`, launcher fills in the `${...}` file path placeholders of `qemu/debian13/config.ini`,
writes the result to `vm/debian13/qemu.ini` and starts QEMU with writes the result to `vm/debian13/qemu.ini` and starts QEMU with
`-readconfig` for that file followed by the one for the host system and guest architecture. `-readconfig` for that file followed by the one for the host system and guest architecture.
QEMU arguments given after `--` override the config files, for example QEMU arguments given after `--` override the config files, for example
@@ -48,8 +49,7 @@ corresponding `*_STATE_DIR` environment variable:
With `--download-only`, a launcher only downloads the media it would fetch With `--download-only`, a launcher only downloads the media it would fetch
automatically and then exits without creating a VM disk or starting QEMU: the automatically and then exits without creating a VM disk or starting QEMU: the
base image for Debian, AlmaLinux, Fedora and Ubuntu, and the VirtIO tools ISO base image for Debian, AlmaLinux, Rocky Linux, Fedora and Ubuntu:
for Windows 11 (whose installation ISO and base QCOW2 are supplied manually):
```bash ```bash
./run-fedora44.py --download-only ./run-fedora44.py --download-only
@@ -102,6 +102,27 @@ stored alongside the VM disk at `vm/almalinux10/seed.iso`. If it already
exists after editing its source files, rebuild it explicitly with exists after editing its source files, rebuild it explicitly with
`./seediso/almalinux10/build-seed.sh vm/almalinux10/seed.iso`. `./seediso/almalinux10/build-seed.sh vm/almalinux10/seed.iso`.
## Rocky Linux 9
Start a Rocky Linux 9 GenericCloud Base VM with:
```bash
./run-rocky9.py
```
The launcher downloads the `rocky9` image through the shared helper when it
is missing, creates `vm/rocky9/disk.qcow2`, and builds its NoCloud seed from
`seediso/rocky9/`. SSH is forwarded to host port `2226` by default:
```bash
ssh -p 2226 guofang@localhost
```
Like the Debian and Fedora VMs, the Rocky Linux VM hardware (8G memory, 8 CPUs, drives) is
defined in `qemu/rocky9/config.ini`, with the accelerators, machine type and
platform-specific devices in `qemu/rocky9/<os>-<arch>.ini`. Other Rocky Linux settings use the `ROCKY_VM_` prefix.
Run `./run-rocky9.py --help` for the complete list of overrides.
## Fedora 44 ## Fedora 44
Start a Fedora 44 Cloud Base VM with: Start a Fedora 44 Cloud Base VM with:
@@ -122,9 +143,9 @@ On the first boot, cloud-init updates package metadata and installs Fedora's
GNOME package group through its native package module, then reboots the guest. GNOME package group through its native package module, then reboots the guest.
Keep the VM running until this initial installation and reboot have completed. Keep the VM running until this initial installation and reboot have completed.
Like the Debian VM, the Fedora VM hardware (8G memory, 8 CPUs, drives, SSH Like the Debian VM, the Fedora VM hardware (8G memory, 8 CPUs, drives) is
port `2224`) is defined in `qemu/fedora44.ini`, with the accelerators, machine type and defined in `qemu/fedora44/config.ini`, with the accelerators, machine type and
platform-specific devices in `qemu/fedora44-<os>-<arch>.ini`. Other Fedora settings use the `FEDORA_VM_` prefix. platform-specific devices in `qemu/fedora44/<os>-<arch>.ini`. Other Fedora settings use the `FEDORA_VM_` prefix.
Run `./run-fedora44.py --help` for the complete list of overrides. Run `./run-fedora44.py --help` for the complete list of overrides.
## Ubuntu 26.04 ## Ubuntu 26.04
@@ -150,9 +171,9 @@ On the first boot, cloud-init installs `ubuntu-desktop-minimal` (GNOME) and
guest. Keep the VM running until this initial installation and reboot have guest. Keep the VM running until this initial installation and reboot have
completed. completed.
The VM hardware (8G memory, 8 CPUs, drives, SSH port `2225`) is defined in The VM hardware (8G memory, 8 CPUs, drives) is defined in
`qemu/ubuntu2604.ini`, with the accelerators, machine type and platform-specific `qemu/ubuntu2604/config.ini`, with the accelerators, machine type and platform-specific
devices in `qemu/ubuntu2604-<os>-<arch>.ini`. Other Ubuntu devices in `qemu/ubuntu2604/<os>-<arch>.ini`. Other Ubuntu
settings use the `UBUNTU_VM_` prefix. Run `./run-ubuntu2604.py --help` for the settings use the `UBUNTU_VM_` prefix. Run `./run-ubuntu2604.py --help` for the
complete list of overrides. complete list of overrides.
@@ -165,40 +186,67 @@ from the shared Windows 11 QCOW2 base image:
./run-windows11.py ./run-windows11.py
``` ```
It creates a persistent 64G disk at `vm/windows11/disk.qcow2`, automatically It creates a persistent 64G disk at `vm/windows11/disk.qcow2` as a QCOW2
partitions it, completes OOBE, creates the local administrator `guofang` with overlay on the base image and nothing else: the launcher only creates the
the initial password `wang`, and enables Remote Desktop on differential disk and starts QEMU. The base image is generic and holds nothing
`localhost:3389`. During the first login it also silently installs the VirtIO but Windows (see `vm-images/README.md`): no account, no SSH, no other
drivers (the guest tools installer on amd64, the Arm64 driver packages from the hardware-specific drivers, no machine identity. Like a cloud image, each VM is
VirtIO ISO with `pnputil` on arm64), without which the guest has no network on configured on its first boot, from the seed ISO.
arm64. The installer includes the VirtIO drivers, SPICE vdagent, and QEMU Guest
Agent. The launcher exposes the vdagent channel for host/guest clipboard Whenever the disk does not exist yet, the launcher also mounts the seed ISO
integration and the QEMU Guest Agent at `vm/windows11/qemu-guest-agent.sock`. `vm/windows11/seed.iso` (beside the disk; built from `seediso/windows11/` when
Change the password after first login. The OpenSSH Server is installed during missing or outdated, with the OpenSSH zip and the host's SSH public key,
the first logon from the Win32-OpenSSH zip for the host architecture, which the `$SSH_PUBLIC_KEY_FILE` or the first of `~/.ssh/id_ed25519.pub`, `id_ecdsa.pub`,
seed ISO carries (downloaded by `vm-images/download-images.sh openssh-win`), so `id_rsa.pub`). Windows reads the `Unattend.xml` in the root of that CD-ROM
Windows Update is not needed. Connect with `ssh guofang@localhost -p 2222`; the during its specialize and OOBE passes: host name `WIN11`, time zone, Remote
seed ISO also carries the host's SSH public key (`$SSH_PUBLIC_KEY_FILE`, else the Desktop on `0.0.0.0:3389` (all host interfaces; connect to `localhost:3389` on
first of `~/.ssh/id_ed25519.pub`, `id_ecdsa.pub`, `id_rsa.pub`) for passwordless the host itself), the local administrator `guofang` (initial password `wang`;
administrator login. change it after first login) and automatic logon. At the first logon it runs
`Configure-Windows.ps1` from the seed ISO, which installs the OpenSSH Server
from the zip with the SSH key (connect with `ssh guofang@localhost -p 2222`),
turns off Windows Update and copies the numbered installer scripts
(`seediso/windows11/NN-*.bat` and `.ps1`) to the desktop folder `Tools`. Later
starts do not mount the seed ISO.
The base image carries the VirtIO drivers (`build-windows-base.sh` installs them
from the virtio-win ISO before generalizing), so on arm64 the VM uses virtio-net and
virtio-gpu (2304x1440 on macOS); amd64 uses an IDE disk and an e1000e NIC. The SPICE
vdagent and QEMU Guest Agent come with the VirtIO guest tools, so host/guest
clipboard integration and the guest agent are not available unless you install
those tools in the VM yourself.
The Windows VM hardware (8G memory, 8 CPUs, system disk, devices) The Windows VM hardware (8G memory, 8 CPUs, system disk, devices)
is defined in `qemu/windows11.ini`, with the accelerators and machine type in is defined in `qemu/windows11/config.ini`, with the accelerators and machine type in
`qemu/windows11-<os>-<arch>.ini`; the launcher renders the former to `qemu/windows11/<os>-<arch>.ini`; the launcher renders the former to
`vm/windows11/qemu.ini` and loads both with `-readconfig`. Only the network `vm/windows11/qemu.ini` and loads both with `-readconfig`. Only the network
and the installation media are still set by the launcher. QEMU arguments after (see Port forwards) and the seed ISO are still set by the launcher. QEMU arguments after
`--` override the config files, for example `./run-windows11.py -- -m 4G -smp 4`. `--` override the config files, for example `./run-windows11.py -- -m 4G -smp 4`.
The generated answer-file ISO is stored beside the disk as
`vm/windows11/seed.iso`.
To install from the Windows ISO instead, use `./run-windows11.py The launcher has no installation options and stops with a hint if the base image
--install-from-iso`. This choice only applies while creating a missing system is missing. Make the base image once with `vm-images/build-windows-base.sh`,
disk; subsequent starts always boot the existing system disk normally. During which is self-contained in `vm-images/` (its own answer file, QEMU config and
ISO installation, the launcher resolves its tools ISO through the shared image installation logic, written in bash): it installs Windows unattended from the
helper and downloads it when missing. Override the base QCOW2, source ISO, ISO into a scratch disk, runs `sysprep` as its last step and stores the disk as
tools ISO, RDP port, or SSH port with the `WIN11_VM_` environment the read-only base image. Override the base QCOW2, seed ISO, RDP port, or SSH
variables shown by `./run-windows11.py --help`. For example, when the VirtIO ISO port with the `WIN11_VM_` environment variables shown by
is elsewhere: `./run-windows11.py --help`.
## Port forwards
The host ports forwarded to the guest are not in the QEMU config files (a
config file section cannot hold more than one `hostfwd`) but in one file per
VM, `qemu/<vm>/forwards.conf`, which the launcher turns into the `-netdev`
argument. Each line is `LABEL HOST_PORT GUEST_PORT`; `#` starts a comment:
```bash
WIN11_VM_TOOLS_ISO=/custom/path/virtio-win.iso ./run-windows11.py --install-from-iso
``` ```
# qemu/windows11/forwards.conf
RDP 3389 3389
SSH 2222 22
```
Add a line to forward another port. A rule whose host port is already in use
is skipped with a warning. `ALMA_VM_FORWARDS`, `ROCKY_VM_FORWARDS`, `FEDORA_VM_FORWARDS`,
`UBUNTU_VM_FORWARDS`, `VM_FORWARDS` (Debian) and `WIN11_VM_FORWARDS` select
another file. `ALMA_VM_SSH_PORT`, `WIN11_VM_RDP_PORT`, `WIN11_VM_SSH_PORT`
and `--rdp-port`/`--ssh-port` still replace the host port of the rule with the
label `SSH` or `RDP`. The launchers import `vmforwards.py` from the repository
root.
+2
View File
@@ -0,0 +1,2 @@
# Host ports forwarded to the guest: LABEL HOST_PORT GUEST_PORT
SSH 2223 22
+4 -16
View File
@@ -1,6 +1,6 @@
# QEMU configuration for the Debian 13 virtual machine, shared by all # QEMU configuration for the Debian 13 virtual machine, shared by all
# architectures. The accelerators, machine type and # architectures. The accelerators, machine type and
# platform-specific devices are in debian13-<os>-<arch>.ini (os is linux or darwin), # platform-specific devices are in <os>-<arch>.ini (os is linux or darwin),
# which QEMU reads after this file. # which QEMU reads after this file.
# #
# run-debian13.py fills in the file path placeholders below, writes the result # run-debian13.py fills in the file path placeholders below, writes the result
@@ -39,21 +39,9 @@
driver = "virtio-blk-pci" driver = "virtio-blk-pci"
drive = "disk0" drive = "disk0"
# cloud-init seed ISO. # The NIC is attached to the user-mode network "net0", which the launcher
[drive "seed0"] # defines with -netdev from qemu/debian13/forwards.conf because a config file
if = "none" # section cannot hold more than one hostfwd.
format = "raw"
readonly = "on"
file = "${seed_image}"
[device "seed0-dev"]
driver = "virtio-blk-pci"
drive = "seed0"
# User-mode networking with guest SSH forwarded to the host.
[netdev "net0"]
type = "user"
hostfwd = "tcp::2222-:22"
[device "nic0"] [device "nic0"]
driver = "virtio-net-pci" driver = "virtio-net-pci"
@@ -1,4 +1,4 @@
# macOS host, arm64 part of the Debian 13 QEMU configuration; read after debian13.ini. # macOS host, arm64 part of the Debian 13 QEMU configuration; read after config.ini.
# Accelerators are tried in order: HVF on macOS, else emulation. # Accelerators are tried in order: HVF on macOS, else emulation.
[accel] [accel]
+2
View File
@@ -0,0 +1,2 @@
# Host ports forwarded to the guest: LABEL HOST_PORT GUEST_PORT
SSH 2222 22
@@ -1,4 +1,4 @@
# Linux host, amd64 part of the Debian 13 QEMU configuration; read after debian13.ini. # Linux host, amd64 part of the Debian 13 QEMU configuration; read after config.ini.
# Accelerators are tried in order: KVM on Linux, else emulation. # Accelerators are tried in order: KVM on Linux, else emulation.
[accel] [accel]
@@ -1,4 +1,4 @@
# Linux host, arm64 part of the Debian 13 QEMU configuration; read after debian13.ini. # Linux host, arm64 part of the Debian 13 QEMU configuration; read after config.ini.
# Accelerators are tried in order: KVM on Linux, else emulation. # Accelerators are tried in order: KVM on Linux, else emulation.
[accel] [accel]
+4 -16
View File
@@ -1,6 +1,6 @@
# QEMU configuration for the Fedora 44 virtual machine, shared by all # QEMU configuration for the Fedora 44 virtual machine, shared by all
# architectures. The accelerators, machine type and # architectures. The accelerators, machine type and
# platform-specific devices are in fedora44-<os>-<arch>.ini (os is linux or darwin), # platform-specific devices are in <os>-<arch>.ini (os is linux or darwin),
# which QEMU reads after this file. # which QEMU reads after this file.
# #
# run-fedora44.py fills in the file path placeholders below, writes the result # run-fedora44.py fills in the file path placeholders below, writes the result
@@ -39,21 +39,9 @@
driver = "virtio-blk-pci" driver = "virtio-blk-pci"
drive = "disk0" drive = "disk0"
# cloud-init seed ISO. # The NIC is attached to the user-mode network "net0", which the launcher
[drive "seed0"] # defines with -netdev from qemu/fedora44/forwards.conf because a config file
if = "none" # section cannot hold more than one hostfwd.
format = "raw"
readonly = "on"
file = "${seed_image}"
[device "seed0-dev"]
driver = "virtio-blk-pci"
drive = "seed0"
# User-mode networking with guest SSH forwarded to the host.
[netdev "net0"]
type = "user"
hostfwd = "tcp::2224-:22"
[device "nic0"] [device "nic0"]
driver = "virtio-net-pci" driver = "virtio-net-pci"
@@ -1,4 +1,4 @@
# macOS host, arm64 part of the Fedora 44 QEMU configuration; read after fedora44.ini. # macOS host, arm64 part of the Fedora 44 QEMU configuration; read after config.ini.
# Accelerators are tried in order: HVF on macOS, else emulation. # Accelerators are tried in order: HVF on macOS, else emulation.
[accel] [accel]
+2
View File
@@ -0,0 +1,2 @@
# Host ports forwarded to the guest: LABEL HOST_PORT GUEST_PORT
SSH 2224 22
@@ -1,4 +1,4 @@
# Linux host, amd64 part of the Fedora 44 QEMU configuration; read after fedora44.ini. # Linux host, amd64 part of the Fedora 44 QEMU configuration; read after config.ini.
# Accelerators are tried in order: KVM on Linux, else emulation. # Accelerators are tried in order: KVM on Linux, else emulation.
[accel] [accel]
@@ -1,4 +1,4 @@
# Linux host, arm64 part of the Fedora 44 QEMU configuration; read after fedora44.ini. # Linux host, arm64 part of the Fedora 44 QEMU configuration; read after config.ini.
# Accelerators are tried in order: KVM on Linux, else emulation. # Accelerators are tried in order: KVM on Linux, else emulation.
[accel] [accel]
+48
View File
@@ -0,0 +1,48 @@
# QEMU configuration for the Rocky Linux 9 virtual machine, shared by all
# architectures. The accelerators, machine type and
# platform-specific devices are in <os>-<arch>.ini (os is linux or darwin),
# which QEMU reads after this file.
#
# run-rocky9.py fills in the file path placeholders below, writes the result
# to <state dir>/qemu.ini and passes it to QEMU with -readconfig. Options that
# have no -readconfig equivalent (-cpu, -display, daemon mode) are still added
# by the launcher.
[name]
guest = "rocky9"
[memory]
size = "8G"
[smp-opts]
cpus = "8"
# UEFI firmware: read-only code and the per-VM variable store.
[drive "pflash0"]
if = "pflash"
format = "raw"
readonly = "on"
file = "${uefi_code}"
[drive "pflash1"]
if = "pflash"
format = "raw"
file = "${uefi_vars}"
# Writable qcow2 overlay on top of the shared base image.
[drive "disk0"]
if = "none"
format = "qcow2"
file = "${disk_image}"
[device "disk0-dev"]
driver = "virtio-blk-pci"
drive = "disk0"
# The NIC is attached to the user-mode network "net0", which the launcher
# defines with -netdev from qemu/rocky9/forwards.conf because a config file
# section cannot hold more than one hostfwd.
[device "nic0"]
driver = "virtio-net-pci"
netdev = "net0"
+24
View File
@@ -0,0 +1,24 @@
# macOS host, arm64 part of the Rocky Linux 9 QEMU configuration; read after config.ini.
# Accelerators are tried in order: HVF on macOS, else emulation.
[accel]
accel = "hvf"
[accel]
accel = "tcg"
[machine]
type = "virt"
# Unlike q35, the "virt" machine has no default GPU or USB input devices.
[device "gpu0"]
driver = "virtio-gpu-pci"
[device "usb0"]
driver = "qemu-xhci"
[device "kbd0"]
driver = "usb-kbd"
[device "tablet0"]
driver = "usb-tablet"
+2
View File
@@ -0,0 +1,2 @@
# Host ports forwarded to the guest: LABEL HOST_PORT GUEST_PORT
SSH 2226 22
+11
View File
@@ -0,0 +1,11 @@
# Linux host, amd64 part of the Rocky Linux 9 QEMU configuration; read after config.ini.
# Accelerators are tried in order: KVM on Linux, else emulation.
[accel]
accel = "kvm"
[accel]
accel = "tcg"
[machine]
type = "q35"
+24
View File
@@ -0,0 +1,24 @@
# Linux host, arm64 part of the Rocky Linux 9 QEMU configuration; read after config.ini.
# Accelerators are tried in order: KVM on Linux, else emulation.
[accel]
accel = "kvm"
[accel]
accel = "tcg"
[machine]
type = "virt"
# Unlike q35, the "virt" machine has no default GPU or USB input devices.
[device "gpu0"]
driver = "virtio-gpu-pci"
[device "usb0"]
driver = "qemu-xhci"
[device "kbd0"]
driver = "usb-kbd"
[device "tablet0"]
driver = "usb-tablet"
@@ -1,6 +1,6 @@
# QEMU configuration for the Ubuntu 26.04 virtual machine, shared by all # QEMU configuration for the Ubuntu 26.04 virtual machine, shared by all
# architectures. The accelerators, machine type and # architectures. The accelerators, machine type and
# platform-specific devices are in ubuntu2604-<os>-<arch>.ini (os is linux or darwin), # platform-specific devices are in <os>-<arch>.ini (os is linux or darwin),
# which QEMU reads after this file. # which QEMU reads after this file.
# #
# run-ubuntu2604.py fills in the file path placeholders below, writes the result # run-ubuntu2604.py fills in the file path placeholders below, writes the result
@@ -39,21 +39,9 @@
driver = "virtio-blk-pci" driver = "virtio-blk-pci"
drive = "disk0" drive = "disk0"
# cloud-init seed ISO. # The NIC is attached to the user-mode network "net0", which the launcher
[drive "seed0"] # defines with -netdev from qemu/ubuntu2604/forwards.conf because a config file
if = "none" # section cannot hold more than one hostfwd.
format = "raw"
readonly = "on"
file = "${seed_image}"
[device "seed0-dev"]
driver = "virtio-blk-pci"
drive = "seed0"
# User-mode networking with guest SSH forwarded to the host.
[netdev "net0"]
type = "user"
hostfwd = "tcp::2225-:22"
[device "nic0"] [device "nic0"]
driver = "virtio-net-pci" driver = "virtio-net-pci"
@@ -1,4 +1,4 @@
# macOS host, arm64 part of the Ubuntu 26.04 QEMU configuration; read after ubuntu2604.ini. # macOS host, arm64 part of the Ubuntu 26.04 QEMU configuration; read after config.ini.
# Accelerators are tried in order: HVF on macOS, else emulation. # Accelerators are tried in order: HVF on macOS, else emulation.
[accel] [accel]
+2
View File
@@ -0,0 +1,2 @@
# Host ports forwarded to the guest: LABEL HOST_PORT GUEST_PORT
SSH 2225 22
@@ -1,4 +1,4 @@
# Linux host, amd64 part of the Ubuntu 26.04 QEMU configuration; read after ubuntu2604.ini. # Linux host, amd64 part of the Ubuntu 26.04 QEMU configuration; read after config.ini.
# Accelerators are tried in order: KVM on Linux, else emulation. # Accelerators are tried in order: KVM on Linux, else emulation.
[accel] [accel]
@@ -1,4 +1,4 @@
# Linux host, arm64 part of the Ubuntu 26.04 QEMU configuration; read after ubuntu2604.ini. # Linux host, arm64 part of the Ubuntu 26.04 QEMU configuration; read after config.ini.
# Accelerators are tried in order: KVM on Linux, else emulation. # Accelerators are tried in order: KVM on Linux, else emulation.
[accel] [accel]
-44
View File
@@ -1,44 +0,0 @@
# macOS host, arm64 part of the Windows 11 QEMU configuration; read after windows11.ini.
# Accelerators are tried in order: HVF on macOS, else emulation.
[accel]
accel = "hvf"
[accel]
accel = "tcg"
# With the HVF in-kernel GICv3, QEMU's usb-storage races on the status packet
# and Windows Setup stalls 10 s after every burst of reads (utmapp/UTM#7970),
# so use the emulated interrupt controller; upstream QEMU only gets the fix
# from the UTM fork.
[machine]
type = "virt"
kernel-irqchip = "off"
# The "virt" machine has no IDE. NVMe and USB mass storage (used by the
# installation media of --install-from-iso) both have inbox Windows drivers.
[device "system_disk-dev"]
driver = "nvme"
drive = "system_disk"
serial = "win11disk"
bootindex = "1"
# Windows has no inbox driver for virtio-net; the guest tools installed by
# Configure-Windows.ps1 provide it.
[device "nic0"]
driver = "virtio-net-pci"
netdev = "net0"
# Unlike q35, "virt" has no default GPU or keyboard. virtio-gpu is the only
# display, driven by viogpudo (installed by Configure-Windows.ps1). ramfb is not
# added because Windows would then see a second adapter with a fixed-mode
# monitor. xres/yres set the preferred mode in the EDID (QEMU defaults to
# 1280x800); viogpudo accepted 1920x1200, 2048x1280 and 2304x1440 but not
# 2560x1440 or 2560x1600.
[device "gpu0"]
driver = "virtio-gpu-pci"
xres = "2304"
yres = "1440"
[device "kbd0"]
driver = "usb-kbd"
-38
View File
@@ -1,38 +0,0 @@
# Linux host, arm64 part of the Windows 11 QEMU configuration; read after windows11.ini.
# Accelerators are tried in order: KVM on Linux, else emulation.
[accel]
accel = "kvm"
[accel]
accel = "tcg"
[machine]
type = "virt"
# The "virt" machine has no IDE. NVMe and USB mass storage (used by the
# installation media of --install-from-iso) both have inbox Windows drivers.
[device "system_disk-dev"]
driver = "nvme"
drive = "system_disk"
serial = "win11disk"
bootindex = "1"
# Windows has no inbox driver for virtio-net; the guest tools installed by
# Configure-Windows.ps1 provide it.
[device "nic0"]
driver = "virtio-net-pci"
netdev = "net0"
# Unlike q35, "virt" has no default GPU or keyboard. virtio-gpu is the only
# display, driven by viogpudo (installed by Configure-Windows.ps1). ramfb is not
# added because Windows would then see a second adapter with a fixed-mode
# monitor. xres/yres set the preferred mode in the EDID (QEMU defaults to
# 1280x800); viogpudo accepted 1920x1200 and 2048x1280 but not 2560x1600.
[device "gpu0"]
driver = "virtio-gpu-pci"
xres = "2048"
yres = "1280"
[device "kbd0"]
driver = "usb-kbd"
@@ -1,11 +1,11 @@
# QEMU configuration for the Windows 11 virtual machine. The accelerators and machine type # QEMU configuration for the Windows 11 virtual machine. The accelerators and machine type
# are in windows11-<os>-<arch>.ini (os is linux or darwin), which QEMU reads after # are in <os>-<arch>.ini (os is linux or darwin), which QEMU reads after
# this file. # this file.
# #
# run-windows11.py fills in the file path placeholders below, writes the result # run-windows11.py fills in the file path placeholders below, writes the result
# to <state dir>/qemu.ini and passes it to QEMU with -readconfig. Options that # to <state dir>/qemu.ini and passes it to QEMU with -readconfig. Options that
# have no -readconfig equivalent (-cpu, -display) and the installation media # have no -readconfig equivalent (-cpu, -display) and the network are still added
# attached by --install-from-iso are still added by the launcher. # by the launcher.
[name] [name]
guest = "windows11" guest = "windows11"
@@ -30,8 +30,8 @@
# System disk image; the device that exposes it (and the NIC, which is # System disk image; the device that exposes it (and the NIC, which is
# attached to the user-mode network "net0" that the launcher defines with # attached to the user-mode network "net0" that the launcher defines with
# -netdev because a config file section cannot hold more than one hostfwd) is in # -netdev from qemu/windows11/forwards.conf because a config file section cannot hold
# the architecture file. # more than one hostfwd) is in the architecture file.
[drive "system_disk"] [drive "system_disk"]
if = "none" if = "none"
format = "qcow2" format = "qcow2"
+38
View File
@@ -0,0 +1,38 @@
# macOS host, arm64 part of the Windows 11 QEMU configuration; read after config.ini.
# Accelerators are tried in order: HVF on macOS, else emulation.
[accel]
accel = "hvf"
[accel]
accel = "tcg"
# run-windows11.py adds kernel-irqchip=off (see there).
[machine]
type = "virt"
# The "virt" machine has no IDE. NVMe and USB mass storage (used by the
# installation media) both have inbox Windows drivers.
[device "system_disk-dev"]
driver = "nvme"
drive = "system_disk"
serial = "win11disk"
bootindex = "1"
# The base image carries the VirtIO drivers (build-windows-base.sh installs them
# from the virtio-win ISO), so the VM uses virtio-net and virtio-gpu. virtio-gpu
# is the only display, driven by viogpudo; ramfb is not added because Windows
# would then see a second adapter with a fixed-mode monitor. xres/yres set the
# preferred mode in the EDID (QEMU defaults to 1280x800); viogpudo accepted
# 1920x1200, 2048x1280 and 2304x1440 but not 2560x1440 or 2560x1600.
[device "nic0"]
driver = "virtio-net-pci"
netdev = "net0"
[device "gpu0"]
driver = "virtio-gpu-pci"
xres = "2304"
yres = "1440"
[device "kbd0"]
driver = "usb-kbd"
+6
View File
@@ -0,0 +1,6 @@
# Host ports forwarded to the guest: LABEL HOST_PORT GUEST_PORT
# --rdp-port and --ssh-port (or WIN11_VM_RDP_PORT and WIN11_VM_SSH_PORT)
# replace the host port of the RDP and SSH rules.
RDP 3389 3389
SSH 2222 22
DSH 3080 3080
@@ -1,4 +1,4 @@
# Linux host, amd64 part of the Windows 11 QEMU configuration; read after windows11.ini. # Linux host, amd64 part of the Windows 11 QEMU configuration; read after config.ini.
# Accelerators are tried in order: KVM on Linux, else emulation. # Accelerators are tried in order: KVM on Linux, else emulation.
[accel] [accel]
@@ -11,8 +11,7 @@
type = "q35" type = "q35"
# The system disk is attached through IDE so that Windows can boot before the # The system disk is attached through IDE so that Windows can boot before the
# VirtIO storage driver is installed; the installation media of # VirtIO storage driver is installed.
# --install-from-iso use ide.1 to ide.3.
[device "system_disk-dev"] [device "system_disk-dev"]
driver = "ide-hd" driver = "ide-hd"
drive = "system_disk" drive = "system_disk"
+37
View File
@@ -0,0 +1,37 @@
# Linux host, arm64 part of the Windows 11 QEMU configuration; read after config.ini.
# Accelerators are tried in order: KVM on Linux, else emulation.
[accel]
accel = "kvm"
[accel]
accel = "tcg"
[machine]
type = "virt"
# The "virt" machine has no IDE. NVMe and USB mass storage (used by the
# installation media) both have inbox Windows drivers.
[device "system_disk-dev"]
driver = "nvme"
drive = "system_disk"
serial = "win11disk"
bootindex = "1"
# The base image carries the VirtIO drivers (build-windows-base.sh installs them
# from the virtio-win ISO), so the VM uses virtio-net and virtio-gpu. virtio-gpu
# is the only display, driven by viogpudo; ramfb is not added because Windows
# would then see a second adapter with a fixed-mode monitor. xres/yres set the
# preferred mode in the EDID (QEMU defaults to 1280x800); viogpudo accepted
# 1920x1200, 2048x1280 and 2304x1440 but not 2560x1440 or 2560x1600.
[device "nic0"]
driver = "virtio-net-pci"
netdev = "net0"
[device "gpu0"]
driver = "virtio-gpu-pci"
xres = "2048"
yres = "1280"
[device "kbd0"]
driver = "usb-kbd"
+27 -9
View File
@@ -8,6 +8,8 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
import vmforwards
script_dir = Path(__file__).resolve().parent script_dir = Path(__file__).resolve().parent
env = os.environ.get env = os.environ.get
@@ -55,7 +57,9 @@ state_dir = Path(env("ALMA_VM_STATE_DIR") or script_dir / "vm" / "almalinux10")
disk_size = env("ALMA_VM_DISK_SIZE") or "20G" disk_size = env("ALMA_VM_DISK_SIZE") or "20G"
memory = env("ALMA_VM_MEMORY") or "8G" memory = env("ALMA_VM_MEMORY") or "8G"
cpus = env("ALMA_VM_CPUS") or "8" cpus = env("ALMA_VM_CPUS") or "8"
ssh_port = env("ALMA_VM_SSH_PORT") or "2223" ssh_port = env("ALMA_VM_SSH_PORT")
forwards_file = Path(
env("ALMA_VM_FORWARDS") or script_dir / "qemu" / "almalinux10" / "forwards.conf")
def normalize_arch(arch): def normalize_arch(arch):
@@ -127,6 +131,8 @@ Usage: {Path(sys.argv[0]).name} [--daemon] [--download-only] [--state-dir DIR] [
Start an AlmaLinux 10 Generic Cloud virtual machine. The launcher obtains a Start an AlmaLinux 10 Generic Cloud virtual machine. The launcher obtains a
missing base image through ../vm-images/download-images.sh and creates a missing base image through ../vm-images/download-images.sh and creates a
writable overlay in the state directory, leaving the base image unchanged. writable overlay in the state directory, leaving the base image unchanged.
Host port forwards, one "LABEL HOST_PORT GUEST_PORT" rule per line, are read
from qemu/almalinux10/forwards.conf.
Options: Options:
--daemon Run in the background while keeping the graphical window --daemon Run in the background while keeping the graphical window
@@ -138,7 +144,9 @@ Options:
Environment variables: Environment variables:
ALMA_VM_MEMORY Guest memory (default: 8G) ALMA_VM_MEMORY Guest memory (default: 8G)
ALMA_VM_CPUS Virtual CPU count (default: 8) ALMA_VM_CPUS Virtual CPU count (default: 8)
ALMA_VM_SSH_PORT Host port forwarded to guest SSH (default: 2223) ALMA_VM_SSH_PORT Host port of the SSH rule in the port forward file
(default: from the file, 2223)
ALMA_VM_FORWARDS Port forward file (default: qemu/almalinux10/forwards.conf)
ALMA_VM_BASE_IMAGE Base qcow2 image path (default: resolved by shared image helper) ALMA_VM_BASE_IMAGE Base qcow2 image path (default: resolved by shared image helper)
ALMA_VM_SEED_IMAGE cloud-init seed ISO path (default: alongside the VM disk) ALMA_VM_SEED_IMAGE cloud-init seed ISO path (default: alongside the VM disk)
ALMA_VM_STATE_DIR Runtime state directory (default: vm/almalinux10) ALMA_VM_STATE_DIR Runtime state directory (default: vm/almalinux10)
@@ -200,7 +208,8 @@ for firmware_file in (uefi_code, uefi_vars_template):
if not firmware_file.is_file(): if not firmware_file.is_file():
die(f"required UEFI firmware was not found: {firmware_file}") die(f"required UEFI firmware was not found: {firmware_file}")
if not seed_image.is_file(): creating = not disk_image.is_file()
if creating and not seed_image.is_file():
seed_builder = script_dir / "seediso" / "almalinux10" / "build-seed.sh" seed_builder = script_dir / "seediso" / "almalinux10" / "build-seed.sh"
if not is_executable(seed_builder): if not is_executable(seed_builder):
die(f"seed image is missing and builder is unavailable: {seed_builder}") die(f"seed image is missing and builder is unavailable: {seed_builder}")
@@ -232,7 +241,7 @@ if pid_file.is_file():
die(f"the AlmaLinux virtual machine is already running (PID {old_pid}).") die(f"the AlmaLinux virtual machine is already running (PID {old_pid}).")
pid_file.unlink(missing_ok=True) pid_file.unlink(missing_ok=True)
if not disk_image.is_file(): if creating:
run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2", run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2",
"-b", str(base_image), str(disk_image), disk_size]) "-b", str(base_image), str(disk_image), disk_size])
print(f"Created writable disk overlay: {disk_image}") print(f"Created writable disk overlay: {disk_image}")
@@ -240,6 +249,9 @@ if not disk_image.is_file():
is_macos = platform.system() == "Darwin" is_macos = platform.system() == "Darwin"
display_backend = env("ALMA_VM_DISPLAY") or ("cocoa" if is_macos else "gtk") display_backend = env("ALMA_VM_DISPLAY") or ("cocoa" if is_macos else "gtk")
forwards = vmforwards.usable_forwards(vmforwards.load_forwards(
forwards_file, {"SSH": ssh_port} if ssh_port else None))
qemu_args = [ qemu_args = [
"-name", "almalinux10", "-name", "almalinux10",
"-machine", machine_type, "-machine", machine_type,
@@ -250,11 +262,17 @@ qemu_args = [
"-drive", f"if=pflash,format=raw,file={uefi_vars}", "-drive", f"if=pflash,format=raw,file={uefi_vars}",
"-drive", f"file={disk_image},if=none,id=disk0,format=qcow2", "-drive", f"file={disk_image},if=none,id=disk0,format=qcow2",
"-device", "virtio-blk-pci,drive=disk0", "-device", "virtio-blk-pci,drive=disk0",
"-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on", "-netdev", vmforwards.netdev_argument(forwards),
"-device", "virtio-blk-pci,drive=seed0", "-device", "virtio-net-pci,netdev=net0",
"-nic", f"user,model=virtio-net-pci,hostfwd=tcp::{ssh_port}-:22",
] ]
if creating:
# cloud-init reads the seed ISO on the first boot only (the users, keys,
# network and packages it sets up stay on the disk), so it is attached only
# when the disk is created.
qemu_args += ["-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on",
"-device", "virtio-blk-pci,drive=seed0"]
if machine_type == "virt": if machine_type == "virt":
# Unlike q35, the "virt" machine has no default GPU or USB input devices. # Unlike q35, the "virt" machine has no default GPU or USB input devices.
qemu_args += [ qemu_args += [
@@ -274,8 +292,8 @@ else:
qemu_args += ["-accel", "tcg", "-cpu", "max"] qemu_args += ["-accel", "tcg", "-cpu", "max"]
accelerator = "TCG" accelerator = "TCG"
print(f"Starting AlmaLinux 10 ({host_arch}) with {accelerator} acceleration; " print(f"Starting AlmaLinux 10 ({host_arch}) with {accelerator} acceleration.", flush=True)
f"SSH is forwarded to localhost:{ssh_port}.", flush=True) vmforwards.print_forwards(forwards)
if daemonize: if daemonize:
log_file.write_text("") log_file.write_text("")
+30 -17
View File
@@ -3,13 +3,14 @@
import os import os
import platform import platform
import re
import shutil import shutil
import string import string
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
import vmforwards
script_dir = Path(__file__).resolve().parent script_dir = Path(__file__).resolve().parent
env = os.environ.get env = os.environ.get
@@ -68,11 +69,13 @@ def normalize_arch(arch):
native_arch = normalize_arch(platform.machine()) native_arch = normalize_arch(platform.machine())
host_arch = normalize_arch(env("VM_IMAGE_ARCH") or native_arch) host_arch = normalize_arch(env("VM_IMAGE_ARCH") or native_arch)
qemu_binary = "qemu-system-aarch64" if host_arch == "arm64" else "qemu-system-x86_64" qemu_binary = "qemu-system-aarch64" if host_arch == "arm64" else "qemu-system-x86_64"
qemu_config_template = Path(env("VM_QEMU_CONFIG") or script_dir / "qemu" / "debian13.ini") qemu_config_template = Path(env("VM_QEMU_CONFIG") or script_dir / "qemu" / "debian13" / "config.ini")
host_os = platform.system().lower() host_os = platform.system().lower()
if host_os == "darwin" and host_arch == "amd64": if host_os == "darwin" and host_arch == "amd64":
die("amd64 guests are not supported on macOS hosts; use an arm64 image") die("amd64 guests are not supported on macOS hosts; use an arm64 image")
qemu_platform_config = script_dir / "qemu" / f"debian13-{host_os}-{host_arch}.ini" qemu_platform_config = script_dir / "qemu" / "debian13" / f"{host_os}-{host_arch}.ini"
forwards_file = Path(
env("VM_FORWARDS") or script_dir / "qemu" / "debian13" / "forwards.conf")
def pick_firmware(override, *candidates): def pick_firmware(override, *candidates):
@@ -125,10 +128,12 @@ Usage: {Path(sys.argv[0]).name} [--daemon] [--download-only] [--state-dir DIR] [
Start the Debian 13 virtual machine. A writable overlay is created in the state Start the Debian 13 virtual machine. A writable overlay is created in the state
directory (default: vm/debian13/), so the downloaded base image is never directory (default: vm/debian13/), so the downloaded base image is never
modified. The VM hardware (memory, CPUs, SSH port forward, ...) is defined in modified. The VM hardware (memory, CPUs, ...) is defined in
qemu/debian13.ini, which is rendered to qemu.ini in the state directory and qemu/debian13/config.ini, which is rendered to qemu.ini in the state directory and
loaded with QEMU's -readconfig, followed by qemu/debian13-<os>-<arch>.ini (os is linux or darwin, arch is amd64 or arm64). Later loaded with QEMU's -readconfig, followed by qemu/debian13/<os>-<arch>.ini (os is linux or darwin, arch is amd64 or arm64). Later
QEMU arguments override them, e.g. `-- -m 4G -smp 4`. QEMU arguments override them, e.g. `-- -m 4G -smp 4`.
Host port forwards, one "LABEL HOST_PORT GUEST_PORT" rule per line, are
read from qemu/debian13/forwards.conf.
Options: Options:
--daemon Run in the background while keeping the graphical window --daemon Run in the background while keeping the graphical window
@@ -148,7 +153,8 @@ Environment variables:
VM_UEFI_VARS UEFI variable-store template path (auto-detected for the selected architecture) VM_UEFI_VARS UEFI variable-store template path (auto-detected for the selected architecture)
VM_DISPLAY QEMU display backend (default: cocoa on macOS, gtk elsewhere) VM_DISPLAY QEMU display backend (default: cocoa on macOS, gtk elsewhere)
VM_QEMU_CONFIG QEMU config template rendered into the state directory and VM_QEMU_CONFIG QEMU config template rendered into the state directory and
passed to -readconfig (default: qemu/debian13.ini) passed to -readconfig (default: qemu/debian13/config.ini)
VM_FORWARDS Port forward file (default: qemu/debian13/forwards.conf)
""") """)
@@ -202,7 +208,8 @@ for firmware_file in (uefi_code, uefi_vars_template):
if not firmware_file.is_file(): if not firmware_file.is_file():
die(f"required UEFI firmware was not found: {firmware_file}") die(f"required UEFI firmware was not found: {firmware_file}")
if not seed_image.is_file(): creating = not disk_image.is_file()
if creating and not seed_image.is_file():
seed_builder = script_dir / "seediso" / "debian13" / "build-seed.sh" seed_builder = script_dir / "seediso" / "debian13" / "build-seed.sh"
if not is_executable(seed_builder): if not is_executable(seed_builder):
die(f"seed image is missing and builder is unavailable: {seed_builder}") die(f"seed image is missing and builder is unavailable: {seed_builder}")
@@ -234,7 +241,7 @@ if pid_file.is_file():
die(f"the virtual machine is already running (PID {old_pid}).") die(f"the virtual machine is already running (PID {old_pid}).")
pid_file.unlink(missing_ok=True) pid_file.unlink(missing_ok=True)
if not disk_image.is_file(): if creating:
run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2", run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2",
"-b", str(base_image), str(disk_image), disk_size]) "-b", str(base_image), str(disk_image), disk_size])
print(f"Created writable disk overlay: {disk_image}") print(f"Created writable disk overlay: {disk_image}")
@@ -248,7 +255,6 @@ config_values = {
"uefi_code": uefi_code_flash, "uefi_code": uefi_code_flash,
"uefi_vars": uefi_vars, "uefi_vars": uefi_vars,
"disk_image": disk_image, "disk_image": disk_image,
"seed_image": seed_image,
} }
for key, value in config_values.items(): for key, value in config_values.items():
# QEMU's config file parser has no escape syntax for quoted values. # QEMU's config file parser has no escape syntax for quoted values.
@@ -264,24 +270,31 @@ except (KeyError, ValueError) as error:
die(f"invalid placeholder in {qemu_config_template}: {error}") die(f"invalid placeholder in {qemu_config_template}: {error}")
qemu_config.write_text(qemu_config_text) qemu_config.write_text(qemu_config_text)
forwards = vmforwards.usable_forwards(vmforwards.load_forwards(forwards_file))
qemu_args = [ qemu_args = [
"-readconfig", str(qemu_config), "-readconfig", str(qemu_config),
"-readconfig", str(qemu_platform_config), "-readconfig", str(qemu_platform_config),
# "max" is the host CPU under KVM/HVF and the fullest model under TCG. # "max" is the host CPU under KVM/HVF and the fullest model under TCG.
"-cpu", "max", "-cpu", "max",
# The config file's [netdev] does not stop QEMU from adding its default # A config file section cannot hold two hostfwd entries, so the network
# NIC, a second user network without the SSH port forward. # that the NIC in the config file uses is defined here. -nic none stops
# QEMU from adding its default NIC as well.
"-netdev", vmforwards.netdev_argument(forwards),
"-nic", "none", "-nic", "none",
"-display", display_backend, "-display", display_backend,
] ]
if creating:
# cloud-init reads the seed ISO on the first boot only (the users, keys,
# network and packages it sets up stay on the disk), so it is attached only
# when the disk is created.
qemu_args += ["-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on",
"-device", "virtio-blk-pci,drive=seed0"]
print(f"Starting {host_arch} VM from {qemu_config_template} " print(f"Starting {host_arch} VM from {qemu_config_template} "
f"and {qemu_platform_config}.", flush=True) f"and {qemu_platform_config}.", flush=True)
# The SSH port forward lives in the config file, e.g. hostfwd = "tcp::2222-:22". vmforwards.print_forwards(forwards)
ssh_forward = re.search(r'hostfwd\s*=\s*"tcp:[^:"]*:(\d+)-[^:"]*:22"', qemu_config_text)
if ssh_forward:
print(f"SSH is forwarded to localhost:{ssh_forward.group(1)}: "
f"ssh -p {ssh_forward.group(1)} guofang@localhost", flush=True)
if daemonize: if daemonize:
log_file.write_text("") log_file.write_text("")
+29 -16
View File
@@ -3,13 +3,14 @@
import os import os
import platform import platform
import re
import shutil import shutil
import string import string
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
import vmforwards
script_dir = Path(__file__).resolve().parent script_dir = Path(__file__).resolve().parent
env = os.environ.get env = os.environ.get
@@ -69,11 +70,13 @@ native_arch = normalize_arch(platform.machine())
host_arch = normalize_arch(env("VM_IMAGE_ARCH") or native_arch) host_arch = normalize_arch(env("VM_IMAGE_ARCH") or native_arch)
qemu_binary = "qemu-system-aarch64" if host_arch == "arm64" else "qemu-system-x86_64" qemu_binary = "qemu-system-aarch64" if host_arch == "arm64" else "qemu-system-x86_64"
qemu_config_template = Path( qemu_config_template = Path(
env("FEDORA_VM_QEMU_CONFIG") or script_dir / "qemu" / "fedora44.ini") env("FEDORA_VM_QEMU_CONFIG") or script_dir / "qemu" / "fedora44" / "config.ini")
host_os = platform.system().lower() host_os = platform.system().lower()
if host_os == "darwin" and host_arch == "amd64": if host_os == "darwin" and host_arch == "amd64":
die("amd64 guests are not supported on macOS hosts; use an arm64 image") die("amd64 guests are not supported on macOS hosts; use an arm64 image")
qemu_platform_config = script_dir / "qemu" / f"fedora44-{host_os}-{host_arch}.ini" qemu_platform_config = script_dir / "qemu" / "fedora44" / f"{host_os}-{host_arch}.ini"
forwards_file = Path(
env("FEDORA_VM_FORWARDS") or script_dir / "qemu" / "fedora44" / "forwards.conf")
def pick_firmware(override, *candidates): def pick_firmware(override, *candidates):
@@ -127,10 +130,12 @@ Usage: {Path(sys.argv[0]).name} [--daemon] [--download-only] [--state-dir DIR] [
Start a Fedora 44 Cloud Base virtual machine. The launcher obtains a missing Start a Fedora 44 Cloud Base virtual machine. The launcher obtains a missing
base image through the shared image helper and creates a writable overlay base image through the shared image helper and creates a writable overlay
in the state directory, leaving the base image unchanged. The VM hardware in the state directory, leaving the base image unchanged. The VM hardware
(memory, CPUs, SSH port forward, ...) is defined in qemu/fedora44.ini, which is (memory, CPUs, ...) is defined in qemu/fedora44/config.ini, which is
rendered to qemu.ini in the state directory and loaded with QEMU's -readconfig, rendered to qemu.ini in the state directory and loaded with QEMU's -readconfig,
followed by qemu/fedora44-<os>-<arch>.ini (os is linux or darwin, arch is amd64 or arm64). Later QEMU arguments override them, e.g. followed by qemu/fedora44/<os>-<arch>.ini (os is linux or darwin, arch is amd64 or arm64). Later QEMU arguments override them, e.g.
`-- -m 4G -smp 4`. `-- -m 4G -smp 4`.
Host port forwards, one "LABEL HOST_PORT GUEST_PORT" rule per line, are
read from qemu/fedora44/forwards.conf.
Options: Options:
--daemon Run in the background while keeping the graphical window --daemon Run in the background while keeping the graphical window
@@ -150,7 +155,8 @@ Environment variables:
FEDORA_VM_UEFI_VARS UEFI variable-store template path (auto-detected for the selected architecture) FEDORA_VM_UEFI_VARS UEFI variable-store template path (auto-detected for the selected architecture)
FEDORA_VM_DISPLAY QEMU display backend (default: cocoa on macOS, gtk elsewhere) FEDORA_VM_DISPLAY QEMU display backend (default: cocoa on macOS, gtk elsewhere)
FEDORA_VM_QEMU_CONFIG QEMU config template rendered into the state directory and FEDORA_VM_QEMU_CONFIG QEMU config template rendered into the state directory and
passed to -readconfig (default: qemu/fedora44.ini) passed to -readconfig (default: qemu/fedora44/config.ini)
FEDORA_VM_FORWARDS Port forward file (default: qemu/fedora44/forwards.conf)
""") """)
@@ -204,7 +210,8 @@ for firmware_file in (uefi_code, uefi_vars_template):
if not firmware_file.is_file(): if not firmware_file.is_file():
die(f"required UEFI firmware was not found: {firmware_file}") die(f"required UEFI firmware was not found: {firmware_file}")
if not seed_image.is_file(): creating = not disk_image.is_file()
if creating and not seed_image.is_file():
seed_builder = script_dir / "seediso" / "fedora44" / "build-seed.sh" seed_builder = script_dir / "seediso" / "fedora44" / "build-seed.sh"
if not is_executable(seed_builder): if not is_executable(seed_builder):
die(f"seed image is missing and builder is unavailable: {seed_builder}") die(f"seed image is missing and builder is unavailable: {seed_builder}")
@@ -236,7 +243,7 @@ if pid_file.is_file():
die(f"the Fedora virtual machine is already running (PID {old_pid}).") die(f"the Fedora virtual machine is already running (PID {old_pid}).")
pid_file.unlink(missing_ok=True) pid_file.unlink(missing_ok=True)
if not disk_image.is_file(): if creating:
run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2", run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2",
"-b", str(base_image), str(disk_image), disk_size]) "-b", str(base_image), str(disk_image), disk_size])
print(f"Created writable disk overlay: {disk_image}") print(f"Created writable disk overlay: {disk_image}")
@@ -250,7 +257,6 @@ config_values = {
"uefi_code": uefi_code_flash, "uefi_code": uefi_code_flash,
"uefi_vars": uefi_vars, "uefi_vars": uefi_vars,
"disk_image": disk_image, "disk_image": disk_image,
"seed_image": seed_image,
} }
for key, value in config_values.items(): for key, value in config_values.items():
# QEMU's config file parser has no escape syntax for quoted values. # QEMU's config file parser has no escape syntax for quoted values.
@@ -266,24 +272,31 @@ except (KeyError, ValueError) as error:
die(f"invalid placeholder in {qemu_config_template}: {error}") die(f"invalid placeholder in {qemu_config_template}: {error}")
qemu_config.write_text(qemu_config_text) qemu_config.write_text(qemu_config_text)
forwards = vmforwards.usable_forwards(vmforwards.load_forwards(forwards_file))
qemu_args = [ qemu_args = [
"-readconfig", str(qemu_config), "-readconfig", str(qemu_config),
"-readconfig", str(qemu_platform_config), "-readconfig", str(qemu_platform_config),
# "max" is the host CPU under KVM/HVF and the fullest model under TCG. # "max" is the host CPU under KVM/HVF and the fullest model under TCG.
"-cpu", "max", "-cpu", "max",
# The config file's [netdev] does not stop QEMU from adding its default # A config file section cannot hold two hostfwd entries, so the network
# NIC, a second user network without the SSH port forward. # that the NIC in the config file uses is defined here. -nic none stops
# QEMU from adding its default NIC as well.
"-netdev", vmforwards.netdev_argument(forwards),
"-nic", "none", "-nic", "none",
"-display", display_backend, "-display", display_backend,
] ]
if creating:
# cloud-init reads the seed ISO on the first boot only (the users, keys,
# network and packages it sets up stay on the disk), so it is attached only
# when the disk is created.
qemu_args += ["-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on",
"-device", "virtio-blk-pci,drive=seed0"]
print(f"Starting Fedora 44 ({host_arch}) from {qemu_config_template} " print(f"Starting Fedora 44 ({host_arch}) from {qemu_config_template} "
f"and {qemu_platform_config}.", flush=True) f"and {qemu_platform_config}.", flush=True)
# The SSH port forward lives in the config file, e.g. hostfwd = "tcp::2224-:22". vmforwards.print_forwards(forwards)
ssh_forward = re.search(r'hostfwd\s*=\s*"tcp:[^:"]*:(\d+)-[^:"]*:22"', qemu_config_text)
if ssh_forward:
print(f"SSH is forwarded to localhost:{ssh_forward.group(1)}: "
f"ssh -p {ssh_forward.group(1)} guofang@localhost", flush=True)
if daemonize: if daemonize:
log_file.write_text("") log_file.write_text("")
Executable
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Start the Rocky Linux 9 virtual machine."""
import os
import platform
import shutil
import string
import subprocess
import sys
from pathlib import Path
import vmforwards
script_dir = Path(__file__).resolve().parent
env = os.environ.get
def die(message, code=1):
print(f"Error: {message}", file=sys.stderr)
sys.exit(code)
def run(command):
"""Run a command, exiting with its status on failure (like `set -e`)."""
result = subprocess.run(command)
if result.returncode != 0:
sys.exit(result.returncode)
def is_executable(path):
return path.is_file() and os.access(path, os.X_OK)
if env("VM_IMAGE_HELPER"):
image_downloader = Path(env("VM_IMAGE_HELPER"))
elif is_executable(script_dir / "vm-images" / "download-images.sh"):
image_downloader = script_dir / "vm-images" / "download-images.sh"
else:
image_downloader = script_dir / ".." / "vm-images" / "download-images.sh"
uses_shared_base_image = False
if env("ROCKY_VM_BASE_IMAGE"):
base_image = Path(env("ROCKY_VM_BASE_IMAGE"))
else:
if not is_executable(image_downloader):
die(f"shared image helper is unavailable: {image_downloader}")
result = subprocess.run(
[str(image_downloader), "--print-path", "rocky9"],
stdout=subprocess.PIPE,
text=True,
)
if result.returncode != 0:
sys.exit(result.returncode)
base_image = Path(result.stdout.rstrip("\n"))
uses_shared_base_image = True
state_dir = Path(env("ROCKY_VM_STATE_DIR") or script_dir / "vm" / "rocky9")
disk_size = env("ROCKY_VM_DISK_SIZE") or "20G"
def normalize_arch(arch):
if arch in ("arm64", "aarch64"):
return "arm64"
if arch in ("x86_64", "amd64"):
return "amd64"
die(f"unsupported architecture: {arch}")
native_arch = normalize_arch(platform.machine())
host_arch = normalize_arch(env("VM_IMAGE_ARCH") or native_arch)
qemu_binary = "qemu-system-aarch64" if host_arch == "arm64" else "qemu-system-x86_64"
qemu_config_template = Path(
env("ROCKY_VM_QEMU_CONFIG") or script_dir / "qemu" / "rocky9" / "config.ini")
host_os = platform.system().lower()
if host_os == "darwin" and host_arch == "amd64":
die("amd64 guests are not supported on macOS hosts; use an arm64 image")
qemu_platform_config = script_dir / "qemu" / "rocky9" / f"{host_os}-{host_arch}.ini"
forwards_file = Path(
env("ROCKY_VM_FORWARDS") or script_dir / "qemu" / "rocky9" / "forwards.conf")
def pick_firmware(override, *candidates):
if override:
return Path(override)
for candidate in candidates:
if Path(candidate).is_file():
return Path(candidate)
return Path(candidates[0])
if host_arch == "arm64":
uefi_code = pick_firmware(
env("ROCKY_VM_UEFI_CODE"),
"/usr/share/AAVMF/AAVMF_CODE.fd",
"/usr/share/edk2/aarch64/QEMU_EFI.fd",
"/opt/homebrew/share/qemu/edk2-aarch64-code.fd",
"/usr/local/share/qemu/edk2-aarch64-code.fd",
)
uefi_vars_template = pick_firmware(
env("ROCKY_VM_UEFI_VARS"),
"/usr/share/AAVMF/AAVMF_VARS.fd",
"/usr/share/edk2/aarch64/vars-template-pflash.raw",
"/opt/homebrew/share/qemu/edk2-arm-vars.fd",
"/usr/local/share/qemu/edk2-arm-vars.fd",
)
else:
uefi_code = pick_firmware(
env("ROCKY_VM_UEFI_CODE"),
"/usr/share/OVMF/OVMF_CODE_4M.fd",
"/usr/share/edk2/ovmf/OVMF_CODE.fd",
"/opt/homebrew/share/qemu/edk2-x86_64-code.fd",
"/usr/local/share/qemu/edk2-x86_64-code.fd",
)
uefi_vars_template = pick_firmware(
env("ROCKY_VM_UEFI_VARS"),
"/usr/share/OVMF/OVMF_VARS_4M.fd",
"/usr/share/edk2/ovmf/OVMF_VARS.fd",
"/opt/homebrew/share/qemu/edk2-i386-vars.fd",
"/usr/local/share/qemu/edk2-i386-vars.fd",
)
daemonize = False
download_only = False
extra_args = []
def usage(stream=sys.stdout):
stream.write(f"""\
Usage: {Path(sys.argv[0]).name} [--daemon] [--download-only] [--state-dir DIR] [-- QEMU_ARGUMENT ...]
Start a Rocky Linux 9 GenericCloud Base virtual machine. The launcher obtains a missing
base image through the shared image helper and creates a writable overlay
in the state directory, leaving the base image unchanged. The VM hardware
(memory, CPUs, ...) is defined in qemu/rocky9/config.ini, which is
rendered to qemu.ini in the state directory and loaded with QEMU's -readconfig,
followed by qemu/rocky9/<os>-<arch>.ini (os is linux or darwin, arch is amd64 or arm64). Later QEMU arguments override them, e.g.
`-- -m 4G -smp 4`.
Host port forwards, one "LABEL HOST_PORT GUEST_PORT" rule per line, are
read from qemu/rocky9/forwards.conf.
Options:
--daemon Run in the background while keeping the graphical window
--download-only Download the base image if it is missing, then exit
--state-dir DIR Directory for the VM disk, seed ISO and runtime files
(default: vm/rocky9; overrides ROCKY_VM_STATE_DIR)
-h, --help Show this help
Environment variables:
ROCKY_VM_BASE_IMAGE Base qcow2 image path (default: resolved by shared image helper)
ROCKY_VM_SEED_IMAGE cloud-init seed ISO path (default: alongside the VM disk)
ROCKY_VM_STATE_DIR Runtime state directory (default: vm/rocky9)
ROCKY_VM_DISK_IMAGE Writable overlay path
ROCKY_VM_DISK_SIZE New overlay virtual capacity (default: 20G)
VM_IMAGE_ARCH Guest/host architecture: arm64 or amd64 (default: auto-detected from the host)
ROCKY_VM_UEFI_CODE UEFI read-only firmware path (auto-detected for the selected architecture)
ROCKY_VM_UEFI_VARS UEFI variable-store template path (auto-detected for the selected architecture)
ROCKY_VM_DISPLAY QEMU display backend (default: cocoa on macOS, gtk elsewhere)
ROCKY_VM_QEMU_CONFIG QEMU config template rendered into the state directory and
passed to -readconfig (default: qemu/rocky9/config.ini)
ROCKY_VM_FORWARDS Port forward file (default: qemu/rocky9/forwards.conf)
""")
args = sys.argv[1:]
while args:
arg = args.pop(0)
if arg == "--daemon":
daemonize = True
elif arg == "--download-only":
download_only = True
elif arg == "--state-dir":
if not args:
die(f"{arg} requires a directory argument.", 2)
state_dir = Path(args.pop(0))
elif arg.startswith("--state-dir="):
state_dir = Path(arg.split("=", 1)[1])
elif arg in ("-h", "--help"):
usage()
sys.exit(0)
elif arg == "--":
extra_args.extend(args)
break
else:
print(f"Error: unknown option: {arg}", file=sys.stderr)
usage(sys.stderr)
sys.exit(2)
disk_image = Path(env("ROCKY_VM_DISK_IMAGE") or state_dir / "disk.qcow2")
seed_image = Path(env("ROCKY_VM_SEED_IMAGE") or disk_image.parent / "seed.iso")
uefi_vars = state_dir / "OVMF_VARS_4M.fd"
pid_file = state_dir / "qemu.pid"
log_file = state_dir / "console.log"
qemu_config = state_dir / "qemu.ini"
if not download_only:
for command_name in (qemu_binary, "qemu-img"):
if shutil.which(command_name) is None:
die(f"{command_name} is not installed.")
if not base_image.is_file():
if uses_shared_base_image:
run([str(image_downloader), "rocky9"])
else:
die(f"Rocky Linux base image was not found: {base_image}")
if not base_image.is_file():
die(f"downloader did not create: {base_image}")
if download_only:
print(f"Base image is ready: {base_image}")
sys.exit(0)
for firmware_file in (uefi_code, uefi_vars_template):
if not firmware_file.is_file():
die(f"required UEFI firmware was not found: {firmware_file}")
creating = not disk_image.is_file()
if creating and not seed_image.is_file():
seed_builder = script_dir / "seediso" / "rocky9" / "build-seed.sh"
if not is_executable(seed_builder):
die(f"seed image is missing and builder is unavailable: {seed_builder}")
print(f"Seed image not found; building {seed_image}...", flush=True)
run([str(seed_builder), str(seed_image)])
state_dir.mkdir(parents=True, exist_ok=True)
# QEMU requires the code and vars pflash images to be exactly the same size.
# Homebrew's edk2 firmware ships code/vars pairs at mismatched sizes (true for
# both the aarch64 and x86_64 pairs), so pad per-VM copies of whichever one is
# smaller up to match the other.
uefi_code_flash = state_dir / "OVMF_CODE_4M.fd"
if not uefi_code_flash.is_file() or not uefi_vars.is_file():
flash_size = max(uefi_code.stat().st_size, uefi_vars_template.stat().st_size)
for source, target in ((uefi_code, uefi_code_flash), (uefi_vars_template, uefi_vars)):
if not target.is_file():
shutil.copyfile(source, target)
os.truncate(target, flash_size)
if pid_file.is_file():
old_pid = pid_file.read_text().strip()
if old_pid.isdigit():
try:
os.kill(int(old_pid), 0)
except OSError:
pass
else:
die(f"the Rocky Linux virtual machine is already running (PID {old_pid}).")
pid_file.unlink(missing_ok=True)
if creating:
run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2",
"-b", str(base_image), str(disk_image), disk_size])
print(f"Created writable disk overlay: {disk_image}")
is_macos = platform.system() == "Darwin"
display_backend = env("ROCKY_VM_DISPLAY") or ("cocoa" if is_macos else "gtk")
# Most of the VM definition lives in the QEMU config template; fill in the
# file paths and hand the rendered file to QEMU with -readconfig.
config_values = {
"uefi_code": uefi_code_flash,
"uefi_vars": uefi_vars,
"disk_image": disk_image,
}
for key, value in config_values.items():
# QEMU's config file parser has no escape syntax for quoted values.
if any(char in str(value) for char in '"\n'):
die(f"{key} must not contain double quotes or newlines: {value}")
for config_file in (qemu_config_template, qemu_platform_config):
if not config_file.is_file():
die(f"QEMU config file was not found: {config_file}")
try:
qemu_config_text = string.Template(qemu_config_template.read_text()).substitute(
{key: str(value) for key, value in config_values.items()})
except (KeyError, ValueError) as error:
die(f"invalid placeholder in {qemu_config_template}: {error}")
qemu_config.write_text(qemu_config_text)
forwards = vmforwards.usable_forwards(vmforwards.load_forwards(forwards_file))
qemu_args = [
"-readconfig", str(qemu_config),
"-readconfig", str(qemu_platform_config),
# "max" is the host CPU under KVM/HVF and the fullest model under TCG.
"-cpu", "max",
# A config file section cannot hold two hostfwd entries, so the network
# that the NIC in the config file uses is defined here. -nic none stops
# QEMU from adding its default NIC as well.
"-netdev", vmforwards.netdev_argument(forwards),
"-nic", "none",
"-display", display_backend,
]
if creating:
# cloud-init reads the seed ISO on the first boot only (the users, keys,
# network and packages it sets up stay on the disk), so it is attached only
# when the disk is created.
qemu_args += ["-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on",
"-device", "virtio-blk-pci,drive=seed0"]
print(f"Starting Rocky Linux 9 ({host_arch}) from {qemu_config_template} "
f"and {qemu_platform_config}.", flush=True)
vmforwards.print_forwards(forwards)
if daemonize:
log_file.write_text("")
qemu_args += [
"-serial", f"file:{log_file}",
"-monitor", "none",
"-daemonize",
"-pidfile", str(pid_file),
]
run([qemu_binary, *qemu_args, *extra_args])
print(f"VM started (PID {pid_file.read_text().strip()}); console log: {log_file}")
else:
os.execvp(qemu_binary, [qemu_binary, *qemu_args, *extra_args])
+31 -18
View File
@@ -3,13 +3,14 @@
import os import os
import platform import platform
import re
import shutil import shutil
import string import string
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
import vmforwards
script_dir = Path(__file__).resolve().parent script_dir = Path(__file__).resolve().parent
env = os.environ.get env = os.environ.get
@@ -69,11 +70,13 @@ native_arch = normalize_arch(platform.machine())
host_arch = normalize_arch(env("VM_IMAGE_ARCH") or native_arch) host_arch = normalize_arch(env("VM_IMAGE_ARCH") or native_arch)
qemu_binary = "qemu-system-aarch64" if host_arch == "arm64" else "qemu-system-x86_64" qemu_binary = "qemu-system-aarch64" if host_arch == "arm64" else "qemu-system-x86_64"
qemu_config_template = Path( qemu_config_template = Path(
env("UBUNTU_VM_QEMU_CONFIG") or script_dir / "qemu" / "ubuntu2604.ini") env("UBUNTU_VM_QEMU_CONFIG") or script_dir / "qemu" / "ubuntu2604" / "config.ini")
host_os = platform.system().lower() host_os = platform.system().lower()
if host_os == "darwin" and host_arch == "amd64": if host_os == "darwin" and host_arch == "amd64":
die("amd64 guests are not supported on macOS hosts; use an arm64 image") die("amd64 guests are not supported on macOS hosts; use an arm64 image")
qemu_platform_config = script_dir / "qemu" / f"ubuntu2604-{host_os}-{host_arch}.ini" qemu_platform_config = script_dir / "qemu" / "ubuntu2604" / f"{host_os}-{host_arch}.ini"
forwards_file = Path(
env("UBUNTU_VM_FORWARDS") or script_dir / "qemu" / "ubuntu2604" / "forwards.conf")
def pick_firmware(override, *candidates): def pick_firmware(override, *candidates):
@@ -126,11 +129,13 @@ Usage: {Path(sys.argv[0]).name} [--daemon] [--download-only] [--state-dir DIR] [
Start an Ubuntu 26.04 LTS cloud image virtual machine. A writable overlay is Start an Ubuntu 26.04 LTS cloud image virtual machine. A writable overlay is
created in the state directory (default: vm/ubuntu2604/), so the downloaded created in the state directory (default: vm/ubuntu2604/), so the downloaded
base image is never modified. The VM hardware (memory, CPUs, SSH port forward, base image is never modified. The VM hardware (memory, CPUs, ...) is defined
...) is defined in qemu/ubuntu2604.ini, which is rendered to qemu.ini in the in qemu/ubuntu2604/config.ini, which is rendered to qemu.ini in the state directory
state directory and loaded with QEMU's -readconfig, followed by and loaded with QEMU's -readconfig, followed by
qemu/ubuntu2604-<os>-<arch>.ini (os is linux or darwin, arch is amd64 or arm64). Later QEMU arguments override them, e.g. qemu/ubuntu2604/<os>-<arch>.ini (os is linux or darwin, arch is amd64 or arm64). Later QEMU arguments override them, e.g.
`-- -m 4G -smp 4`. `-- -m 4G -smp 4`.
Host port forwards, one "LABEL HOST_PORT GUEST_PORT" rule per line, are
read from qemu/ubuntu2604/forwards.conf.
Options: Options:
--daemon Run in the background while keeping the graphical window --daemon Run in the background while keeping the graphical window
@@ -150,7 +155,8 @@ Environment variables:
UBUNTU_VM_UEFI_VARS UEFI variable-store template path (auto-detected for the selected architecture) UBUNTU_VM_UEFI_VARS UEFI variable-store template path (auto-detected for the selected architecture)
UBUNTU_VM_DISPLAY QEMU display backend (default: cocoa on macOS, gtk elsewhere) UBUNTU_VM_DISPLAY QEMU display backend (default: cocoa on macOS, gtk elsewhere)
UBUNTU_VM_QEMU_CONFIG QEMU config template rendered into the state directory and UBUNTU_VM_QEMU_CONFIG QEMU config template rendered into the state directory and
passed to -readconfig (default: qemu/ubuntu2604.ini) passed to -readconfig (default: qemu/ubuntu2604/config.ini)
UBUNTU_VM_FORWARDS Port forward file (default: qemu/ubuntu2604/forwards.conf)
""") """)
@@ -204,7 +210,8 @@ for firmware_file in (uefi_code, uefi_vars_template):
if not firmware_file.is_file(): if not firmware_file.is_file():
die(f"required UEFI firmware was not found: {firmware_file}") die(f"required UEFI firmware was not found: {firmware_file}")
if not seed_image.is_file(): creating = not disk_image.is_file()
if creating and not seed_image.is_file():
seed_builder = script_dir / "seediso" / "ubuntu2604" / "build-seed.sh" seed_builder = script_dir / "seediso" / "ubuntu2604" / "build-seed.sh"
if not is_executable(seed_builder): if not is_executable(seed_builder):
die(f"seed image is missing and builder is unavailable: {seed_builder}") die(f"seed image is missing and builder is unavailable: {seed_builder}")
@@ -236,7 +243,7 @@ if pid_file.is_file():
die(f"the Ubuntu virtual machine is already running (PID {old_pid}).") die(f"the Ubuntu virtual machine is already running (PID {old_pid}).")
pid_file.unlink(missing_ok=True) pid_file.unlink(missing_ok=True)
if not disk_image.is_file(): if creating:
run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2", run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2",
"-b", str(base_image), str(disk_image), disk_size]) "-b", str(base_image), str(disk_image), disk_size])
print(f"Created writable disk overlay: {disk_image}") print(f"Created writable disk overlay: {disk_image}")
@@ -250,7 +257,6 @@ config_values = {
"uefi_code": uefi_code_flash, "uefi_code": uefi_code_flash,
"uefi_vars": uefi_vars, "uefi_vars": uefi_vars,
"disk_image": disk_image, "disk_image": disk_image,
"seed_image": seed_image,
} }
for key, value in config_values.items(): for key, value in config_values.items():
# QEMU's config file parser has no escape syntax for quoted values. # QEMU's config file parser has no escape syntax for quoted values.
@@ -266,24 +272,31 @@ except (KeyError, ValueError) as error:
die(f"invalid placeholder in {qemu_config_template}: {error}") die(f"invalid placeholder in {qemu_config_template}: {error}")
qemu_config.write_text(qemu_config_text) qemu_config.write_text(qemu_config_text)
forwards = vmforwards.usable_forwards(vmforwards.load_forwards(forwards_file))
qemu_args = [ qemu_args = [
"-readconfig", str(qemu_config), "-readconfig", str(qemu_config),
"-readconfig", str(qemu_platform_config), "-readconfig", str(qemu_platform_config),
# "max" is the host CPU under KVM/HVF and the fullest model under TCG. # "max" is the host CPU under KVM/HVF and the fullest model under TCG.
"-cpu", "max", "-cpu", "max",
# The config file's [netdev] does not stop QEMU from adding its default # A config file section cannot hold two hostfwd entries, so the network
# NIC, a second user network without the SSH port forward. # that the NIC in the config file uses is defined here. -nic none stops
# QEMU from adding its default NIC as well.
"-netdev", vmforwards.netdev_argument(forwards),
"-nic", "none", "-nic", "none",
"-display", display_backend, "-display", display_backend,
] ]
if creating:
# cloud-init reads the seed ISO on the first boot only (the users, keys,
# network and packages it sets up stay on the disk), so it is attached only
# when the disk is created.
qemu_args += ["-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on",
"-device", "virtio-blk-pci,drive=seed0"]
print(f"Starting Ubuntu 26.04 ({host_arch}) from {qemu_config_template} " print(f"Starting Ubuntu 26.04 ({host_arch}) from {qemu_config_template} "
f"and {qemu_platform_config}.", flush=True) f"and {qemu_platform_config}.", flush=True)
# The SSH port forward lives in the config file, e.g. hostfwd = "tcp::2225-:22". vmforwards.print_forwards(forwards)
ssh_forward = re.search(r'hostfwd\s*=\s*"tcp:[^:"]*:(\d+)-[^:"]*:22"', qemu_config_text)
if ssh_forward:
print(f"SSH is forwarded to localhost:{ssh_forward.group(1)}: "
f"ssh -p {ssh_forward.group(1)} guofang@localhost", flush=True)
if daemonize: if daemonize:
log_file.write_text("") log_file.write_text("")
+329 -400
View File
@@ -4,12 +4,13 @@
import os import os
import platform import platform
import shutil import shutil
import socket
import string import string
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
import vmforwards
script_dir = Path(__file__).resolve().parent script_dir = Path(__file__).resolve().parent
env = os.environ.get env = os.environ.get
@@ -34,17 +35,6 @@ def capture(command):
return result.stdout.rstrip("\n") return result.stdout.rstrip("\n")
def port_in_use(port):
"""True if a host TCP port cannot be bound, as QEMU does for a hostfwd rule."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(("", int(port)))
except OSError:
return True
return False
def is_executable(path): def is_executable(path):
return path.is_file() and os.access(path, os.X_OK) return path.is_file() and os.access(path, os.X_OK)
@@ -61,14 +51,6 @@ def find_image_downloader():
return image_downloader return image_downloader
state_dir = Path(env("WIN11_VM_STATE_DIR") or script_dir / "vm" / "windows11")
disk_size = env("WIN11_VM_DISK_SIZE") or "64G"
rdp_port = env("WIN11_VM_RDP_PORT") or "3389"
ssh_port = env("WIN11_VM_SSH_PORT") or "2222"
qemu_config_template = Path(
env("WIN11_VM_QEMU_CONFIG") or script_dir / "qemu" / "windows11.ini")
def normalize_arch(arch): def normalize_arch(arch):
if arch in ("arm64", "aarch64"): if arch in ("arm64", "aarch64"):
return "arm64" return "arm64"
@@ -77,133 +59,6 @@ def normalize_arch(arch):
die(f"unsupported architecture: {arch}") die(f"unsupported architecture: {arch}")
daemonize = False
install_from_iso = False
install_from_img = False
download_only = False
seed_only = False
extra_args = []
arch_option = None
def usage(stream=sys.stdout):
stream.write(f"""\
Usage: {Path(sys.argv[0]).name} [--daemon] [--download-only] [--seed-only] [--install-from-iso | --install-from-img]
[--arch ARCH] [--state-dir DIR] [--rdp-port PORT] [--ssh-port PORT] [-- QEMU_ARGUMENT ...]
If the system disk (DIR/disk.qcow2, see --state-dir) is missing, create it from
the Windows 11 QCOW2 base image.
The VM hardware (accelerator, memory, CPUs, drives, devices) is defined in
qemu/windows11.ini, which is rendered to qemu.ini in the state directory and
loaded with QEMU's -readconfig, followed by qemu/windows11-<os>-<arch>.ini (os is linux or darwin, arch is amd64 or arm64, chosen from the host system and
architecture, --arch or VM_IMAGE_ARCH). Later QEMU arguments override them, e.g. -- -m 4G -smp 4.
Use --install-from-iso to perform an automatic ISO installation instead, or
--install-from-img to install from a disk image built from the ISO.
If it exists, start Windows normally without attaching installation media.
Options:
--daemon Run QEMU in the background
--download-only Download the VirtIO tools ISO and the Windows 11 ISO
(for --arch) if they are missing, then exit; needs
Node.js and Chromium for the latter, see vm-images/README.md
(the base QCOW2 is supplied manually)
--seed-only Build the seed ISO (answer file and setup scripts) and exit,
without starting the VM or fetching the Windows and VirtIO ISOs
--install-from-iso Install from ISO when the system disk is missing
--install-from-img Like --install-from-iso, but install from the disk image
made by vm-images/build-windows-install-disk.sh, which
is the ISO path ending in .img instead of .iso.
It is attached as an NVMe disk, much faster than the
emulated USB CD-ROM, and is never modified
--arch ARCH Guest architecture: arm64 or amd64 (default: the host's;
overrides VM_IMAGE_ARCH). A guest that differs from the
host runs under slow TCG emulation
--state-dir DIR Directory for the VM disk, seed ISO and runtime files
(default: vm/windows11; overrides WIN11_VM_STATE_DIR)
--rdp-port PORT Host port forwarded to guest RDP (default: 3389;
overrides WIN11_VM_RDP_PORT)
--ssh-port PORT Host port forwarded to guest SSH (default: 2222;
overrides WIN11_VM_SSH_PORT)
-h, --help Show this help
Environment variables:
WIN11_VM_BASE_IMAGE Windows 11 base QCOW2 image (default: beside shared images)
WIN11_VM_INSTALL_ISO Windows 11 installation ISO used with --install-from-iso
(--install-from-img uses the same path ending in .img)
WIN11_VM_TOOLS_ISO VirtIO Windows tools ISO (default: resolved by shared image helper)
WIN11_VM_RDP_PORT Host RDP port (default: 3389)
WIN11_VM_SSH_PORT Host SSH port (default: 2222)
WIN11_VM_STATE_DIR Runtime directory (default: vm/windows11)
WIN11_VM_DISK_IMAGE System disk path
WIN11_VM_DISK_SIZE New system disk size (default: 64G)
WIN11_VM_SEED_ISO Answer-file ISO path (default: alongside the VM disk)
WIN11_VM_UEFI_CODE OVMF read-only firmware path (auto-detected if unset)
WIN11_VM_UEFI_VARS OVMF variable-store template path (auto-detected if unset)
WIN11_VM_QEMU_CONFIG QEMU config template passed to -readconfig
(default: qemu/windows11.ini)
WIN11_VM_DISPLAY QEMU display backend (default: cocoa on macOS,
gtk elsewhere)
""")
args = sys.argv[1:]
while args:
arg = args.pop(0)
if arg == "--daemon":
daemonize = True
elif arg == "--download-only":
download_only = True
elif arg == "--seed-only":
seed_only = True
elif arg == "--install-from-iso":
install_from_iso = True
elif arg == "--install-from-img":
install_from_img = True
elif arg == "--state-dir":
if not args:
die(f"{arg} requires a directory argument.", 2)
state_dir = Path(args.pop(0))
elif arg.startswith("--state-dir="):
state_dir = Path(arg.split("=", 1)[1])
elif arg in ("--rdp-port", "--ssh-port") or arg.startswith(("--rdp-port=", "--ssh-port=")):
name, sep, value = arg.partition("=")
if not sep:
if not args:
die(f"{name} requires a port argument.", 2)
value = args.pop(0)
if not (value.isascii() and value.isdigit() and 1 <= int(value) <= 65535):
die(f"{name} must be a port number from 1 to 65535: {value}", 2)
if name == "--rdp-port":
rdp_port = value
else:
ssh_port = value
elif arg == "--arch":
if not args:
die(f"{arg} requires an architecture argument.", 2)
arch_option = args.pop(0)
elif arg.startswith("--arch="):
arch_option = arg.split("=", 1)[1]
elif arg in ("-h", "--help"):
usage()
sys.exit(0)
elif arg == "--":
extra_args.extend(args)
break
else:
print(f"Error: unknown option: {arg}", file=sys.stderr)
usage(sys.stderr)
sys.exit(2)
host_arch = normalize_arch(arch_option or env("VM_IMAGE_ARCH") or platform.machine())
# The shared image helper and build-seed.sh pick the architecture from here.
os.environ["VM_IMAGE_ARCH"] = host_arch
qemu_binary = "qemu-system-aarch64" if host_arch == "arm64" else "qemu-system-x86_64"
host_os = platform.system().lower()
if host_os == "darwin" and host_arch == "amd64":
die("amd64 guests are not supported on macOS hosts; use an arm64 image")
qemu_platform_config = script_dir / "qemu" / f"windows11-{host_os}-{host_arch}.ini"
def pick_firmware(override, *candidates): def pick_firmware(override, *candidates):
if override: if override:
return Path(override) return Path(override)
@@ -213,81 +68,298 @@ def pick_firmware(override, *candidates):
return Path(candidates[0]) return Path(candidates[0])
if host_arch == "arm64": class Options:
uefi_code = pick_firmware( """Command-line options."""
env("WIN11_VM_UEFI_CODE"),
"/usr/share/AAVMF/AAVMF_CODE.fd",
"/usr/share/edk2/aarch64/QEMU_EFI.fd",
"/opt/homebrew/share/qemu/edk2-aarch64-code.fd",
"/usr/local/share/qemu/edk2-aarch64-code.fd",
)
uefi_vars_template = pick_firmware(
env("WIN11_VM_UEFI_VARS"),
"/usr/share/AAVMF/AAVMF_VARS.fd",
"/usr/share/edk2/aarch64/vars-template-pflash.raw",
"/opt/homebrew/share/qemu/edk2-arm-vars.fd",
"/usr/local/share/qemu/edk2-arm-vars.fd",
)
else:
uefi_code = pick_firmware(
env("WIN11_VM_UEFI_CODE"),
"/usr/share/OVMF/OVMF_CODE_4M.fd",
"/usr/share/edk2/ovmf/OVMF_CODE.fd",
"/opt/homebrew/share/qemu/edk2-x86_64-code.fd",
"/usr/local/share/qemu/edk2-x86_64-code.fd",
)
uefi_vars_template = pick_firmware(
env("WIN11_VM_UEFI_VARS"),
"/usr/share/OVMF/OVMF_VARS_4M.fd",
"/usr/share/edk2/ovmf/OVMF_VARS.fd",
"/opt/homebrew/share/qemu/edk2-i386-vars.fd",
"/usr/local/share/qemu/edk2-i386-vars.fd",
)
if install_from_img:
install_from_iso = True
if download_only: def __init__(self):
# The VirtIO tools ISO and the Windows installation ISO are the media the self.daemonize = False
# launcher downloads itself; the base QCOW2 is supplied manually. self.vnc_listen = None
image_downloader = None self.state_dir = Path(env("WIN11_VM_STATE_DIR") or script_dir / "vm" / "windows11")
if env("WIN11_VM_TOOLS_ISO"): self.rdp_port = env("WIN11_VM_RDP_PORT")
tools_iso = Path(env("WIN11_VM_TOOLS_ISO")) self.ssh_port = env("WIN11_VM_SSH_PORT")
if not tools_iso.is_file(): self.arch_option = None
die(f"VirtIO Windows tools ISO was not found: {tools_iso}") self.extra_args = []
def usage(stream=sys.stdout):
stream.write(f"""\
Usage: {Path(sys.argv[0]).name} [--daemon] [--vnc[=ADDR:N]]
[--arch ARCH] [--state-dir DIR] [--rdp-port PORT] [--ssh-port PORT] [-- QEMU_ARGUMENT ...]
If the system disk (DIR/disk.qcow2, see --state-dir) is missing, create it as a
QCOW2 overlay on the Windows 11 base image, which starts in seconds and runs the
specialize and OOBE passes of its answer file on the first boot, and mount the
seed ISO (DIR/seed.iso, built from seediso/windows11 if missing or outdated):
Windows reads its Unattend.xml from the root of that CD-ROM during the specialize
and OOBE passes: host name, account, auto logon, Remote Desktop, then at the
first logon Configure-Windows.ps1 (OpenSSH and the SSH key, settings, installer
scripts on the desktop). If the disk exists, start Windows normally, without the
seed ISO.
Make the base image once with vm-images/build-windows-base.sh; it installs
Windows from the ISO and generalizes the result. The base image contains only
Windows plus the VirtIO drivers (no account, no SSH, no other
hardware-specific drivers).
The VM hardware (accelerator, memory, CPUs, drives, devices) is defined in
qemu/windows11/config.ini, which is rendered to qemu.ini in the state directory and
loaded with QEMU's -readconfig, followed by qemu/windows11/<os>-<arch>.ini (os is linux or darwin, arch is amd64 or arm64, chosen from the host system and
architecture, --arch or VM_IMAGE_ARCH). Later QEMU arguments override them, e.g. -- -m 4G -smp 4.
Options:
--daemon Run QEMU in the background
--vnc[=ADDR:N] Serve the screen over VNC instead of opening a window, on
ADDR (default: 127.0.0.1) at TCP port 5900+N (default N: 0).
Overrides WIN11_VM_DISPLAY
--arch ARCH Guest architecture: arm64 or amd64 (default: the host's;
overrides VM_IMAGE_ARCH). A guest that differs from the
host runs under slow TCG emulation
--state-dir DIR Directory for the VM disk and runtime files
(default: vm/windows11; overrides WIN11_VM_STATE_DIR)
--rdp-port PORT Host port of the RDP rule in the port forward file
(default: 3389; overrides WIN11_VM_RDP_PORT)
--ssh-port PORT Host port of the SSH rule in the port forward file
(default: 2222; overrides WIN11_VM_SSH_PORT)
-h, --help Show this help
Environment variables:
WIN11_VM_BASE_IMAGE Windows 11 base QCOW2 image (default: beside shared images)
WIN11_VM_RDP_PORT Host RDP port (default: from the port forward file, 3389)
WIN11_VM_SSH_PORT Host SSH port (default: from the port forward file, 2222)
WIN11_VM_FORWARDS Port forward file (default: qemu/windows11/forwards.conf)
WIN11_VM_STATE_DIR Runtime directory (default: vm/windows11)
WIN11_VM_DISK_IMAGE System disk path
WIN11_VM_DISK_SIZE New system disk size (default: 64G)
WIN11_VM_SEED_ISO Seed ISO path (default: alongside the VM disk)
WIN11_VM_UEFI_CODE OVMF read-only firmware path (auto-detected if unset)
WIN11_VM_UEFI_VARS OVMF variable-store template path (auto-detected if unset)
WIN11_VM_QEMU_CONFIG QEMU config template passed to -readconfig
(default: qemu/windows11/config.ini)
WIN11_VM_DISPLAY QEMU display backend (default: cocoa on macOS,
gtk elsewhere)
""")
def parse_args(args):
"""Parse the command line."""
options = Options()
args = list(args)
while args:
arg = args.pop(0)
if arg == "--daemon":
options.daemonize = True
elif arg == "--vnc" or arg.startswith("--vnc="):
options.vnc_listen = arg.partition("=")[2] or "127.0.0.1:0"
if ":" not in options.vnc_listen:
options.vnc_listen += ":0"
elif arg == "--state-dir":
if not args:
die(f"{arg} requires a directory argument.", 2)
options.state_dir = Path(args.pop(0))
elif arg.startswith("--state-dir="):
options.state_dir = Path(arg.split("=", 1)[1])
elif arg in ("--rdp-port", "--ssh-port") or arg.startswith(("--rdp-port=", "--ssh-port=")):
name, sep, value = arg.partition("=")
if not sep:
if not args:
die(f"{name} requires a port argument.", 2)
value = args.pop(0)
if not (value.isascii() and value.isdigit() and 1 <= int(value) <= 65535):
die(f"{name} must be a port number from 1 to 65535: {value}", 2)
if name == "--rdp-port":
options.rdp_port = value
else:
options.ssh_port = value
elif arg == "--arch":
if not args:
die(f"{arg} requires an architecture argument.", 2)
options.arch_option = args.pop(0)
elif arg.startswith("--arch="):
options.arch_option = arg.split("=", 1)[1]
elif arg in ("-h", "--help"):
usage()
sys.exit(0)
elif arg == "--":
options.extra_args.extend(args)
break
else:
print(f"Error: unknown option: {arg}", file=sys.stderr)
usage(sys.stderr)
sys.exit(2)
return options
class Platform:
"""Guest architecture, host system and the UEFI firmware that fit them."""
def __init__(self, arch_option):
self.arch = normalize_arch(arch_option or env("VM_IMAGE_ARCH") or platform.machine())
# The shared image helper and build-seed.sh pick the architecture from here.
os.environ["VM_IMAGE_ARCH"] = self.arch
self.qemu_binary = "qemu-system-aarch64" if self.arch == "arm64" else "qemu-system-x86_64"
self.os = platform.system().lower()
if self.os == "darwin" and self.arch == "amd64":
die("amd64 guests are not supported on macOS hosts; use an arm64 image")
self.config_template = Path(
env("WIN11_VM_QEMU_CONFIG") or script_dir / "qemu" / "windows11" / "config.ini")
self.platform_config = script_dir / "qemu" / "windows11" / f"{self.os}-{self.arch}.ini"
if self.arch == "arm64":
self.uefi_code = pick_firmware(
env("WIN11_VM_UEFI_CODE"),
"/usr/share/AAVMF/AAVMF_CODE.fd",
"/usr/share/edk2/aarch64/QEMU_EFI.fd",
"/opt/homebrew/share/qemu/edk2-aarch64-code.fd",
"/usr/local/share/qemu/edk2-aarch64-code.fd",
)
self.uefi_vars_template = pick_firmware(
env("WIN11_VM_UEFI_VARS"),
"/usr/share/AAVMF/AAVMF_VARS.fd",
"/usr/share/edk2/aarch64/vars-template-pflash.raw",
"/opt/homebrew/share/qemu/edk2-arm-vars.fd",
"/usr/local/share/qemu/edk2-arm-vars.fd",
)
else:
self.uefi_code = pick_firmware(
env("WIN11_VM_UEFI_CODE"),
"/usr/share/OVMF/OVMF_CODE_4M.fd",
"/usr/share/edk2/ovmf/OVMF_CODE.fd",
"/opt/homebrew/share/qemu/edk2-x86_64-code.fd",
"/usr/local/share/qemu/edk2-x86_64-code.fd",
)
self.uefi_vars_template = pick_firmware(
env("WIN11_VM_UEFI_VARS"),
"/usr/share/OVMF/OVMF_VARS_4M.fd",
"/usr/share/edk2/ovmf/OVMF_VARS.fd",
"/opt/homebrew/share/qemu/edk2-i386-vars.fd",
"/usr/local/share/qemu/edk2-i386-vars.fd",
)
class Runtime:
"""Paths of the files a VM keeps in its state directory."""
def __init__(self, state_dir):
self.state_dir = state_dir
self.disk_image = Path(env("WIN11_VM_DISK_IMAGE") or state_dir / "disk.qcow2")
self.uefi_vars = state_dir / "OVMF_VARS_4M.fd"
self.pid_file = state_dir / "qemu.pid"
self.log_file = state_dir / "console.log"
self.monitor_socket = state_dir / "qemu-monitor.sock"
self.guest_agent_socket = state_dir / "qemu-guest-agent.sock"
self.qemu_config = state_dir / "qemu.ini"
def check_tools_and_firmware(plat, need_qemu_img=True):
if shutil.which(plat.qemu_binary) is None:
die(f"{plat.qemu_binary} is not installed.")
if need_qemu_img and shutil.which("qemu-img") is None:
die("qemu-img is not installed.")
for firmware_file in (plat.uefi_code, plat.uefi_vars_template):
if not firmware_file.is_file():
die(f"required UEFI firmware was not found: {firmware_file}")
def prepare_runtime(plat, rt):
"""Create the state directory and clear the leftovers of an earlier run."""
rt.state_dir.mkdir(parents=True, exist_ok=True)
if rt.pid_file.is_file():
old_pid = rt.pid_file.read_text().strip()
if old_pid.isdigit():
try:
os.kill(int(old_pid), 0)
except OSError:
pass
else:
die(f"Windows 11 VM is already running (PID {old_pid}).")
rt.pid_file.unlink(missing_ok=True)
rt.monitor_socket.unlink(missing_ok=True)
rt.guest_agent_socket.unlink(missing_ok=True)
if not rt.uefi_vars.is_file():
shutil.copyfile(plat.uefi_vars_template, rt.uefi_vars)
def display_backend(vnc_listen):
"""cocoa shows the guest at its own resolution. Append ,zoom-to-fit=on (via
WIN11_VM_DISPLAY) to stretch a low-resolution screen to the window."""
backend = (f"vnc={vnc_listen}" if vnc_listen else env("WIN11_VM_DISPLAY") or (
"cocoa" if platform.system() == "Darwin" else "gtk"))
if vnc_listen:
vnc_host, _, vnc_display = vnc_listen.rpartition(":")
if not vnc_display.isdigit():
die(f"--vnc display number must be an integer: {vnc_listen}", 2)
print(f"VNC: connect to {vnc_host or '127.0.0.1'}:{5900 + int(vnc_display)}")
return backend
def common_qemu_args(plat, rt, options):
"""Render the config template and return the QEMU arguments shared by both
launchers, plus the port forwards (for the start-up message).
Most of the VM definition lives in the QEMU config template; the file
paths are filled in and the rendered file is handed to QEMU with -readconfig.
"""
config_values = {
"uefi_code": plat.uefi_code,
"uefi_vars": rt.uefi_vars,
"disk_image": rt.disk_image,
"guest_agent_socket": rt.guest_agent_socket,
"monitor_socket": rt.monitor_socket,
}
for key, value in config_values.items():
# QEMU's config file parser has no escape syntax for quoted values.
if any(char in str(value) for char in '"\n'):
die(f"{key} must not contain double quotes or newlines: {value}")
for config_file in (plat.config_template, plat.platform_config):
if not config_file.is_file():
die(f"QEMU config file was not found: {config_file}")
try:
config_text = string.Template(plat.config_template.read_text()).substitute(
{key: str(value) for key, value in config_values.items()})
except (KeyError, ValueError) as error:
die(f"invalid placeholder in {plat.config_template}: {error}")
rt.qemu_config.write_text(config_text)
# Each rule of the port forward file is one hostfwd entry of the network.
forwards_file = Path(
env("WIN11_VM_FORWARDS") or script_dir / "qemu" / "windows11" / "forwards.conf")
forwards = vmforwards.usable_forwards(vmforwards.load_forwards(
forwards_file,
{label: port for label, port in (("RDP", options.rdp_port), ("SSH", options.ssh_port))
if port}))
qemu_args = [
"-readconfig", str(rt.qemu_config),
"-readconfig", str(plat.platform_config),
# "max" is the host CPU under KVM/HVF and the fullest model under TCG.
"-cpu", "max",
# A config file section cannot hold two hostfwd entries, so the network
# that the NIC in the config file uses is defined here. -nic none stops
# QEMU from adding its default NIC as well.
"-netdev", vmforwards.netdev_argument(forwards),
"-nic", "none",
"-display", display_backend(options.vnc_listen),
]
return qemu_args, forwards
def start_qemu(plat, rt, qemu_args, extra_args, forwards, daemonize, banner):
print(banner)
print("Account: guofang / password; " + "; ".join(
f"{forward.label}: 0.0.0.0:{forward.host_port}" for forward in forwards),
flush=True)
if daemonize:
rt.log_file.write_text("")
qemu_args += ["-serial", f"file:{rt.log_file}", "-daemonize", "-pidfile", str(rt.pid_file)]
run([plat.qemu_binary, *qemu_args, *extra_args])
print(f"VM started (PID {rt.pid_file.read_text().strip()}); console log: {rt.log_file}")
else: else:
image_downloader = find_image_downloader() os.execvp(plat.qemu_binary, [plat.qemu_binary, *qemu_args, *extra_args])
tools_iso = Path(capture([str(image_downloader), "--print-path", "virtio-win"]))
if not tools_iso.is_file():
run([str(image_downloader), "virtio-win"])
if not tools_iso.is_file():
die(f"downloader did not create: {tools_iso}")
print(f"VirtIO Windows tools ISO is ready: {tools_iso}")
if env("WIN11_VM_INSTALL_ISO"):
install_iso = Path(env("WIN11_VM_INSTALL_ISO"))
if not install_iso.is_file():
die(f"Windows 11 installation ISO was not found: {install_iso}")
else:
image_downloader = image_downloader or find_image_downloader()
run([str(image_downloader), "windows11"])
install_iso = Path(capture([str(image_downloader), "--print-path", "windows11", "--iso"]))
print(f"Windows 11 installation ISO is ready: {install_iso}")
sys.exit(0)
disk_image = Path(env("WIN11_VM_DISK_IMAGE") or state_dir / "disk.qcow2")
seed_iso = Path(env("WIN11_VM_SEED_ISO") or disk_image.parent / "seed.iso")
uefi_vars = state_dir / "OVMF_VARS_4M.fd"
pid_file = state_dir / "qemu.pid"
log_file = state_dir / "console.log"
monitor_socket = state_dir / "qemu-monitor.sock"
guest_agent_socket = state_dir / "qemu-guest-agent.sock"
qemu_config = state_dir / "qemu.ini"
def build_seed_iso(image_downloader, force=False): def build_seed_iso(seed_iso):
"""Build the seed ISO, unless it is newer than all of its sources and not forced.""" """Build the seed ISO, unless it is newer than all of its sources."""
seed_dir = script_dir / "seediso" / "windows11" seed_dir = script_dir / "seediso" / "windows11"
seed_sources = [seed_dir / "Autounattend.xml", seed_dir / "Configure-Windows.ps1", seed_sources = [seed_dir / "Unattend.xml", seed_dir / "Configure-Windows.ps1",
*sorted(seed_dir.glob("[0-9][0-9]-*.bat"))] *sorted(seed_dir.glob("[0-9][0-9]-*.bat")),
*sorted(seed_dir.glob("[0-9][0-9]-*.ps1"))]
# build-seed.sh embeds the administrator SSH public key. # build-seed.sh embeds the administrator SSH public key.
ssh_key = os.environ.get("SSH_PUBLIC_KEY_FILE") or next( ssh_key = os.environ.get("SSH_PUBLIC_KEY_FILE") or next(
@@ -298,192 +370,49 @@ def build_seed_iso(image_downloader, force=False):
# The seed carries the OpenSSH zip for this architecture; build-seed.sh # The seed carries the OpenSSH zip for this architecture; build-seed.sh
# downloads it when missing. # downloads it when missing.
seed_sources.append(Path(capture( openssh_zip = Path(capture(
[str(image_downloader), "--print-path", "openssh-win"]))) [str(find_image_downloader()), "--print-path", "openssh-win"]))
seed_sources.append(openssh_zip)
def newer_than_seed(source): if not seed_iso.is_file() or not openssh_zip.is_file() or any(
# Mirrors bash `-nt`: true if source exists and is newer than seed_iso. source.exists() and source.stat().st_mtime > seed_iso.stat().st_mtime
return source.exists() and source.stat().st_mtime > seed_iso.stat().st_mtime for source in seed_sources):
run([str(seed_dir / "build-seed.sh"), str(seed_iso), plat.arch])
if force or not seed_iso.is_file() or not seed_sources[-1].is_file() \
or any(newer_than_seed(s) for s in seed_sources):
run([str(script_dir / "seediso" / "windows11" / "build-seed.sh"), str(seed_iso), host_arch])
if seed_only: options = parse_args(sys.argv[1:])
build_seed_iso(find_image_downloader(), force=True) plat = Platform(options.arch_option)
print(f"Seed ISO is ready: {seed_iso}") rt = Runtime(options.state_dir)
sys.exit(0) disk_size = env("WIN11_VM_DISK_SIZE") or "64G"
installing = False creating = not rt.disk_image.is_file()
installing_from_iso = False if creating:
if not disk_image.is_file(): base_image = Path(env("WIN11_VM_BASE_IMAGE")
installing = True or capture([str(find_image_downloader()), "--print-path", "windows11"]))
installing_from_iso = install_from_iso if not base_image.is_file():
image_downloader = find_image_downloader() die(f"Windows 11 base QCOW2 image was not found: {base_image}\n"
if install_from_iso: "Make it once with vm-images/build-windows-base.sh")
install_iso = Path(
env("WIN11_VM_INSTALL_ISO")
or capture([str(image_downloader), "--print-path", "windows11", "--iso"]))
if install_from_img:
install_iso = install_iso.with_suffix(".img")
uses_shared_tools_iso = False
if env("WIN11_VM_TOOLS_ISO"):
tools_iso = Path(env("WIN11_VM_TOOLS_ISO"))
else:
tools_iso = Path(capture([str(image_downloader), "--print-path", "virtio-win"]))
uses_shared_tools_iso = True
else:
base_image = Path(env("WIN11_VM_BASE_IMAGE")
or capture([str(image_downloader), "--print-path", "windows11"]))
if shutil.which(qemu_binary) is None: check_tools_and_firmware(plat, need_qemu_img=creating)
die(f"{qemu_binary} is not installed.") prepare_runtime(plat, rt)
if installing: if creating:
if shutil.which("qemu-img") is None: seed_iso = Path(env("WIN11_VM_SEED_ISO") or rt.disk_image.parent / "seed.iso")
die("qemu-img is not installed.") build_seed_iso(seed_iso)
if installing_from_iso: if creating:
if not install_iso.is_file(): run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2",
if install_from_img: "-b", str(base_image), str(rt.disk_image), disk_size])
die(f"Windows 11 installation disk image was not found: {install_iso}\n" print(f"Created Windows 11 system disk: {rt.disk_image}")
"Build it with vm-images/build-windows-install-disk.sh ISO")
die(f"Windows 11 ISO was not found: {install_iso}")
elif not base_image.is_file():
die(f"Windows 11 base QCOW2 image was not found: {base_image}")
for firmware_file in (uefi_code, uefi_vars_template):
if not firmware_file.is_file():
die(f"required UEFI firmware was not found: {firmware_file}")
state_dir.mkdir(parents=True, exist_ok=True) qemu_args, forwards = common_qemu_args(plat, rt, options)
if installing_from_iso: if plat.os == "darwin" and plat.arch == "arm64":
if not tools_iso.is_file(): # The HVF in-kernel GICv3 stalls Windows on its USB and NVMe devices
if uses_shared_tools_iso: # (utmapp/UTM#7970), so use the emulated interrupt controller.
run([str(image_downloader), "virtio-win"]) qemu_args += ["-machine", "kernel-irqchip=off"]
else: if creating:
die(f"VirtIO Windows tools ISO was not found: {tools_iso}") # q35 has IDE (ide.0 is the system disk); "virt" does not, so use USB.
if not tools_iso.is_file(): qemu_args += ["-drive", f"file={seed_iso},if=none,id=seed_media,media=cdrom,readonly=on",
die(f"downloader did not create: {tools_iso}") "-device", "ide-cd,drive=seed_media,bus=ide.1" if plat.arch == "amd64"
else "usb-storage,drive=seed_media"]
build_seed_iso(image_downloader) start_qemu(plat, rt, qemu_args, options.extra_args, forwards, options.daemonize,
"Starting Windows 11 from the QCOW2 base image." if creating
if pid_file.is_file(): else "Starting Windows 11 normally.")
old_pid = pid_file.read_text().strip()
if old_pid.isdigit():
try:
os.kill(int(old_pid), 0)
except OSError:
pass
else:
die(f"Windows 11 VM is already running (PID {old_pid}).")
pid_file.unlink(missing_ok=True)
monitor_socket.unlink(missing_ok=True)
guest_agent_socket.unlink(missing_ok=True)
if not uefi_vars.is_file():
shutil.copyfile(uefi_vars_template, uefi_vars)
if installing:
if installing_from_iso:
run(["qemu-img", "create", "-q", "-f", "qcow2", str(disk_image), disk_size])
else:
run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2",
"-b", str(base_image), str(disk_image), disk_size])
print(f"Created Windows 11 system disk: {disk_image}")
# cocoa shows the guest at its own resolution. Append ,zoom-to-fit=on (via
# WIN11_VM_DISPLAY) to stretch a low-resolution screen to the window.
display_backend = env("WIN11_VM_DISPLAY") or (
"cocoa" if platform.system() == "Darwin" else "gtk")
# Most of the VM definition lives in the QEMU config template; fill in the
# file paths and hand the rendered file to QEMU with -readconfig.
config_values = {
"uefi_code": uefi_code,
"uefi_vars": uefi_vars,
"disk_image": disk_image,
"guest_agent_socket": guest_agent_socket,
"monitor_socket": monitor_socket,
}
for key, value in config_values.items():
# QEMU's config file parser has no escape syntax for quoted values.
if any(char in str(value) for char in '"\n'):
die(f"{key} must not contain double quotes or newlines: {value}")
for config_file in (qemu_config_template, qemu_platform_config):
if not config_file.is_file():
die(f"QEMU config file was not found: {config_file}")
try:
qemu_config_text = string.Template(qemu_config_template.read_text()).substitute(
{key: str(value) for key, value in config_values.items()})
except (KeyError, ValueError) as error:
die(f"invalid placeholder in {qemu_config_template}: {error}")
qemu_config.write_text(qemu_config_text)
# QEMU refuses to start when a forwarded host port is taken, so warn and leave
# that forward out instead.
forwards = []
forwarded_ports = []
for label, host_port, guest_port in (("RDP", rdp_port, 3389), ("SSH", ssh_port, 22)):
in_use = port_in_use(host_port)
forwarded_ports.append((label, host_port, not in_use))
if in_use:
print(f"Warning: host port {host_port} is already in use; "
f"{label} will not be forwarded to the guest.", file=sys.stderr)
else:
forwards.append(f"hostfwd=tcp::{host_port}-:{guest_port}")
# Neither Windows Setup nor the base image has the viogpudo driver yet, and
# without it the virtio-gpu in the config file shows "display output is not
# active". ramfb shows the screen until Configure-Windows.ps1 installs it. It
# must be created before the virtio-gpu: the display window shows the first GPU.
qemu_args = ["-device", "ramfb"] if installing and host_arch == "arm64" else []
qemu_args += [
"-readconfig", str(qemu_config),
"-readconfig", str(qemu_platform_config),
# "max" is the host CPU under KVM/HVF and the fullest model under TCG.
"-cpu", "max",
# A config file section cannot hold two hostfwd entries, so the network
# that the NIC in the config file uses is defined here. -nic none stops
# QEMU from adding its default NIC as well.
"-netdev", ",".join(["user,id=net0", *forwards]),
"-nic", "none",
"-display", display_backend,
# "-display", "none", "-spice", "port=5900,addr=127.0.0.1,disable-ticketing=on,agent-mouse=off", "-device", "virtio-vga",
]
if installing_from_iso:
# q35 has IDE for the installation media; "virt" does not, so use USB.
for media_id, media_file, boot_index, ide_bus in (
("install_media", install_iso, 2, "ide.1"),
("answer_media", seed_iso, 3, "ide.2"),
("tools_media", tools_iso, 4, "ide.3")):
device = (f"ide-cd,drive={media_id},bus={ide_bus},bootindex={boot_index}"
if host_arch == "amd64"
else f"usb-storage,drive={media_id},bootindex={boot_index}")
if media_id == "install_media" and install_from_img:
# snapshot=on: NVMe cannot report write protection, and Windows
# writes to the volume it mounts, so a read-only drive makes it fail.
# The writes go to a temporary overlay and the image stays unchanged.
qemu_args += [
"-drive", f"file={media_file},if=none,id={media_id},format=raw,snapshot=on",
"-device", f"nvme,drive={media_id},serial=installer,bootindex={boot_index}",
]
continue
qemu_args += [
"-drive", f"file={media_file},if=none,id={media_id},media=cdrom,readonly=on",
"-device", device,
]
if installing_from_iso:
print("Starting Windows 11 automatic installation.")
elif installing:
print("Starting Windows 11 from the QCOW2 base image.")
else:
print("Starting Windows 11 normally.")
print("Account: guofang / password; " + "; ".join(
f"{label}: localhost:{port}" for label, port, forwarded in forwarded_ports if forwarded),
flush=True)
if daemonize:
log_file.write_text("")
qemu_args += ["-serial", f"file:{log_file}", "-daemonize", "-pidfile", str(pid_file)]
run([qemu_binary, *qemu_args, *extra_args])
print(f"VM started (PID {pid_file.read_text().strip()}); console log: {log_file}")
else:
os.execvp(qemu_binary, [qemu_binary, *qemu_args, *extra_args])
+1
View File
@@ -7,6 +7,7 @@ Each operating system has an independent NoCloud configuration and generated
seediso/ seediso/
├── debian13/ ├── debian13/
├── almalinux10/ ├── almalinux10/
├── rocky9/
├── fedora44/ ├── fedora44/
└── ubuntu2604/ └── ubuntu2604/
``` ```
+4
View File
@@ -3,6 +3,10 @@
This directory contains the cloud-init NoCloud configuration used by This directory contains the cloud-init NoCloud configuration used by
`run-almalinux10.py`. `run-almalinux10.py`.
`build-seed.sh` injects your SSH public key into `user-data` (the `@SSH_PUBKEY@`
placeholder): `$SSH_PUBKEY_FILE`, else the first `~/.ssh/id_*.pub`, else the first
key offered by `ssh-agent`.
After changing `user-data`, `meta-data`, or `network-config`, remove the old After changing `user-data`, `meta-data`, or `network-config`, remove the old
`seed.iso` and rebuild it: `seed.iso` and rebuild it:
+36 -9
View File
@@ -5,6 +5,33 @@ script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
output="${1:-${script_dir}/seed.iso}" output="${1:-${script_dir}/seed.iso}"
files=(user-data meta-data) files=(user-data meta-data)
# Public key injected into user-data: $SSH_PUBKEY_FILE, else the first
# ~/.ssh/id_*.pub, else the first key offered by ssh-agent.
pubkey=""
if [[ -n "${SSH_PUBKEY_FILE:-}" ]]; then
pubkey="$(head -n1 -- "${SSH_PUBKEY_FILE}")"
else
for f in "${HOME}"/.ssh/id_ed25519.pub "${HOME}"/.ssh/id_*.pub; do
if [[ -f "$f" ]]; then pubkey="$(head -n1 -- "$f")"; break; fi
done
if [[ -z "$pubkey" ]] && command -v ssh-add >/dev/null 2>&1; then
pubkey="$(ssh-add -L 2>/dev/null | head -n1 || true)"
fi
fi
if [[ "$pubkey" != ssh-* && "$pubkey" != ecdsa-* && "$pubkey" != sk-* ]]; then
printf 'Error: no SSH public key found; set SSH_PUBKEY_FILE.\n' >&2
exit 1
fi
work="$(mktemp -d)"
trap 'rm -rf -- "${work}"' EXIT
template="$(<"${script_dir}/user-data")"
printf '%s\n' "${template//@SSH_PUBKEY@/${pubkey}}" > "${work}/user-data"
cp -- "${script_dir}/meta-data" "${work}/meta-data"
if [[ -f "${script_dir}/network-config" ]]; then
cp -- "${script_dir}/network-config" "${work}/network-config"
fi
mkdir -p "$(dirname -- "$output")" mkdir -p "$(dirname -- "$output")"
if [[ -f "${script_dir}/network-config" ]]; then if [[ -f "${script_dir}/network-config" ]]; then
@@ -14,22 +41,22 @@ fi
if command -v cloud-localds >/dev/null 2>&1; then if command -v cloud-localds >/dev/null 2>&1; then
args=() args=()
if [[ -f "${script_dir}/network-config" ]]; then if [[ -f "${script_dir}/network-config" ]]; then
args+=(--network-config="${script_dir}/network-config") args+=(--network-config="${work}/network-config")
fi fi
cloud-localds "${args[@]}" "$output" \ cloud-localds "${args[@]}" "${output}" \
"${script_dir}/user-data" "${script_dir}/meta-data" "${work}/user-data" "${work}/meta-data"
elif command -v xorriso >/dev/null 2>&1; then elif command -v xorriso >/dev/null 2>&1; then
xorriso -as mkisofs -quiet -volid cidata -joliet -rock \ xorriso -as mkisofs -quiet -volid cidata -joliet -rock \
-output "$output" "${files[@]/#/${script_dir}/}" -output "${output}" "${files[@]/#/${work}/}"
elif command -v genisoimage >/dev/null 2>&1; then elif command -v genisoimage >/dev/null 2>&1; then
genisoimage -quiet -output "$output" -volid cidata -joliet -rock \ genisoimage -quiet -output "${output}" -volid cidata -joliet -rock \
"${files[@]/#/${script_dir}/}" "${files[@]/#/${work}/}"
elif command -v mkisofs >/dev/null 2>&1; then elif command -v mkisofs >/dev/null 2>&1; then
mkisofs -quiet -output "$output" -volid cidata -joliet -rock \ mkisofs -quiet -output "${output}" -volid cidata -joliet -rock \
"${files[@]/#/${script_dir}/}" "${files[@]/#/${work}/}"
else else
printf 'Error: install cloud-image-utils, xorriso, genisoimage, or mkisofs.\n' >&2 printf 'Error: install cloud-image-utils, xorriso, genisoimage, or mkisofs.\n' >&2
exit 1 exit 1
fi fi
printf 'Created %s\n' "$output" printf 'Created %s\n' "${output}"
+2
View File
@@ -10,6 +10,8 @@ users:
shell: /bin/bash shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD: ALL" sudo: "ALL=(ALL) NOPASSWD: ALL"
lock_passwd: false lock_passwd: false
ssh_authorized_keys:
- @SSH_PUBKEY@
hashed_passwd: $6$0NtCERV3eO12t9oJ$nlFE2sSc66nFfuD5X0xMnOla1cuzOnHfeGNRY5X7dCDHg.WzWVmuRe7M17Puj44.eCWLnJAWt4WNjd9vP9X0D0 hashed_passwd: $6$0NtCERV3eO12t9oJ$nlFE2sSc66nFfuD5X0xMnOla1cuzOnHfeGNRY5X7dCDHg.WzWVmuRe7M17Puj44.eCWLnJAWt4WNjd9vP9X0D0
chpasswd: chpasswd:
+4
View File
@@ -4,6 +4,10 @@ This directory contains the cloud-init NoCloud configuration used by
`run-fedora44.py`. On first boot, cloud-init's package module installs Fedora's `run-fedora44.py`. On first boot, cloud-init's package module installs Fedora's
GNOME package group and reboots the guest. GNOME package group and reboots the guest.
`build-seed.sh` injects your SSH public key into `user-data` (the `@SSH_PUBKEY@`
placeholder): `$SSH_PUBKEY_FILE`, else the first `~/.ssh/id_*.pub`, else the first
key offered by `ssh-agent`.
After changing `user-data`, `meta-data`, or `network-config`, remove the old After changing `user-data`, `meta-data`, or `network-config`, remove the old
`seed.iso` and rebuild it: `seed.iso` and rebuild it:
+40 -6
View File
@@ -5,24 +5,58 @@ script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
output="${1:-${script_dir}/seed.iso}" output="${1:-${script_dir}/seed.iso}"
files=(user-data meta-data) files=(user-data meta-data)
# Public key injected into user-data: $SSH_PUBKEY_FILE, else the first
# ~/.ssh/id_*.pub, else the first key offered by ssh-agent.
pubkey=""
if [[ -n "${SSH_PUBKEY_FILE:-}" ]]; then
pubkey="$(head -n1 -- "${SSH_PUBKEY_FILE}")"
else
for f in "${HOME}"/.ssh/id_ed25519.pub "${HOME}"/.ssh/id_*.pub; do
if [[ -f "$f" ]]; then pubkey="$(head -n1 -- "$f")"; break; fi
done
if [[ -z "$pubkey" ]] && command -v ssh-add >/dev/null 2>&1; then
pubkey="$(ssh-add -L 2>/dev/null | head -n1 || true)"
fi
fi
if [[ "$pubkey" != ssh-* && "$pubkey" != ecdsa-* && "$pubkey" != sk-* ]]; then
printf 'Error: no SSH public key found; set SSH_PUBKEY_FILE.\n' >&2
exit 1
fi
work="$(mktemp -d)"
trap 'rm -rf -- "${work}"' EXIT
template="$(<"${script_dir}/user-data")"
printf '%s\n' "${template//@SSH_PUBKEY@/${pubkey}}" > "${work}/user-data"
cp -- "${script_dir}/meta-data" "${work}/meta-data"
if [[ -f "${script_dir}/network-config" ]]; then
cp -- "${script_dir}/network-config" "${work}/network-config"
fi
mkdir -p "$(dirname -- "$output")" mkdir -p "$(dirname -- "$output")"
if [[ -f "${script_dir}/network-config" ]]; then if [[ -f "${script_dir}/network-config" ]]; then
files+=(network-config) files+=(network-config)
fi fi
if command -v cloud-localds >/dev/null 2>&1; then if command -v cloud-localds >/dev/null 2>&1; then
args=() args=()
[[ -f "${script_dir}/network-config" ]] && args+=(--network-config="${script_dir}/network-config") if [[ -f "${script_dir}/network-config" ]]; then
cloud-localds "${args[@]}" "$output" "${script_dir}/user-data" "${script_dir}/meta-data" args+=(--network-config="${work}/network-config")
fi
cloud-localds "${args[@]}" "${output}" \
"${work}/user-data" "${work}/meta-data"
elif command -v xorriso >/dev/null 2>&1; then elif command -v xorriso >/dev/null 2>&1; then
xorriso -as mkisofs -quiet -volid cidata -joliet -rock -output "$output" "${files[@]/#/${script_dir}/}" xorriso -as mkisofs -quiet -volid cidata -joliet -rock \
-output "${output}" "${files[@]/#/${work}/}"
elif command -v genisoimage >/dev/null 2>&1; then elif command -v genisoimage >/dev/null 2>&1; then
genisoimage -quiet -output "$output" -volid cidata -joliet -rock "${files[@]/#/${script_dir}/}" genisoimage -quiet -output "${output}" -volid cidata -joliet -rock \
"${files[@]/#/${work}/}"
elif command -v mkisofs >/dev/null 2>&1; then elif command -v mkisofs >/dev/null 2>&1; then
mkisofs -quiet -output "$output" -volid cidata -joliet -rock "${files[@]/#/${script_dir}/}" mkisofs -quiet -output "${output}" -volid cidata -joliet -rock \
"${files[@]/#/${work}/}"
else else
printf 'Error: install cloud-image-utils, xorriso, genisoimage, or mkisofs.\n' >&2 printf 'Error: install cloud-image-utils, xorriso, genisoimage, or mkisofs.\n' >&2
exit 1 exit 1
fi fi
printf 'Created %s\n' "$output" printf 'Created %s\n' "${output}"
+2
View File
@@ -10,6 +10,8 @@ users:
shell: /bin/bash shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD: ALL" sudo: "ALL=(ALL) NOPASSWD: ALL"
lock_passwd: false lock_passwd: false
ssh_authorized_keys:
- @SSH_PUBKEY@
hashed_passwd: $6$0NtCERV3eO12t9oJ$nlFE2sSc66nFfuD5X0xMnOla1cuzOnHfeGNRY5X7dCDHg.WzWVmuRe7M17Puj44.eCWLnJAWt4WNjd9vP9X0D0 hashed_passwd: $6$0NtCERV3eO12t9oJ$nlFE2sSc66nFfuD5X0xMnOla1cuzOnHfeGNRY5X7dCDHg.WzWVmuRe7M17Puj44.eCWLnJAWt4WNjd9vP9X0D0
chpasswd: chpasswd:
+2
View File
@@ -0,0 +1,2 @@
seed.iso
*.iso
+17
View File
@@ -0,0 +1,17 @@
# Rocky Linux 9 seed.iso
This directory contains the cloud-init NoCloud configuration used by
`run-rocky9.py`.
`build-seed.sh` injects your SSH public key into `user-data` (the `@SSH_PUBKEY@`
placeholder): `$SSH_PUBKEY_FILE`, else the first `~/.ssh/id_*.pub`, else the first
key offered by `ssh-agent`.
After changing `user-data`, `meta-data`, or `network-config`, remove the old
`seed.iso` and rebuild it:
```bash
./seediso/rocky9/build-seed.sh
```
Use a unique `instance-id` when cloning this configuration for another VM.
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
output="${1:-${script_dir}/seed.iso}"
files=(user-data meta-data)
# Public key injected into user-data: $SSH_PUBKEY_FILE, else the first
# ~/.ssh/id_*.pub, else the first key offered by ssh-agent.
pubkey=""
if [[ -n "${SSH_PUBKEY_FILE:-}" ]]; then
pubkey="$(head -n1 -- "${SSH_PUBKEY_FILE}")"
else
for f in "${HOME}"/.ssh/id_ed25519.pub "${HOME}"/.ssh/id_*.pub; do
if [[ -f "$f" ]]; then pubkey="$(head -n1 -- "$f")"; break; fi
done
if [[ -z "$pubkey" ]] && command -v ssh-add >/dev/null 2>&1; then
pubkey="$(ssh-add -L 2>/dev/null | head -n1 || true)"
fi
fi
if [[ "$pubkey" != ssh-* && "$pubkey" != ecdsa-* && "$pubkey" != sk-* ]]; then
printf 'Error: no SSH public key found; set SSH_PUBKEY_FILE.\n' >&2
exit 1
fi
work="$(mktemp -d)"
trap 'rm -rf -- "${work}"' EXIT
template="$(<"${script_dir}/user-data")"
printf '%s\n' "${template//@SSH_PUBKEY@/${pubkey}}" > "${work}/user-data"
cp -- "${script_dir}/meta-data" "${work}/meta-data"
if [[ -f "${script_dir}/network-config" ]]; then
cp -- "${script_dir}/network-config" "${work}/network-config"
fi
mkdir -p "$(dirname -- "$output")"
if [[ -f "${script_dir}/network-config" ]]; then
files+=(network-config)
fi
if command -v cloud-localds >/dev/null 2>&1; then
args=()
if [[ -f "${script_dir}/network-config" ]]; then
args+=(--network-config="${work}/network-config")
fi
cloud-localds "${args[@]}" "${output}" \
"${work}/user-data" "${work}/meta-data"
elif command -v xorriso >/dev/null 2>&1; then
xorriso -as mkisofs -quiet -volid cidata -joliet -rock \
-output "${output}" "${files[@]/#/${work}/}"
elif command -v genisoimage >/dev/null 2>&1; then
genisoimage -quiet -output "${output}" -volid cidata -joliet -rock \
"${files[@]/#/${work}/}"
elif command -v mkisofs >/dev/null 2>&1; then
mkisofs -quiet -output "${output}" -volid cidata -joliet -rock \
"${files[@]/#/${work}/}"
else
printf 'Error: install cloud-image-utils, xorriso, genisoimage, or mkisofs.\n' >&2
exit 1
fi
printf 'Created %s\n' "${output}"
+2
View File
@@ -0,0 +1,2 @@
instance-id: rocky9-host-001
local-hostname: rocky9-host
+10
View File
@@ -0,0 +1,10 @@
version: 2
ethernets:
all-en:
match:
name: "en*"
dhcp4: true
all-eth:
match:
name: "eth*"
dhcp4: true
+18
View File
@@ -0,0 +1,18 @@
#cloud-config
preserve_hostname: false
manage_etc_hosts: true
ssh_pwauth: true
users:
- name: guofang
gecos: Guofang
groups: [wheel]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD: ALL"
lock_passwd: false
ssh_authorized_keys:
- @SSH_PUBKEY@
hashed_passwd: $6$0NtCERV3eO12t9oJ$nlFE2sSc66nFfuD5X0xMnOla1cuzOnHfeGNRY5X7dCDHg.WzWVmuRe7M17Puj44.eCWLnJAWt4WNjd9vP9X0D0
chpasswd:
expire: false
+1 -1
View File
@@ -23,7 +23,7 @@ set "QT_ARCH=win64_%QT_DIR%"
rem aqt has no Arm64 build; the x64 one runs under emulation on Arm64 Windows. rem aqt has no Arm64 build; the x64 one runs under emulation on Arm64 Windows.
echo Downloading aqtinstall... echo Downloading aqtinstall...
curl.exe -L --fail -o "%AQT%" https://github.com/miurahr/aqtinstall/releases/download/v3.3.0/aqt_x64.exe curl.exe -L --fail -o "%AQT%" https://wangguofang.net/github.com/miurahr/aqtinstall/releases/download/v3.3.0/aqt_x64.exe
if errorlevel 1 ( if errorlevel 1 (
echo Download failed. echo Download failed.
if not defined INSTALL_ALL pause if not defined INSTALL_ALL pause
@@ -0,0 +1,14 @@
@echo off
setlocal
title Windows update status
fltmc >nul 2>&1
if errorlevel 1 (
echo Requesting administrator rights...
powershell.exe -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
exit /b
)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp080-Get-UpdateStatus-Admin.ps1"
echo.
pause
@@ -0,0 +1,104 @@
# Prints whether the guest is upgrading or downloading updates.
# Read-only. Run as Administrator for complete results (BITS jobs of all users,
# command lines of system processes); a standard user gets partial output.
param(
[int]$EventCount = 8
)
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
function Write-Section([string]$Title) {
Write-Host ''
Write-Host "== $Title =="
}
function Out-Text($Value) {
$text = ($Value | Out-String).TrimEnd()
if ($text) { Write-Host $text } else { Write-Host '(none)' }
}
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$isAdmin = [Security.Principal.WindowsPrincipal]::new($identity).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
Write-Section 'Host'
Write-Host ('Time: {0}' -f (Get-Date))
Write-Host ('Admin: {0}' -f $isAdmin)
if (-not $isAdmin) {
Write-Host 'Warning: not elevated, BITS jobs and process command lines may be incomplete.'
}
Write-Section 'Operating system'
Out-Text (Get-CimInstance Win32_OperatingSystem |
Select-Object Caption, Version, BuildNumber, LastBootUpTime | Format-List)
Write-Section 'Update policy'
$wuPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'
$props = 'NoAutoUpdate', 'NoAutoRebootWithLoggedOnUsers', 'ExcludeWUDriversInQualityUpdate',
'TargetReleaseVersion', 'TargetReleaseVersionInfo'
foreach ($item in @(@{ Path = "$wuPath\AU" }, @{ Path = $wuPath })) {
$values = Get-ItemProperty -Path $item.Path -ErrorAction SilentlyContinue
foreach ($name in $props) {
if ($values -and $values.PSObject.Properties[$name]) {
Write-Host ('{0,-34} {1}' -f $name, $values.$name)
}
}
}
Write-Section 'Upgrade / pending-reboot markers'
$markers = [ordered]@{
'C:\$WINDOWS.~BT (upgrade staging)' = Test-Path -LiteralPath 'C:\$WINDOWS.~BT'
'C:\Windows\WinSxS\pending.xml' = Test-Path -LiteralPath 'C:\Windows\WinSxS\pending.xml'
'WindowsUpdate RebootRequired' = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'
'Component Based Servicing RebootPending' = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending'
}
foreach ($key in $markers.Keys) {
Write-Host ('{0,-42} {1}' -f $key, $markers[$key])
}
Write-Section 'Update services'
Out-Text (Get-Service wuauserv, UsoSvc, TrustedInstaller, bits, WaaSMedicSvc, DoSvc `
-ErrorAction SilentlyContinue |
Select-Object Name, Status, StartType | Format-Table -AutoSize)
Write-Section 'Update scheduled tasks'
Out-Text (Get-ScheduledTask `
-TaskPath '\Microsoft\Windows\UpdateOrchestrator\*', '\Microsoft\Windows\WindowsUpdate\*' `
-ErrorAction SilentlyContinue |
Select-Object TaskName, State | Format-Table -AutoSize)
Write-Section 'Update-related processes'
$pattern = 'setup|TiWorker|TrustedInstaller|UsoClient|MoSetup|SetupHost|SetupPrep|MusNotification|wusa|dism|EdgeUpdate|Installer'
Out-Text (Get-CimInstance Win32_Process |
Where-Object { $_.Name -match $pattern -or $_.CommandLine -match 'wuauserv|UsoSvc|DoSvc' } |
Select-Object ProcessId, Name, CreationDate, CommandLine | Format-List)
Write-Section 'BITS download jobs'
Out-Text (Get-BitsTransfer -AllUsers -ErrorAction SilentlyContinue |
Select-Object DisplayName, JobState, BytesTransferred, BytesTotal | Format-Table -AutoSize)
Write-Section "Windows Update events (last $EventCount)"
Out-Text (Get-WinEvent -LogName 'Microsoft-Windows-WindowsUpdateClient/Operational' `
-MaxEvents $EventCount -ErrorAction SilentlyContinue |
Select-Object TimeCreated, Id, Message | Format-List)
Write-Section 'Established HTTP/HTTPS connections by process'
Out-Text (Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
Where-Object { $_.RemotePort -in 80, 443 } |
Group-Object OwningProcess |
ForEach-Object {
$process = Get-Process -Id $_.Name -ErrorAction SilentlyContinue
[pscustomobject]@{ Pid = $_.Name; Process = $process.Name; Connections = $_.Count }
} | Format-Table -AutoSize)
Write-Section 'Network receive rate (2 samples, 2 s apart)'
$counter = Get-Counter '\Network Interface(*)\Bytes Received/sec' `
-SampleInterval 2 -MaxSamples 2 -ErrorAction SilentlyContinue
if ($counter) {
Out-Text ($counter[-1].CounterSamples |
Select-Object InstanceName, @{ n = 'KBps'; e = { [int]($_.CookedValue / 1024) } } |
Format-Table -AutoSize)
} else {
Write-Host '(performance counters unavailable)'
}
+21
View File
@@ -0,0 +1,21 @@
@echo off
setlocal
title Toggle display scale
rem No argument: switch between 100% and 200%. Pass 100 or 200 to force a value.
rem Runs as the current user (no elevation), because the scale is a per-user setting.
rem The change takes effect after a restart; the script offers to restart at the end.
set "SCALE=%~1"
if not defined SCALE set "SCALE=0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp081-Toggle-Scale.ps1" -Scale %SCALE%
if errorlevel 1 (
pause
exit /b 1
)
echo.
choice /c YN /m "Restart now"
if errorlevel 2 exit /b 0
shutdown.exe /r /t 5
+34
View File
@@ -0,0 +1,34 @@
# Switches the display scale between 100% and 200% for the current user.
# With no argument it toggles; -Scale 100 or -Scale 200 forces a value.
# The scale is stored in the registry (LogPixels), so the change takes effect only after
# a restart. Use -Restart to reboot right away.
param(
[ValidateSet(0, 100, 200)]
[int]$Scale = 0,
[switch]$Restart
)
$ErrorActionPreference = 'Stop'
$desktopKey = 'HKCU:\Control Panel\Desktop'
$metricsKey = 'HKCU:\Control Panel\Desktop\WindowMetrics'
$logPixels = (Get-ItemProperty -Path $desktopKey -Name LogPixels -ErrorAction SilentlyContinue).LogPixels
if (-not $logPixels) { $logPixels = 96 }
$current = [int][math]::Round($logPixels * 100 / 96)
$target = if ($Scale -ne 0) { $Scale } elseif ($current -eq 100) { 200 } else { 100 }
$dpi = [int]($target * 96 / 100)
Write-Host "Scale: $current% -> $target%"
# Win8DpiScaling=1 makes Windows use LogPixels instead of the per-monitor recommendation.
Set-ItemProperty -Path $desktopKey -Name LogPixels -Type DWord -Value $dpi
Set-ItemProperty -Path $desktopKey -Name Win8DpiScaling -Type DWord -Value 1
Set-ItemProperty -Path $metricsKey -Name AppliedDPI -Type DWord -Value $dpi
Write-Host 'Restart Windows for the new scale to take effect.'
if ($Restart) {
Write-Host 'Restarting in 5 seconds...'
shutdown.exe /r /t 5
}
-171
View File
@@ -1,171 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<SetupUILanguage>
<UILanguage>en-US</UILanguage>
</SetupUILanguage>
<InputLocale>en-US</InputLocale>
<SystemLocale>en-US</SystemLocale>
<UILanguage>en-US</UILanguage>
<UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<DynamicUpdate>
<Enable>false</Enable>
<WillShowUI>Never</WillShowUI>
</DynamicUpdate>
<DiskConfiguration>
<Disk wcm:action="add">
<DiskID>0</DiskID>
<WillWipeDisk>true</WillWipeDisk>
<CreatePartitions>
<CreatePartition wcm:action="add">
<Order>1</Order>
<Type>EFI</Type>
<Size>100</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>2</Order>
<Type>MSR</Type>
<Size>16</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>3</Order>
<Type>Primary</Type>
<Extend>true</Extend>
</CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add">
<Order>1</Order>
<PartitionID>1</PartitionID>
<Format>FAT32</Format>
<Label>System</Label>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>2</Order>
<PartitionID>3</PartitionID>
<Format>NTFS</Format>
<Label>Windows</Label>
<Letter>C</Letter>
</ModifyPartition>
</ModifyPartitions>
</Disk>
<WillShowUI>OnError</WillShowUI>
</DiskConfiguration>
<ImageInstall>
<OSImage>
<InstallFrom>
<MetaData wcm:action="add">
<Key>/IMAGE/INDEX</Key>
<Value>6</Value>
</MetaData>
</InstallFrom>
<InstallTo>
<DiskID>0</DiskID>
<PartitionID>3</PartitionID>
</InstallTo>
<WillShowUI>OnError</WillShowUI>
</OSImage>
</ImageInstall>
<UserData>
<AcceptEula>true</AcceptEula>
<ProductKey>
<Key>W269N-WFGWX-YVC9B-4J6C9-T83GX</Key>
<WillShowUI>Never</WillShowUI>
</ProductKey>
</UserData>
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>3</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassRAMCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<ComputerName>WIN11</ComputerName>
<TimeZone>China Standard Time</TimeZone>
</component>
<component name="Microsoft-Windows-TerminalServices-LocalSessionManager"
processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral"
versionScope="nonSxS">
<fDenyTSConnections>false</fDenyTSConnections>
</component>
<component name="Networking-MPSSVC-Svc" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<FirewallGroups>
<FirewallGroup xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
wcm:action="add" wcm:keyValue="RemoteDesktop">
<Active>true</Active>
<Group>Remote Desktop</Group>
<Profile>all</Profile>
</FirewallGroup>
</FirewallGroups>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core" processorArchitecture="amd64"
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="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<ProtectYourPC>3</ProtectYourPC>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>guofang</Name>
<Group>Administrators</Group>
<DisplayName>Guofang</DisplayName>
<Password>
<Value>wang</Value>
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Enabled>true</Enabled>
<LogonCount>1</LogonCount>
<Username>guofang</Username>
<Password>
<Value>wang</Value>
<PlainText>true</PlainText>
</Password>
</AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>Configure Windows from the answer-media script</Description>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "&amp; ('{0}:\Configure-Windows.ps1' -f (Get-Volume -FileSystemLabel CIDATA).DriveLetter) -InstallVirtIO -EnableAutomaticLogon -DisableAutoUpdate -CopyInstallers -InstallOpenSSH -NoPause"</CommandLine>
<RequiresUserInput>false</RequiresUserInput>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>
+4 -72
View File
@@ -1,5 +1,4 @@
param( param(
[switch]$InstallVirtIO,
[switch]$EnableAutomaticLogon, [switch]$EnableAutomaticLogon,
[switch]$DisableAutoUpdate, [switch]$DisableAutoUpdate,
[switch]$CopyInstallers, [switch]$CopyInstallers,
@@ -35,72 +34,6 @@ if (-not (Test-Administrator)) {
exit $elevated.ExitCode exit $elevated.ExitCode
} }
# The guest tools installer is an x86 program and the VirtIO ISO has no Arm64
# package for it, so on Arm64 Windows the Arm64 drivers are installed from their
# folders on the ISO instead. NetKVM is the network driver; without it the guest
# has no network.
function Install-VirtIOArm64Drivers {
$roots = (Get-PSDrive -PSProvider FileSystem).Root
$drivers = @(
@{ Folder = 'NetKVM'; Inf = 'netkvm.inf' },
@{ Folder = 'vioserial'; Inf = 'vioser.inf' },
@{ Folder = 'Balloon'; Inf = 'balloon.inf' },
@{ Folder = 'viorng'; Inf = 'viorng.inf' },
@{ Folder = 'vioinput'; Inf = 'vioinput.inf' },
@{ Folder = 'viogpudo'; Inf = 'viogpudo.inf' }
)
$installed = 0
foreach ($driver in $drivers) {
$inf = $roots |
ForEach-Object { Join-Path $_ "$($driver.Folder)\w11\ARM64\$($driver.Inf)" } |
Where-Object { Test-Path -LiteralPath $_ } |
Select-Object -First 1
if (-not $inf) {
if ($driver.Folder -eq 'NetKVM') {
throw 'The Arm64 NetKVM driver was not found on the attached media.'
}
continue
}
& pnputil.exe /add-driver $inf /install | Out-Host
# 3010 asks for a restart; 259 means that no device uses the driver.
if ($LASTEXITCODE -notin 0, 259, 3010) {
throw "pnputil failed to install $inf (exit code $LASTEXITCODE)."
}
$installed++
}
Write-Host "Installed $installed VirtIO Arm64 driver packages."
}
function Install-VirtIOGuestTools {
if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') {
Install-VirtIOArm64Drivers
return
}
$installer = Get-ChildItem `
-Path (Get-PSDrive -PSProvider FileSystem).Root `
-Filter 'virtio-win-guest-tools.exe' `
-File `
-ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $installer) {
throw 'virtio-win-guest-tools.exe was not found on the attached media.'
}
$process = Start-Process `
-FilePath $installer.FullName `
-ArgumentList '/quiet', '/norestart' `
-Wait `
-PassThru
if ($process.ExitCode -notin 0, 3010) {
throw "VirtIO guest tools installer exited with code $($process.ExitCode)."
}
}
function Enable-AutomaticLogon { function Enable-AutomaticLogon {
$winlogonPath = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' $winlogonPath = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
@@ -144,12 +77,14 @@ function Disable-AutomaticUpdate {
} }
function Copy-InstallerScripts { function Copy-InstallerScripts {
$scripts = @(Get-ChildItem -Path $PSScriptRoot -Filter '*-Install-*.bat' -File) # Numbered scripts (NN-*.bat / NN-*.ps1: installers and tools) go to Desktop\Tools.
$scripts = @(Get-ChildItem -Path $PSScriptRoot -File |
Where-Object { $_.Name -match '^\d\d-.*\.(bat|ps1)$' })
if ($scripts.Count -eq 0) { if ($scripts.Count -eq 0) {
throw "No installer batch files were found next to $PSCommandPath." throw "No installer batch files were found next to $PSCommandPath."
} }
$target = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Installers' $target = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Tools'
New-Item -ItemType Directory -Path $target -Force | Out-Null New-Item -ItemType Directory -Path $target -Force | Out-Null
Copy-Item -Path $scripts.FullName -Destination $target -Force Copy-Item -Path $scripts.FullName -Destination $target -Force
} }
@@ -203,9 +138,6 @@ function Install-OpenSSHServer {
$exitCode = 0 $exitCode = 0
try { try {
if ($InstallVirtIO) {
Install-VirtIOGuestTools
}
if ($EnableAutomaticLogon) { if ($EnableAutomaticLogon) {
Enable-AutomaticLogon Enable-AutomaticLogon
} }
+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<ComputerName>WIN11</ComputerName>
<TimeZone>China Standard Time</TimeZone>
</component>
<component name="Microsoft-Windows-TerminalServices-LocalSessionManager"
processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral"
versionScope="nonSxS">
<fDenyTSConnections>false</fDenyTSConnections>
</component>
<component name="Networking-MPSSVC-Svc" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<FirewallGroups>
<FirewallGroup xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
wcm:action="add" wcm:keyValue="RemoteDesktop">
<Active>true</Active>
<Group>Remote Desktop</Group>
<Profile>all</Profile>
</FirewallGroup>
</FirewallGroups>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core" processorArchitecture="amd64"
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="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<ProtectYourPC>3</ProtectYourPC>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>guofang</Name>
<Group>Administrators</Group>
<DisplayName>Guofang</DisplayName>
<Password>
<Value>wang</Value>
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Enabled>true</Enabled>
<LogonCount>1</LogonCount>
<Username>guofang</Username>
<Password>
<Value>wang</Value>
<PlainText>true</PlainText>
</Password>
</AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>Configure Windows from the answer-media script</Description>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "&amp; ('{0}:\Configure-Windows.ps1' -f (Get-Volume -FileSystemLabel CIDATA).DriveLetter) -EnableAutomaticLogon -DisableAutoUpdate -CopyInstallers -InstallOpenSSH -NoPause"</CommandLine>
<RequiresUserInput>false</RequiresUserInput>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>
+5 -4
View File
@@ -32,10 +32,10 @@ fi
work_dir="$(mktemp -d)" work_dir="$(mktemp -d)"
trap 'rm -rf "$work_dir"' EXIT trap 'rm -rf "$work_dir"' EXIT
sed "s/processorArchitecture=\"amd64\"/processorArchitecture=\"${answer_arch}\"/" \ sed "s/processorArchitecture=\"amd64\"/processorArchitecture=\"${answer_arch}\"/" \
"$script_dir/Autounattend.xml" >"$work_dir/Autounattend.xml" "$script_dir/Unattend.xml" >"$work_dir/Unattend.xml"
seed_files=( seed_files=(
"$work_dir/Autounattend.xml" "$work_dir/Unattend.xml"
"$script_dir/Configure-Windows.ps1" "$script_dir/Configure-Windows.ps1"
"$openssh_zip" "$openssh_zip"
) )
@@ -43,9 +43,10 @@ if [[ -n "$ssh_key" ]]; then
cp -- "$ssh_key" "$work_dir/authorized_keys" cp -- "$ssh_key" "$work_dir/authorized_keys"
seed_files+=("$work_dir/authorized_keys") seed_files+=("$work_dir/authorized_keys")
fi fi
# Numbered installer scripts (10-Install-Git-Admin.bat, 20-..., in install order). # Numbered scripts: installers (10-Install-Git-Admin.bat, 20-..., in install order)
# and tools (80-Get-UpdateStatus-Admin.bat/.ps1); all land in Desktop\Tools.
shopt -s nullglob shopt -s nullglob
seed_files+=("$script_dir"/[0-9][0-9]-*.bat) seed_files+=("$script_dir"/[0-9][0-9]-*.bat "$script_dir"/[0-9][0-9]-*.ps1)
shopt -u nullglob shopt -u nullglob
mkdir -p "$(dirname -- "$output")" mkdir -p "$(dirname -- "$output")"
+96
View File
@@ -0,0 +1,96 @@
"""Host port forwards for the QEMU user-mode network, read from a .forwards file.
Each line of the file is "LABEL HOST_PORT GUEST_PORT"; "#" starts a comment.
A QEMU config file section cannot hold more than one hostfwd entry, so the
launchers build the -netdev argument from these rules instead.
"""
import socket
import sys
from collections import namedtuple
Forward = namedtuple("Forward", "label host_port guest_port")
def _die(message):
print(f"Error: {message}", file=sys.stderr)
sys.exit(1)
def _port(text, where):
if not (text.isascii() and text.isdigit() and 1 <= int(text) <= 65535):
_die(f"{where}: not a port number from 1 to 65535: {text}")
return int(text)
def port_in_use(port):
"""True if a host TCP port cannot be bound, as QEMU does for a hostfwd rule."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(("", int(port)))
except OSError:
return True
return False
def load_forwards(path, host_port_overrides=None):
"""Read the rules in a .forwards file.
host_port_overrides maps a label to a host port that replaces the one in
the file, e.g. {"SSH": "2200"}.
"""
if not path.is_file():
_die(f"port forward file was not found: {path}")
forwards = []
for number, line in enumerate(path.read_text().splitlines(), 1):
fields = line.partition("#")[0].split()
if not fields:
continue
where = f"{path}:{number}"
if len(fields) != 3:
_die(f"{where}: expected LABEL HOST_PORT GUEST_PORT: {line.strip()}")
label, host_port, guest_port = fields
if any(forward.label == label for forward in forwards):
_die(f"{where}: duplicate label: {label}")
forwards.append(Forward(label, _port(host_port, where), _port(guest_port, where)))
for label, host_port in (host_port_overrides or {}).items():
if not any(forward.label == label for forward in forwards):
_die(f"{path} has no {label} rule to override the port of")
forwards = [forward._replace(host_port=_port(str(host_port), f"{label} port"))
if forward.label == label else forward for forward in forwards]
seen = set()
for forward in forwards:
if forward.host_port in seen:
_die(f"{path}: host port {forward.host_port} is used by more than one rule")
seen.add(forward.host_port)
return forwards
def usable_forwards(forwards):
"""Drop (with a warning) the rules whose host port is taken.
QEMU refuses to start when a forwarded host port is taken.
"""
usable = []
for forward in forwards:
if port_in_use(forward.host_port):
print(f"Warning: host port {forward.host_port} is already in use; "
f"{forward.label} will not be forwarded to the guest.", file=sys.stderr)
else:
usable.append(forward)
return usable
def netdev_argument(forwards, netdev_id="net0"):
"""The value of -netdev for a user-mode network with the given rules."""
return ",".join([f"user,id={netdev_id}", *(
f"hostfwd=tcp::{forward.host_port}-:{forward.guest_port}" for forward in forwards)])
def print_forwards(forwards):
for forward in forwards:
print(f"{forward.label}: localhost:{forward.host_port} -> guest port "
f"{forward.guest_port}", flush=True)
if forward.guest_port == 22:
print(f" ssh -p {forward.host_port} guofang@localhost", flush=True)
+3 -3
View File
@@ -6,7 +6,7 @@
## 结论 ## 结论
- **根因(第五节)**:HVF 的内核态 GICv3 与 QEMU 的 `usb-storage` 有竞态,Windows Setup 每读一批数据就卡约 10 秒。 - **根因(第五节)**:HVF 的内核态 GICv3 与 QEMU 的 `usb-storage` 有竞态,Windows Setup 每读一批数据就卡约 10 秒。
在 `[machine]` 里加 `kernel-irqchip = "off"`(已写入 `qemu/windows11-darwin-arm64.ini`)后, 由 `run-windows11.py` 加 `-machine kernel-irqchip=off`(`--install-from-img` 时不加)后,
**同样走 USB 光驱的 `--install-from-iso` 也变快了**,和 UTM 的速度一致。 **同样走 USB 光驱的 `--install-from-iso` 也变快了**,和 UTM 的速度一致。
- 下面第一到五节是更早的分析:当时把慢归因于"模拟 USB 光驱吞吐低"(平均约 4 MB/s), - 下面第一到五节是更早的分析:当时把慢归因于"模拟 USB 光驱吞吐低"(平均约 4 MB/s),
这个归因不准确,真实原因是上面的 10 秒停顿;那时测到的低吞吐就是停顿拉低的平均值。 这个归因不准确,真实原因是上面的 10 秒停顿;那时测到的低吞吐就是停顿拉低的平均值。
@@ -20,7 +20,7 @@
|---|---| |---|---|
| 主机 | Apple M5 Max,arm64,macOS 26.4,18 核,48 GB 内存 | | 主机 | Apple M5 Max,arm64,macOS 26.4,18 核,48 GB 内存 |
| QEMU | 11.1.2,`qemu-system-aarch64`,加速器 HVF | | QEMU | 11.1.2,`qemu-system-aarch64`,加速器 HVF |
| 客户机配置 | `qemu/windows11.ini` + `qemu/windows11-darwin-arm64.ini`(`virt` 机型,8 vCPU,8G 内存,NVMe 系统盘,virtio-net,ramfb,usb-kbd/usb-tablet) | | 客户机配置 | `qemu/windows11/config.ini` + `qemu/windows11/darwin-arm64.ini`(`virt` 机型,8 vCPU,8G 内存,NVMe 系统盘,virtio-net,ramfb,usb-kbd/usb-tablet) |
| 安装 ISO | `Windows11_Client_arm64_en-us_26300_9457.iso`(卷标 `CCCOMA_A64FRE_EN-US_DV9`,`install.wim` 7.6 GB,`boot.wim` 630 MB) | | 安装 ISO | `Windows11_Client_arm64_en-us_26300_9457.iso`(卷标 `CCCOMA_A64FRE_EN-US_DV9`,`install.wim` 7.6 GB,`boot.wim` 630 MB) |
| 驱动 ISO | `virtio-win.iso`(含 ARM64 驱动目录,根目录只有 x64/x86 的 MSI) | | 驱动 ISO | `virtio-win.iso`(含 ARM64 驱动目录,根目录只有 x64/x86 的 MSI) |
@@ -200,7 +200,7 @@ UTM 5.0.8 带有这个修复;Homebrew 的 QEMU 11.1.2 没有,所以上游版
### 处理 ### 处理
在 `qemu/windows11-darwin-arm64.ini` 的 `[machine]` 加 `kernel-irqchip = "off"`,改用模拟的中断控制器。 在 `qemu/windows11/darwin-arm64.ini` 的 `[machine]` 加 `kernel-irqchip = "off"`,改用模拟的中断控制器。
这一项改完后速度明显提升,已确认和 UTM 一致。 这一项改完后速度明显提升,已确认和 UTM 一致。
```ini ```ini