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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8ZGHSJ5JogcTAYhPNxXTV
This commit is contained in:
1 parent
a0cd640ffe
commit
b5a2061276
4 files changed
+1085
No files matched your search
Executable
+274
@@ -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])
|
||||||
Executable
+273
@@ -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])
|
||||||
Executable
+274
@@ -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])
|
||||||
Executable
+264
@@ -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])
|
||||||
Reference in new issue
Block a user