From 204051f93057c96575f1859c4f7324f1d99830a7 Mon Sep 17 00:00:00 2001 From: Guofang Date: Wed, 30 Sep 2026 10:56:27 +0800 Subject: [PATCH] Add --download-only to the VM launchers The option downloads the media a launcher would fetch automatically and exits without checking for QEMU, creating a VM disk or starting the VM: the base image for Debian, AlmaLinux and Fedora, and the VirtIO tools ISO for Windows 11. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YKxXLyiL7XJ3a9e8HmR2my --- README.md | 9 +++++++++ run-almalinux10.py | 16 ++++++++++++---- run-debian13.py | 16 ++++++++++++---- run-fedora44.py | 16 ++++++++++++---- run-windows11.py | 44 +++++++++++++++++++++++++++++++++++--------- 5 files changed, 80 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index ce932ef..4d35ed3 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,15 @@ corresponding `*_STATE_DIR` environment variable: ./run-debian13.py --state-dir /srv/vms/debian13-test --daemon ``` +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 +base image for Debian, AlmaLinux and Fedora, and the VirtIO tools ISO for +Windows 11 (whose installation ISO and base QCOW2 are supplied manually): + +```bash +./run-fedora44.py --download-only +``` + If `.vm/debian13/seed.iso` is missing, the launcher automatically builds it from `seediso/debian13/user-data`, `seediso/debian13/meta-data`, and `seediso/debian13/network-config` before starting the VM. diff --git a/run-almalinux10.py b/run-almalinux10.py index 0b90477..ce0d75f 100755 --- a/run-almalinux10.py +++ b/run-almalinux10.py @@ -116,12 +116,13 @@ else: "/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] [--state-dir DIR] [-- QEMU_ARGUMENT ...] +Usage: {Path(sys.argv[0]).name} [--daemon] [--download-only] [--state-dir DIR] [-- 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 @@ -129,6 +130,7 @@ writable overlay in the state directory, leaving the base image unchanged. 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/almalinux10; overrides ALMA_VM_STATE_DIR) -h, --help Show this help @@ -154,6 +156,8 @@ 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) @@ -177,9 +181,10 @@ uefi_vars = state_dir / "OVMF_VARS_4M.fd" pid_file = state_dir / "qemu.pid" log_file = state_dir / "console.log" -for command_name in (qemu_binary, "qemu-img"): - if shutil.which(command_name) is None: - die(f"{command_name} is not installed.") +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: @@ -188,6 +193,9 @@ if not base_image.is_file(): die(f"AlmaLinux 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}") diff --git a/run-debian13.py b/run-debian13.py index a190757..85d7ed1 100755 --- a/run-debian13.py +++ b/run-debian13.py @@ -111,12 +111,13 @@ else: "/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] [--state-dir DIR] [-- QEMU_ARGUMENT ...] +Usage: {Path(sys.argv[0]).name} [--daemon] [--download-only] [--state-dir DIR] [-- QEMU_ARGUMENT ...] 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 @@ -127,6 +128,7 @@ 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/debian13; overrides VM_STATE_DIR) -h, --help Show this help @@ -151,6 +153,8 @@ 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) @@ -175,9 +179,10 @@ pid_file = state_dir / "qemu.pid" log_file = state_dir / "console.log" qemu_config = state_dir / "qemu.ini" -for command_name in (qemu_binary, "qemu-img"): - if shutil.which(command_name) is None: - die(f"{command_name} is not installed.") +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: @@ -186,6 +191,9 @@ if not base_image.is_file(): die(f"Debian 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}") diff --git a/run-fedora44.py b/run-fedora44.py index 1dced1f..cd5049f 100755 --- a/run-fedora44.py +++ b/run-fedora44.py @@ -112,12 +112,13 @@ else: "/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] [--state-dir DIR] [-- QEMU_ARGUMENT ...] +Usage: {Path(sys.argv[0]).name} [--daemon] [--download-only] [--state-dir DIR] [-- 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 @@ -129,6 +130,7 @@ followed by qemu/fedora44-.ini. Later QEMU arguments override them, e.g. 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/fedora44; overrides FEDORA_VM_STATE_DIR) -h, --help Show this help @@ -153,6 +155,8 @@ 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) @@ -177,9 +181,10 @@ pid_file = state_dir / "qemu.pid" log_file = state_dir / "console.log" qemu_config = state_dir / "qemu.ini" -for command_name in (qemu_binary, "qemu-img"): - if shutil.which(command_name) is None: - die(f"{command_name} is not installed.") +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: @@ -188,6 +193,9 @@ if not base_image.is_file(): die(f"Fedora 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}") diff --git a/run-windows11.py b/run-windows11.py index 3dea4d4..81cd14f 100755 --- a/run-windows11.py +++ b/run-windows11.py @@ -36,6 +36,18 @@ def is_executable(path): return path.is_file() and os.access(path, os.X_OK) +def find_image_downloader(): + 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}") + 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" memory = env("WIN11_VM_MEMORY") or "8G" @@ -69,12 +81,13 @@ uefi_vars_template = pick_firmware( ) daemonize = False install_from_iso = False +download_only = False extra_args = [] def usage(stream=sys.stdout): stream.write(f"""\ -Usage: {Path(sys.argv[0]).name} [--daemon] [--install-from-iso] [--state-dir DIR] [-- QEMU_ARGUMENT ...] +Usage: {Path(sys.argv[0]).name} [--daemon] [--download-only] [--install-from-iso] [--state-dir DIR] [-- QEMU_ARGUMENT ...] If the system disk (DIR/disk.qcow2, see --state-dir) is missing, create it from the Windows 11 QCOW2 base image. @@ -83,6 +96,8 @@ If it exists, start Windows normally without attaching installation media. Options: --daemon Run QEMU in the background + --download-only Download the VirtIO tools ISO if it is missing, then exit + (the Windows ISO and base QCOW2 are supplied manually) --install-from-iso Install from ISO when the system disk is missing --state-dir DIR Directory for the VM disk, seed ISO and runtime files (default: .vm/windows11; overrides WIN11_VM_STATE_DIR) @@ -111,6 +126,8 @@ while args: arg = args.pop(0) if arg == "--daemon": daemonize = True + elif arg == "--download-only": + download_only = True elif arg == "--install-from-iso": install_from_iso = True elif arg == "--state-dir": @@ -130,6 +147,22 @@ while args: usage(sys.stderr) sys.exit(2) +if download_only: + # The VirtIO tools ISO is the only medium the launcher downloads itself. + if env("WIN11_VM_TOOLS_ISO"): + tools_iso = Path(env("WIN11_VM_TOOLS_ISO")) + if not tools_iso.is_file(): + die(f"VirtIO Windows tools ISO was not found: {tools_iso}") + else: + image_downloader = find_image_downloader() + 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}") + 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" @@ -143,14 +176,7 @@ 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}") + image_downloader = find_image_downloader() if install_from_iso: install_iso = Path(env("WIN11_VM_INSTALL_ISO") or capture([str(image_downloader), "--print-path", "windows11", "--iso"]))