From b5a2061276152b55974688c1c616555afae83990 Mon Sep 17 00:00:00 2001 From: Guofang Date: Tue, 29 Sep 2026 12:36:38 +0800 Subject: [PATCH] Add Python ports of the VM launchers Each run-*.py mirrors its run-*.sh counterpart: same options, environment variables, messages and QEMU command line. Unlike the shell scripts, the Python ports use a portable /usr/bin/env shebang. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S8ZGHSJ5JogcTAYhPNxXTV --- run-almalinux10.py | 274 +++++++++++++++++++++++++++++++++++++++++++++ run-debian13.py | 273 ++++++++++++++++++++++++++++++++++++++++++++ run-fedora44.py | 274 +++++++++++++++++++++++++++++++++++++++++++++ run-windows11.py | 264 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1085 insertions(+) create mode 100755 run-almalinux10.py create mode 100755 run-debian13.py create mode 100755 run-fedora44.py create mode 100755 run-windows11.py diff --git a/run-almalinux10.py b/run-almalinux10.py new file mode 100755 index 0000000..275f949 --- /dev/null +++ b/run-almalinux10.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""Start the AlmaLinux 10 virtual machine (Python port of run-almalinux10.sh).""" + +import os +import platform +import shutil +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("ALMA_VM_BASE_IMAGE"): + base_image = Path(env("ALMA_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", "almalinux10"], + 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("ALMA_VM_STATE_DIR") or script_dir / ".vm" / "almalinux10") +disk_image = Path(env("ALMA_VM_DISK_IMAGE") or state_dir / "disk.qcow2") +seed_image = Path(env("ALMA_VM_SEED_IMAGE") or disk_image.parent / "seed.iso") +disk_size = env("ALMA_VM_DISK_SIZE") or "20G" +memory = env("ALMA_VM_MEMORY") or "8G" +cpus = env("ALMA_VM_CPUS") or "8" +ssh_port = env("ALMA_VM_SSH_PORT") or "2223" + + +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) +if host_arch == "arm64": + qemu_binary = "qemu-system-aarch64" + machine_type = "virt" +else: + qemu_binary = "qemu-system-x86_64" + machine_type = "q35" + + +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("ALMA_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("ALMA_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("ALMA_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("ALMA_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", + ) +uefi_vars = state_dir / "OVMF_VARS_4M.fd" +pid_file = state_dir / "qemu.pid" +log_file = state_dir / "console.log" +daemonize = False +extra_args = [] + + +def usage(stream=sys.stdout): + stream.write(f"""\ +Usage: {Path(sys.argv[0]).name} [--daemon] [-- QEMU_ARGUMENT ...] + +Start an AlmaLinux 10 Generic Cloud virtual machine. The launcher obtains a +missing base image through ../vm-images/download-images.sh and creates a +writable overlay under .vm/almalinux10/, leaving the base image unchanged. + +Options: + --daemon Run in the background while keeping the graphical window + -h, --help Show this help + +Environment variables: + ALMA_VM_MEMORY Guest memory (default: 8G) + ALMA_VM_CPUS Virtual CPU count (default: 8) + ALMA_VM_SSH_PORT Host port forwarded to guest SSH (default: 2223) + 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_STATE_DIR Runtime state directory (default: .vm/almalinux10) + ALMA_VM_DISK_IMAGE Writable overlay path + ALMA_VM_DISK_SIZE New overlay virtual capacity (default: 20G) + VM_IMAGE_ARCH Guest/host architecture: arm64 or amd64 (default: auto-detected from the host) + ALMA_VM_UEFI_CODE UEFI read-only firmware path (auto-detected for the selected architecture) + ALMA_VM_UEFI_VARS UEFI variable-store template path (auto-detected for the selected architecture) + ALMA_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 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) + +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), "almalinux10"]) + else: + die(f"AlmaLinux base image was not found: {base_image}") +if not base_image.is_file(): + die(f"downloader did not create: {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}") + +if not seed_image.is_file(): + seed_builder = script_dir / "seediso" / "almalinux10" / "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 AlmaLinux 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("ALMA_VM_DISPLAY") or ("cocoa" if is_macos else "gtk") + +qemu_args = [ + "-name", "almalinux10", + "-machine", machine_type, + "-display", display_backend, + "-m", memory, + "-smp", cpus, + "-drive", f"if=pflash,format=raw,readonly=on,file={uefi_code_flash}", + "-drive", f"if=pflash,format=raw,file={uefi_vars}", + "-drive", f"file={disk_image},if=none,id=disk0,format=qcow2", + "-device", "virtio-blk-pci,drive=disk0", + "-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on", + "-device", "virtio-blk-pci,drive=seed0", + "-nic", f"user,model=virtio-net-pci,hostfwd=tcp::{ssh_port}-:22", +] + +if machine_type == "virt": + # Unlike q35, the "virt" machine has no default GPU or USB input devices. + qemu_args += [ + "-device", "virtio-gpu-pci", + "-device", "qemu-xhci", + "-device", "usb-kbd", + "-device", "usb-tablet", + ] + +if is_macos and host_arch == native_arch: + qemu_args += ["-accel", "hvf", "-cpu", "host"] + accelerator = "HVF" +elif host_arch == native_arch and os.access("/dev/kvm", os.R_OK | os.W_OK): + qemu_args += ["-enable-kvm", "-cpu", "host"] + accelerator = "KVM" +else: + qemu_args += ["-accel", "tcg", "-cpu", "max"] + accelerator = "TCG" + +print(f"Starting AlmaLinux 10 ({host_arch}) with {accelerator} acceleration; " + f"SSH is forwarded to localhost:{ssh_port}.", 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]) diff --git a/run-debian13.py b/run-debian13.py new file mode 100755 index 0000000..017b5f3 --- /dev/null +++ b/run-debian13.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Start the Debian 13 virtual machine (Python port of run-debian13.sh).""" + +import os +import platform +import shutil +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("VM_BASE_IMAGE"): + base_image = Path(env("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", "debian13"], + 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("VM_STATE_DIR") or script_dir / ".vm" / "debian13") +disk_image = Path(env("VM_DISK_IMAGE") or state_dir / "disk.qcow2") +seed_image = Path(env("VM_SEED_IMAGE") or disk_image.parent / "seed.iso") +disk_size = env("VM_DISK_SIZE") or "20G" +memory = env("VM_MEMORY") or "8G" +cpus = env("VM_CPUS") or "8" +ssh_port = env("VM_SSH_PORT") or "2222" + + +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) +if host_arch == "arm64": + qemu_binary = "qemu-system-aarch64" + machine_type = "virt" +else: + qemu_binary = "qemu-system-x86_64" + machine_type = "q35" + + +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("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("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("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("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", + ) +uefi_vars = state_dir / "OVMF_VARS_4M.fd" +pid_file = state_dir / "qemu.pid" +log_file = state_dir / "console.log" +daemonize = False +extra_args = [] + + +def usage(stream=sys.stdout): + stream.write(f"""\ +Usage: {Path(sys.argv[0]).name} [--daemon] [-- QEMU_ARGUMENT ...] + +Start the Debian 13 virtual machine. A writable overlay is created under +.vm/debian13/, so the downloaded base image is never modified. + +Options: + --daemon Run in the background while keeping the graphical window + -h, --help Show this help + +Environment variables: + VM_MEMORY Guest memory (default: 8G) + VM_CPUS Virtual CPU count (default: 8) + VM_SSH_PORT Host port forwarded to guest SSH (default: 2222) + VM_BASE_IMAGE Base qcow2 image path (default: resolved by shared image helper) + VM_SEED_IMAGE cloud-init seed ISO path (default: alongside the VM disk) + VM_STATE_DIR Runtime state directory (default: .vm/debian13) + VM_DISK_IMAGE Writable overlay path + VM_DISK_SIZE New overlay virtual capacity (default: 20G) + VM_IMAGE_ARCH Guest/host architecture: arm64 or amd64 (default: auto-detected from the host) + VM_UEFI_CODE UEFI read-only firmware 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) +""") + + +args = sys.argv[1:] +while args: + arg = args.pop(0) + if arg == "--daemon": + daemonize = True + 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) + +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), "debian13"]) + else: + die(f"Debian base image was not found: {base_image}") +if not base_image.is_file(): + die(f"downloader did not create: {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}") + +if not seed_image.is_file(): + seed_builder = script_dir / "seediso" / "debian13" / "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 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("VM_DISPLAY") or ("cocoa" if is_macos else "gtk") + +qemu_args = [ + "-name", "debian13", + "-machine", machine_type, + "-display", display_backend, + "-m", memory, + "-smp", cpus, + "-drive", f"if=pflash,format=raw,readonly=on,file={uefi_code_flash}", + "-drive", f"if=pflash,format=raw,file={uefi_vars}", + "-drive", f"file={disk_image},if=none,id=disk0,format=qcow2", + "-device", "virtio-blk-pci,drive=disk0", + "-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on", + "-device", "virtio-blk-pci,drive=seed0", + "-nic", f"user,model=virtio-net-pci,hostfwd=tcp::{ssh_port}-:22", +] + +if machine_type == "virt": + # Unlike q35, the "virt" machine has no default GPU or USB input devices. + qemu_args += [ + "-device", "virtio-gpu-pci", + "-device", "qemu-xhci", + "-device", "usb-kbd", + "-device", "usb-tablet", + ] + +if is_macos and host_arch == native_arch: + qemu_args += ["-accel", "hvf", "-cpu", "host"] + accelerator = "HVF" +elif host_arch == native_arch and os.access("/dev/kvm", os.R_OK | os.W_OK): + qemu_args += ["-enable-kvm", "-cpu", "host"] + accelerator = "KVM" +else: + qemu_args += ["-accel", "tcg", "-cpu", "max"] + accelerator = "TCG" + +print(f"Starting {host_arch} VM with {accelerator} acceleration; " + f"SSH is forwarded to localhost:{ssh_port}.", 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]) diff --git a/run-fedora44.py b/run-fedora44.py new file mode 100755 index 0000000..0cf4a7c --- /dev/null +++ b/run-fedora44.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""Start the Fedora 44 virtual machine (Python port of run-fedora44.sh).""" + +import os +import platform +import shutil +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("FEDORA_VM_BASE_IMAGE"): + base_image = Path(env("FEDORA_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", "fedora44"], + 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("FEDORA_VM_STATE_DIR") or script_dir / ".vm" / "fedora44") +disk_image = Path(env("FEDORA_VM_DISK_IMAGE") or state_dir / "disk.qcow2") +seed_image = Path(env("FEDORA_VM_SEED_IMAGE") or disk_image.parent / "seed.iso") +disk_size = env("FEDORA_VM_DISK_SIZE") or "20G" +memory = env("FEDORA_VM_MEMORY") or "8G" +cpus = env("FEDORA_VM_CPUS") or "8" +ssh_port = env("FEDORA_VM_SSH_PORT") or "2224" + + +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) +if host_arch == "arm64": + qemu_binary = "qemu-system-aarch64" + machine_type = "virt" +else: + qemu_binary = "qemu-system-x86_64" + machine_type = "q35" + + +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("FEDORA_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("FEDORA_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("FEDORA_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("FEDORA_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", + ) +uefi_vars = state_dir / "OVMF_VARS_4M.fd" +pid_file = state_dir / "qemu.pid" +log_file = state_dir / "console.log" +daemonize = False +extra_args = [] + + +def usage(stream=sys.stdout): + stream.write(f"""\ +Usage: {Path(sys.argv[0]).name} [--daemon] [-- QEMU_ARGUMENT ...] + +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 +under .vm/fedora44/, leaving the base image unchanged. + +Options: + --daemon Run in the background while keeping the graphical window + -h, --help Show this help + +Environment variables: + FEDORA_VM_MEMORY Guest memory (default: 8G) + FEDORA_VM_CPUS Virtual CPU count (default: 8) + FEDORA_VM_SSH_PORT Host port forwarded to guest SSH (default: 2224) + FEDORA_VM_BASE_IMAGE Base qcow2 image path (default: resolved by shared image helper) + FEDORA_VM_SEED_IMAGE cloud-init seed ISO path (default: alongside the VM disk) + FEDORA_VM_STATE_DIR Runtime state directory (default: .vm/fedora44) + FEDORA_VM_DISK_IMAGE Writable overlay path + FEDORA_VM_DISK_SIZE New overlay virtual capacity (default: 20G) + VM_IMAGE_ARCH Guest/host architecture: arm64 or amd64 (default: auto-detected from the host) + FEDORA_VM_UEFI_CODE UEFI read-only firmware 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) +""") + + +args = sys.argv[1:] +while args: + arg = args.pop(0) + if arg == "--daemon": + daemonize = True + 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) + +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), "fedora44"]) + else: + die(f"Fedora base image was not found: {base_image}") +if not base_image.is_file(): + die(f"downloader did not create: {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}") + +if not seed_image.is_file(): + seed_builder = script_dir / "seediso" / "fedora44" / "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 Fedora 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("FEDORA_VM_DISPLAY") or ("cocoa" if is_macos else "gtk") + +qemu_args = [ + "-name", "fedora44", + "-machine", machine_type, + "-display", display_backend, + "-m", memory, + "-smp", cpus, + "-drive", f"if=pflash,format=raw,readonly=on,file={uefi_code_flash}", + "-drive", f"if=pflash,format=raw,file={uefi_vars}", + "-drive", f"file={disk_image},if=none,id=disk0,format=qcow2", + "-device", "virtio-blk-pci,drive=disk0", + "-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on", + "-device", "virtio-blk-pci,drive=seed0", + "-nic", f"user,model=virtio-net-pci,hostfwd=tcp::{ssh_port}-:22", +] + +if machine_type == "virt": + # Unlike q35, the "virt" machine has no default GPU or USB input devices. + qemu_args += [ + "-device", "virtio-gpu-pci", + "-device", "qemu-xhci", + "-device", "usb-kbd", + "-device", "usb-tablet", + ] + +if is_macos and host_arch == native_arch: + qemu_args += ["-accel", "hvf", "-cpu", "host"] + accelerator = "HVF" +elif host_arch == native_arch and os.access("/dev/kvm", os.R_OK | os.W_OK): + qemu_args += ["-enable-kvm", "-cpu", "host"] + accelerator = "KVM" +else: + qemu_args += ["-accel", "tcg", "-cpu", "max"] + accelerator = "TCG" + +print(f"Starting Fedora 44 ({host_arch}) with {accelerator} acceleration; " + f"SSH is forwarded to localhost:{ssh_port}.", 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]) diff --git a/run-windows11.py b/run-windows11.py new file mode 100755 index 0000000..0f429d2 --- /dev/null +++ b/run-windows11.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Start the Windows 11 virtual machine (Python port of run-windows11.sh).""" + +import os +import platform +import shutil +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 capture(command): + """Return a command's stdout without the trailing newline, like $(...).""" + result = subprocess.run(command, stdout=subprocess.PIPE, text=True) + if result.returncode != 0: + sys.exit(result.returncode) + return result.stdout.rstrip("\n") + + +def is_executable(path): + return path.is_file() and os.access(path, os.X_OK) + + +state_dir = Path(env("WIN11_VM_STATE_DIR") or script_dir / ".vm" / "windows11") +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") +disk_size = env("WIN11_VM_DISK_SIZE") or "64G" +memory = env("WIN11_VM_MEMORY") or "8G" +cpus = env("WIN11_VM_CPUS") or "8" +rdp_port = env("WIN11_VM_RDP_PORT") or "3389" +ssh_port = env("WIN11_VM_SSH_PORT") or "2222" + + +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]) + + +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", +) +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" +daemonize = False +install_from_iso = False +extra_args = [] + + +def usage(stream=sys.stdout): + stream.write(f"""\ +Usage: {Path(sys.argv[0]).name} [--daemon] [--install-from-iso] [-- QEMU_ARGUMENT ...] + +If {disk_image} is missing, create it from the Windows 11 QCOW2 base image. +Use --install-from-iso to perform an automatic ISO installation instead. +If it exists, start Windows normally without attaching installation media. + +Options: + --daemon Run QEMU in the background + --install-from-iso Install from ISO when the system disk is missing + -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 + WIN11_VM_TOOLS_ISO VirtIO Windows tools ISO (default: resolved by shared image helper) + WIN11_VM_MEMORY Guest memory (default: 8G) + WIN11_VM_CPUS Virtual CPU count (default: 8) + 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_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 == "--install-from-iso": + install_from_iso = True + 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) + +installing = False +installing_from_iso = False +if not disk_image.is_file(): + installing = True + installing_from_iso = install_from_iso + 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" + if not is_executable(image_downloader): + die(f"shared image helper is unavailable: {image_downloader}") + if install_from_iso: + install_iso = Path(env("WIN11_VM_INSTALL_ISO") + or capture([str(image_downloader), "--print-path", "windows11", "--iso"])) + 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-system-x86_64") is None: + die("qemu-system-x86_64 is not installed.") +if installing: + if shutil.which("qemu-img") is None: + die("qemu-img is not installed.") + if installing_from_iso: + if not install_iso.is_file(): + 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) +if installing_from_iso: + if not tools_iso.is_file(): + if uses_shared_tools_iso: + run([str(image_downloader), "virtio-win"]) + else: + die(f"VirtIO Windows tools ISO was not found: {tools_iso}") + if not tools_iso.is_file(): + die(f"downloader did not create: {tools_iso}") + + seed_sources = [script_dir / "seediso" / "windows11" / name + for name in ("Autounattend.xml", "Configure-Windows.ps1")] + + def newer_than_seed(source): + # Mirrors bash `-nt`: true if source exists and is newer than seed_iso. + return source.exists() and source.stat().st_mtime > seed_iso.stat().st_mtime + + if not seed_iso.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)]) + +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"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}") + +display_backend = env("WIN11_VM_DISPLAY") or ( + "cocoa" if platform.system() == "Darwin" else "gtk") + +qemu_args = [ + "-name", "windows11", "-machine", "q35", "-m", memory, "-smp", cpus, + "-display", display_backend, + # "-display", "none", "-spice", "port=5900,addr=127.0.0.1,disable-ticketing=on,agent-mouse=off", "-device", "virtio-vga", + "-drive", f"if=pflash,format=raw,readonly=on,file={uefi_code}", + "-drive", f"if=pflash,format=raw,file={uefi_vars}", + "-drive", f"file={disk_image},if=none,id=system_disk,format=qcow2", + "-device", "ide-hd,drive=system_disk,bus=ide.0,bootindex=1", + "-nic", f"user,model=e1000e,hostfwd=tcp::{rdp_port}-:3389,hostfwd=tcp::{ssh_port}-:22", + "-device", "virtio-serial-pci,id=virtio_serial", + "-chardev", "qemu-vdagent,id=vdagent,name=vdagent,clipboard=on,mouse=off", + "-device", "virtserialport,chardev=vdagent,name=com.redhat.spice.0", + # Register the USB tablet after vdagent so QEMU selects it as the active pointer. + "-device", "qemu-xhci,id=usb_controller", "-device", "usb-tablet,bus=usb_controller.0", + "-chardev", f"socket,path={guest_agent_socket},server=on,wait=off,id=qga", + "-device", "virtserialport,chardev=qga,name=org.qemu.guest_agent.0", + "-monitor", f"unix:{monitor_socket},server=on,wait=off", +] +if installing_from_iso: + qemu_args += [ + "-drive", f"file={install_iso},if=none,id=install_media,media=cdrom,readonly=on", + "-device", "ide-cd,drive=install_media,bus=ide.1,bootindex=2", + "-drive", f"file={seed_iso},if=none,id=answer_media,media=cdrom,readonly=on", + "-device", "ide-cd,drive=answer_media,bus=ide.2,bootindex=3", + "-drive", f"file={tools_iso},if=none,id=tools_media,media=cdrom,readonly=on", + "-device", "ide-cd,drive=tools_media,bus=ide.3,bootindex=4", + ] +if os.access("/dev/kvm", os.R_OK | os.W_OK): + qemu_args += ["-enable-kvm", "-cpu", "host"] + accelerator = "KVM" +else: + qemu_args += ["-accel", "tcg", "-cpu", "max"] + accelerator = "TCG" +if installing_from_iso: + print(f"Starting Windows 11 automatic installation with {accelerator} acceleration.") +elif installing: + print(f"Starting Windows 11 from the QCOW2 base image with {accelerator} acceleration.") +else: + print(f"Starting Windows 11 normally with {accelerator} acceleration.") +print(f"Account: guofang / password; RDP: localhost:{rdp_port}; SSH: localhost:{ssh_port}", + flush=True) + +if daemonize: + log_file.write_text("") + qemu_args += ["-serial", f"file:{log_file}", "-daemonize", "-pidfile", str(pid_file)] + run(["qemu-system-x86_64", *qemu_args, *extra_args]) + print(f"VM started (PID {pid_file.read_text().strip()}); console log: {log_file}") +else: + os.execvp("qemu-system-x86_64", ["qemu-system-x86_64", *qemu_args, *extra_args])