Add Ubuntu 26.04 virtual machine

Add run-ubuntu2604.py with its QEMU config files and NoCloud seed. The
launcher follows the Debian 13 one, with UBUNTU_VM_ settings and SSH
forwarded to host port 2225. The seed injects the current user's SSH public
key, uses the USTC mirror (ubuntu-ports on arm64) and installs the GNOME
desktop on first boot. Update vm-images for the ubuntu2604 image target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kwxp2XWvKnuEBifnCbhyX5
This commit is contained in:
GuofangandClaude Opus 5.5 committed 2026-10-15 09:00:15 +08:00
1 parent 649399aaa1
commit 1691062554
13 files changed
+564 -4

No files matched your search

+32 -2
View File
@@ -6,6 +6,7 @@ Start the required operating system with its distribution-specific launcher:
./run-debian13.py ./run-debian13.py
./run-almalinux10.py ./run-almalinux10.py
./run-fedora44.py ./run-fedora44.py
./run-ubuntu2604.py
./run-windows11.py ./run-windows11.py
``` ```
@@ -46,8 +47,8 @@ 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 and Fedora, and the VirtIO tools ISO for base image for Debian, AlmaLinux, Fedora and Ubuntu, and the VirtIO tools ISO
Windows 11 (whose installation ISO and base QCOW2 are supplied manually): for Windows 11 (whose installation ISO and base QCOW2 are supplied manually):
```bash ```bash
./run-fedora44.py --download-only ./run-fedora44.py --download-only
@@ -126,6 +127,35 @@ architecture-specific devices in `qemu/fedora44-amd64.ini` and
`qemu/fedora44-arm64.ini`. Other Fedora settings use the `FEDORA_VM_` prefix. `qemu/fedora44-arm64.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
Start an Ubuntu 26.04 LTS (Resolute Raccoon) cloud image VM with:
```bash
./run-ubuntu2604.py
```
The launcher downloads the `ubuntu2604` image through the shared helper when
it is missing, creates `.vm/ubuntu2604/disk.qcow2`, and builds its NoCloud seed
from `seediso/ubuntu2604/`. Like the Debian seed, it injects the current user's
SSH public key and points APT at the USTC mirror (`ubuntu-ports` on arm64).
SSH is forwarded to host port `2225` by default:
```bash
ssh -p 2225 guofang@localhost
```
On the first boot, cloud-init installs `ubuntu-desktop-minimal` (GNOME) and
`ibus-table-wubi`, makes the graphical target the default and reboots the
guest. Keep the VM running until this initial installation and reboot have
completed.
The VM hardware (8G memory, 8 CPUs, drives, SSH port `2225`) is defined in
`qemu/ubuntu2604.ini`, with the machine type and architecture-specific devices
in `qemu/ubuntu2604-amd64.ini` and `qemu/ubuntu2604-arm64.ini`. Other Ubuntu
settings use the `UBUNTU_VM_` prefix. Run `./run-ubuntu2604.py --help` for the
complete list of overrides.
## Windows 11 23H2 ## Windows 11 23H2
On first boot, the Windows 11 launcher creates a writable system-disk overlay On first boot, the Windows 11 launcher creates a writable system-disk overlay
+4
View File
@@ -0,0 +1,4 @@
# amd64 part of the Ubuntu 26.04 QEMU configuration; read after ubuntu2604.ini.
[machine]
type = "q35"
+17
View File
@@ -0,0 +1,17 @@
# arm64 part of the Ubuntu 26.04 QEMU configuration; read after ubuntu2604.ini.
[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"
+69
View File
@@ -0,0 +1,69 @@
# QEMU configuration for the Ubuntu 26.04 virtual machine, shared by all
# architectures. The machine type and architecture-specific devices are in
# ubuntu2604-<arch>.ini, which QEMU reads after this file.
#
# run-ubuntu2604.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 = "ubuntu2604"
# Accelerators are tried in order: KVM on Linux, HVF on macOS, else emulation.
[accel]
accel = "kvm"
[accel]
accel = "hvf"
[accel]
accel = "tcg"
[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"
# cloud-init seed ISO.
[drive "seed0"]
if = "none"
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"]
driver = "virtio-net-pci"
netdev = "net0"
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Start the Ubuntu 26.04 virtual machine."""
import os
import platform
import re
import shutil
import string
import subprocess
import sys
from pathlib import Path
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("UBUNTU_VM_BASE_IMAGE"):
base_image = Path(env("UBUNTU_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", "ubuntu2604"],
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("UBUNTU_VM_STATE_DIR") or script_dir / ".vm" / "ubuntu2604")
disk_size = env("UBUNTU_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("UBUNTU_VM_QEMU_CONFIG") or script_dir / "qemu" / "ubuntu2604.ini")
qemu_arch_config = script_dir / "qemu" / f"ubuntu2604-{host_arch}.ini"
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("UBUNTU_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("UBUNTU_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("UBUNTU_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("UBUNTU_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 an Ubuntu 26.04 LTS cloud image virtual machine. A writable overlay is
created in the state directory (default: .vm/ubuntu2604/), so the downloaded
base image is never modified. The VM hardware (memory, CPUs, SSH port forward,
...) is defined in qemu/ubuntu2604.ini, which is rendered to qemu.ini in the
state directory and loaded with QEMU's -readconfig, followed by
qemu/ubuntu2604-<arch>.ini. Later QEMU arguments override them, e.g.
`-- -m 4G -smp 4`.
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/ubuntu2604; overrides UBUNTU_VM_STATE_DIR)
-h, --help Show this help
Environment variables:
UBUNTU_VM_BASE_IMAGE Base qcow2 image path (default: resolved by shared image helper)
UBUNTU_VM_SEED_IMAGE cloud-init seed ISO path (default: alongside the VM disk)
UBUNTU_VM_STATE_DIR Runtime state directory (default: .vm/ubuntu2604)
UBUNTU_VM_DISK_IMAGE Writable overlay path
UBUNTU_VM_DISK_SIZE New overlay virtual capacity (default: 20G)
VM_IMAGE_ARCH Guest/host architecture: arm64 or amd64 (default: auto-detected from the host)
UBUNTU_VM_UEFI_CODE UEFI read-only firmware 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_QEMU_CONFIG QEMU config template rendered into the state directory and
passed to -readconfig (default: qemu/ubuntu2604.ini)
""")
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("UBUNTU_VM_DISK_IMAGE") or state_dir / "disk.qcow2")
seed_image = Path(env("UBUNTU_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), "ubuntu2604"])
else:
die(f"Ubuntu 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}")
if not seed_image.is_file():
seed_builder = script_dir / "seediso" / "ubuntu2604" / "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 Ubuntu virtual machine is already running (PID {old_pid}).")
pid_file.unlink(missing_ok=True)
if not disk_image.is_file():
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("UBUNTU_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,
"seed_image": seed_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_arch_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_args = [
"-readconfig", str(qemu_config),
"-readconfig", str(qemu_arch_config),
# "max" is the host CPU under KVM/HVF and the fullest model under TCG.
"-cpu", "max",
# The config file's [netdev] does not stop QEMU from adding its default
# NIC, a second user network without the SSH port forward.
"-nic", "none",
"-display", display_backend,
]
print(f"Starting Ubuntu 26.04 ({host_arch}) from {qemu_config_template} "
f"and {qemu_arch_config}.", flush=True)
# The SSH port forward lives in the config file, e.g. hostfwd = "tcp::2225-:22".
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:
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])
+2 -1
View File
@@ -7,7 +7,8 @@ Each operating system has an independent NoCloud configuration and generated
seediso/ seediso/
├── debian13/ ├── debian13/
├── almalinux10/ ├── almalinux10/
└── fedora44/ ├── fedora44/
└── ubuntu2604/
``` ```
Keeping per-OS metadata separate allows each VM to use a distinct hostname and Keeping per-OS metadata separate allows each VM to use a distinct hostname and
+2
View File
@@ -0,0 +1,2 @@
seed.iso
*.iso
+21
View File
@@ -0,0 +1,21 @@
# Ubuntu 26.04 seed.iso
This directory contains the cloud-init NoCloud configuration used by
`run-ubuntu2604.py`.
1. Edit `user-data`, `meta-data`, and (if needed) `network-config`.
2. Build the image:
```bash
./seediso/ubuntu2604/build-seed.sh
```
To choose another output path:
```bash
./seediso/ubuntu2604/build-seed.sh /path/to/seed.iso
```
The script uses `cloud-localds` when available, then falls back to `xorriso`,
`genisoimage`, or `mkisofs`. Attach the resulting ISO as a CD-ROM when booting
a cloud-init-enabled virtual machine.
+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: ubuntu2604-host-001
local-hostname: ubuntu2604-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
+46
View File
@@ -0,0 +1,46 @@
#cloud-config
preserve_hostname: false
manage_etc_hosts: true
ssh_pwauth: true
users:
- name: guofang
gecos: Guofang
groups: [sudo]
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
# amd64 packages are in the ubuntu archive, arm64 packages in ubuntu-ports.
apt:
primary:
- arches: [amd64, i386]
uri: "https://mirrors.ustc.edu.cn/ubuntu"
- arches: [default]
uri: "https://mirrors.ustc.edu.cn/ubuntu-ports"
security:
- arches: [amd64, i386]
uri: "https://mirrors.ustc.edu.cn/ubuntu"
- arches: [default]
uri: "https://mirrors.ustc.edu.cn/ubuntu-ports"
package_update: true
packages:
- ubuntu-desktop-minimal
- ibus-table-wubi
runcmd:
- systemctl set-default graphical.target
power_state:
delay: now
mode: reboot
message: "GNOME installation completed; rebooting into graphical mode."
condition: true