cloud-init reads the seed on the first boot only, so the Linux launchers now mount it just for the run that creates the overlay disk. The seed drive moves from the config files to the launcher arguments. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
313 lines
12 KiB
Python
Executable File
313 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Start the Fedora 44 virtual machine."""
|
|
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import string
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import vmforwards
|
|
|
|
script_dir = Path(__file__).resolve().parent
|
|
env = os.environ.get
|
|
|
|
|
|
def die(message, code=1):
|
|
print(f"Error: {message}", file=sys.stderr)
|
|
sys.exit(code)
|
|
|
|
|
|
def run(command):
|
|
"""Run a command, exiting with its status on failure (like `set -e`)."""
|
|
result = subprocess.run(command)
|
|
if result.returncode != 0:
|
|
sys.exit(result.returncode)
|
|
|
|
|
|
def is_executable(path):
|
|
return path.is_file() and os.access(path, os.X_OK)
|
|
|
|
|
|
if env("VM_IMAGE_HELPER"):
|
|
image_downloader = Path(env("VM_IMAGE_HELPER"))
|
|
elif is_executable(script_dir / "vm-images" / "download-images.sh"):
|
|
image_downloader = script_dir / "vm-images" / "download-images.sh"
|
|
else:
|
|
image_downloader = script_dir / ".." / "vm-images" / "download-images.sh"
|
|
|
|
uses_shared_base_image = False
|
|
if env("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_size = env("FEDORA_VM_DISK_SIZE") or "20G"
|
|
|
|
|
|
def normalize_arch(arch):
|
|
if arch in ("arm64", "aarch64"):
|
|
return "arm64"
|
|
if arch in ("x86_64", "amd64"):
|
|
return "amd64"
|
|
die(f"unsupported architecture: {arch}")
|
|
|
|
|
|
native_arch = normalize_arch(platform.machine())
|
|
host_arch = normalize_arch(env("VM_IMAGE_ARCH") or native_arch)
|
|
qemu_binary = "qemu-system-aarch64" if host_arch == "arm64" else "qemu-system-x86_64"
|
|
qemu_config_template = Path(
|
|
env("FEDORA_VM_QEMU_CONFIG") or script_dir / "qemu" / "fedora44" / "config.ini")
|
|
host_os = platform.system().lower()
|
|
if host_os == "darwin" and host_arch == "amd64":
|
|
die("amd64 guests are not supported on macOS hosts; use an arm64 image")
|
|
qemu_platform_config = script_dir / "qemu" / "fedora44" / f"{host_os}-{host_arch}.ini"
|
|
forwards_file = Path(
|
|
env("FEDORA_VM_FORWARDS") or script_dir / "qemu" / "fedora44" / "forwards.conf")
|
|
|
|
|
|
def pick_firmware(override, *candidates):
|
|
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",
|
|
)
|
|
daemonize = False
|
|
download_only = False
|
|
extra_args = []
|
|
|
|
|
|
def usage(stream=sys.stdout):
|
|
stream.write(f"""\
|
|
Usage: {Path(sys.argv[0]).name} [--daemon] [--download-only] [--state-dir DIR] [-- QEMU_ARGUMENT ...]
|
|
|
|
Start a Fedora 44 Cloud Base virtual machine. The launcher obtains a missing
|
|
base image through the shared image helper and creates a writable overlay
|
|
in the state directory, leaving the base image unchanged. The VM hardware
|
|
(memory, CPUs, ...) is defined in qemu/fedora44/config.ini, which is
|
|
rendered to qemu.ini in the state directory and loaded with QEMU's -readconfig,
|
|
followed by qemu/fedora44/<os>-<arch>.ini (os is linux or darwin, arch is amd64 or arm64). Later QEMU arguments override them, e.g.
|
|
`-- -m 4G -smp 4`.
|
|
Host port forwards, one "LABEL HOST_PORT GUEST_PORT" rule per line, are
|
|
read from qemu/fedora44/forwards.conf.
|
|
|
|
Options:
|
|
--daemon Run in the background while keeping the graphical window
|
|
--download-only Download the base image if it is missing, then exit
|
|
--state-dir DIR Directory for the VM disk, seed ISO and runtime files
|
|
(default: vm/fedora44; overrides FEDORA_VM_STATE_DIR)
|
|
-h, --help Show this help
|
|
|
|
Environment variables:
|
|
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)
|
|
FEDORA_VM_QEMU_CONFIG QEMU config template rendered into the state directory and
|
|
passed to -readconfig (default: qemu/fedora44/config.ini)
|
|
FEDORA_VM_FORWARDS Port forward file (default: qemu/fedora44/forwards.conf)
|
|
""")
|
|
|
|
|
|
args = sys.argv[1:]
|
|
while args:
|
|
arg = args.pop(0)
|
|
if arg == "--daemon":
|
|
daemonize = True
|
|
elif arg == "--download-only":
|
|
download_only = True
|
|
elif arg == "--state-dir":
|
|
if not args:
|
|
die(f"{arg} requires a directory argument.", 2)
|
|
state_dir = Path(args.pop(0))
|
|
elif arg.startswith("--state-dir="):
|
|
state_dir = Path(arg.split("=", 1)[1])
|
|
elif arg in ("-h", "--help"):
|
|
usage()
|
|
sys.exit(0)
|
|
elif arg == "--":
|
|
extra_args.extend(args)
|
|
break
|
|
else:
|
|
print(f"Error: unknown option: {arg}", file=sys.stderr)
|
|
usage(sys.stderr)
|
|
sys.exit(2)
|
|
|
|
disk_image = Path(env("FEDORA_VM_DISK_IMAGE") or state_dir / "disk.qcow2")
|
|
seed_image = Path(env("FEDORA_VM_SEED_IMAGE") or disk_image.parent / "seed.iso")
|
|
uefi_vars = state_dir / "OVMF_VARS_4M.fd"
|
|
pid_file = state_dir / "qemu.pid"
|
|
log_file = state_dir / "console.log"
|
|
qemu_config = state_dir / "qemu.ini"
|
|
|
|
if not download_only:
|
|
for command_name in (qemu_binary, "qemu-img"):
|
|
if shutil.which(command_name) is None:
|
|
die(f"{command_name} is not installed.")
|
|
|
|
if not base_image.is_file():
|
|
if uses_shared_base_image:
|
|
run([str(image_downloader), "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}")
|
|
if download_only:
|
|
print(f"Base image is ready: {base_image}")
|
|
sys.exit(0)
|
|
for firmware_file in (uefi_code, uefi_vars_template):
|
|
if not firmware_file.is_file():
|
|
die(f"required UEFI firmware was not found: {firmware_file}")
|
|
|
|
creating = not disk_image.is_file()
|
|
if creating and not seed_image.is_file():
|
|
seed_builder = script_dir / "seediso" / "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 creating:
|
|
run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2",
|
|
"-b", str(base_image), str(disk_image), disk_size])
|
|
print(f"Created writable disk overlay: {disk_image}")
|
|
|
|
is_macos = platform.system() == "Darwin"
|
|
display_backend = env("FEDORA_VM_DISPLAY") or ("cocoa" if is_macos else "gtk")
|
|
|
|
# Most of the VM definition lives in the QEMU config template; fill in the
|
|
# file paths and hand the rendered file to QEMU with -readconfig.
|
|
config_values = {
|
|
"uefi_code": uefi_code_flash,
|
|
"uefi_vars": uefi_vars,
|
|
"disk_image": disk_image,
|
|
}
|
|
for key, value in config_values.items():
|
|
# QEMU's config file parser has no escape syntax for quoted values.
|
|
if any(char in str(value) for char in '"\n'):
|
|
die(f"{key} must not contain double quotes or newlines: {value}")
|
|
for config_file in (qemu_config_template, qemu_platform_config):
|
|
if not config_file.is_file():
|
|
die(f"QEMU config file was not found: {config_file}")
|
|
try:
|
|
qemu_config_text = string.Template(qemu_config_template.read_text()).substitute(
|
|
{key: str(value) for key, value in config_values.items()})
|
|
except (KeyError, ValueError) as error:
|
|
die(f"invalid placeholder in {qemu_config_template}: {error}")
|
|
qemu_config.write_text(qemu_config_text)
|
|
|
|
forwards = vmforwards.usable_forwards(vmforwards.load_forwards(forwards_file))
|
|
|
|
qemu_args = [
|
|
"-readconfig", str(qemu_config),
|
|
"-readconfig", str(qemu_platform_config),
|
|
# "max" is the host CPU under KVM/HVF and the fullest model under TCG.
|
|
"-cpu", "max",
|
|
# A config file section cannot hold two hostfwd entries, so the network
|
|
# that the NIC in the config file uses is defined here. -nic none stops
|
|
# QEMU from adding its default NIC as well.
|
|
"-netdev", vmforwards.netdev_argument(forwards),
|
|
"-nic", "none",
|
|
"-display", display_backend,
|
|
]
|
|
|
|
if creating:
|
|
# cloud-init reads the seed ISO on the first boot only (the users, keys,
|
|
# network and packages it sets up stay on the disk), so it is attached only
|
|
# when the disk is created.
|
|
qemu_args += ["-drive", f"file={seed_image},if=none,id=seed0,format=raw,readonly=on",
|
|
"-device", "virtio-blk-pci,drive=seed0"]
|
|
|
|
print(f"Starting Fedora 44 ({host_arch}) from {qemu_config_template} "
|
|
f"and {qemu_platform_config}.", flush=True)
|
|
vmforwards.print_forwards(forwards)
|
|
|
|
if daemonize:
|
|
log_file.write_text("")
|
|
qemu_args += [
|
|
"-serial", f"file:{log_file}",
|
|
"-monitor", "none",
|
|
"-daemonize",
|
|
"-pidfile", str(pid_file),
|
|
]
|
|
run([qemu_binary, *qemu_args, *extra_args])
|
|
print(f"VM started (PID {pid_file.read_text().strip()}); console log: {log_file}")
|
|
else:
|
|
os.execvp(qemu_binary, [qemu_binary, *qemu_args, *extra_args])
|