tstest/build-macos-base-vm: add tool to create macOS base VM for vmtest

New command that creates a macOS VM image from scratch:

    go run ./tstest/build-macos-base-vm

It downloads the latest macOS IPSW restore image (~15GB, cached), installs
macOS via Virtualization.framework (~3.5 min), and applies the
.AppleSetupDone fixup so the VM boots without the interactive Setup
Assistant.

The vmtest skip message now points to this command instead of requiring
an external tool. The --macos-vm-id flag defaults to "llmacstation" so
no flags are needed in the common case.
This commit is contained in:
Brad Fitzpatrick 2026-04-10 07:41:50 -07:00
parent 7fe5a954b4
commit a981211011
3 changed files with 411 additions and 3 deletions

View File

@ -0,0 +1,164 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// This is a helper program invoked by the Go build-macos-base-vm command.
// It uses Apple's Virtualization.framework to download a macOS IPSW
// restore image and install macOS into a VM disk image.
//
// Usage: installer <vm-dir> <ipsw-path>
import Foundation
import Virtualization
guard CommandLine.arguments.count == 3 else {
fputs("usage: installer <vm-dir> <ipsw-path>\n", stderr)
exit(1)
}
let vmDir = CommandLine.arguments[1]
let ipswPath = CommandLine.arguments[2]
let diskURL = URL(fileURLWithPath: vmDir).appendingPathComponent("Disk.img")
let auxURL = URL(fileURLWithPath: vmDir).appendingPathComponent("AuxiliaryStorage")
let hwModelURL = URL(fileURLWithPath: vmDir).appendingPathComponent("HardwareModel")
let machineIdURL = URL(fileURLWithPath: vmDir).appendingPathComponent("MachineIdentifier")
let diskSize: Int64 = 72 * 1024 * 1024 * 1024 // 72 GB sparse
let memorySize: UInt64 = 8 * 1024 * 1024 * 1024 // 8 GB
// Step 1: Download IPSW if needed.
func downloadIPSW(to path: String, completion: @escaping (URL) -> Void) {
let url = URL(fileURLWithPath: path)
if FileManager.default.fileExists(atPath: path) {
print("Using existing IPSW at \(path)")
completion(url)
return
}
print("Downloading latest macOS restore image...")
VZMacOSRestoreImage.fetchLatestSupported { result in
switch result {
case .failure(let error):
fputs("Failed to fetch restore image info: \(error)\n", stderr)
exit(1)
case .success(let image):
print("Downloading from \(image.url)...")
let task = URLSession.shared.downloadTask(with: image.url) { localURL, _, error in
if let error = error {
fputs("Download failed: \(error)\n", stderr)
exit(1)
}
do {
try FileManager.default.moveItem(at: localURL!, to: url)
} catch {
fputs("Failed to move IPSW: \(error)\n", stderr)
exit(1)
}
print("Downloaded IPSW to \(path)")
completion(url)
}
task.progress.observe(\.fractionCompleted, options: [.new]) { _, change in
let pct = Int((change.newValue ?? 0) * 100)
print(" download: \(pct)%")
}
task.resume()
}
}
}
// Step 2: Install macOS from IPSW.
func installMacOS(ipswURL: URL) {
print("Loading IPSW...")
VZMacOSRestoreImage.load(from: ipswURL) { result in
switch result {
case .failure(let error):
fputs("Failed to load IPSW: \(error)\n", stderr)
exit(1)
case .success(let restoreImage):
guard let macOSConfig = restoreImage.mostFeaturefulSupportedConfiguration else {
fputs("No supported macOS configuration for this host.\n", stderr)
exit(1)
}
guard macOSConfig.hardwareModel.isSupported else {
fputs("Hardware model not supported on this host.\n", stderr)
exit(1)
}
DispatchQueue.main.async {
doInstall(restoreImage: restoreImage, macOSConfig: macOSConfig)
}
}
}
}
func doInstall(restoreImage: VZMacOSRestoreImage, macOSConfig: VZMacOSConfigurationRequirements) {
// Create disk image.
let fd = open(diskURL.path, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR)
guard fd != -1 else { fputs("Cannot create disk image.\n", stderr); exit(1) }
guard ftruncate(fd, diskSize) == 0 else { fputs("ftruncate failed.\n", stderr); exit(1) }
close(fd)
// Create platform config.
let platform = VZMacPlatformConfiguration()
platform.auxiliaryStorage = try! VZMacAuxiliaryStorage(
creatingStorageAt: auxURL,
hardwareModel: macOSConfig.hardwareModel,
options: [])
platform.hardwareModel = macOSConfig.hardwareModel
platform.machineIdentifier = VZMacMachineIdentifier()
// Save hardware model and machine identifier for future boots.
try! platform.hardwareModel.dataRepresentation.write(to: hwModelURL)
try! platform.machineIdentifier.dataRepresentation.write(to: machineIdURL)
// Build VM config (minimal just enough for installation).
let vmConfig = VZVirtualMachineConfiguration()
vmConfig.platform = platform
vmConfig.bootLoader = VZMacOSBootLoader()
var cpuCount = ProcessInfo.processInfo.processorCount - 1
cpuCount = max(cpuCount, VZVirtualMachineConfiguration.minimumAllowedCPUCount)
cpuCount = min(cpuCount, VZVirtualMachineConfiguration.maximumAllowedCPUCount)
vmConfig.cpuCount = cpuCount
var mem = memorySize
mem = max(mem, VZVirtualMachineConfiguration.minimumAllowedMemorySize)
mem = min(mem, VZVirtualMachineConfiguration.maximumAllowedMemorySize)
vmConfig.memorySize = mem
let gfx = VZMacGraphicsDeviceConfiguration()
gfx.displays = [VZMacGraphicsDisplayConfiguration(widthInPixels: 1920, heightInPixels: 1200, pixelsPerInch: 80)]
vmConfig.graphicsDevices = [gfx]
let disk = try! VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
vmConfig.storageDevices = [VZVirtioBlockDeviceConfiguration(attachment: disk)]
vmConfig.networkDevices = []
vmConfig.pointingDevices = [VZMacTrackpadConfiguration()]
vmConfig.keyboards = [VZMacKeyboardConfiguration()]
try! vmConfig.validate()
let vm = VZVirtualMachine(configuration: vmConfig)
// Install.
print("Starting macOS installation...")
let installer = VZMacOSInstaller(virtualMachine: vm, restoringFromImageAt: restoreImage.url)
installer.install { result in
switch result {
case .success:
print("Installation complete.")
exit(0)
case .failure(let error):
fputs("Installation failed: \(error)\n", stderr)
exit(1)
}
}
_ = installer.progress.observe(\.fractionCompleted, options: [.initial, .new]) { _, change in
let pct = Int((change.newValue ?? 0) * 100)
print(" install: \(pct)%")
}
}
// Main flow.
downloadIPSW(to: ipswPath) { url in
installMacOS(ipswURL: url)
}
RunLoop.main.run()

View File

@ -0,0 +1,238 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Command build-macos-base-vm creates a macOS VM image suitable for use
// with the vmtest integration test framework. It downloads a macOS IPSW
// restore image, installs macOS into a VM, and applies post-install fixups
// so the VM boots to a usable state without the interactive Setup Assistant.
//
// Usage:
//
// go run ./tstest/build-macos-base-vm
//
// The VM is created at ~/VM.bundle/llmacstation/ and can be used by vmtest
// tests that include macOS nodes. The IPSW is cached at ~/VM.bundle/RestoreImage.ipsw.
//
// This only runs on macOS arm64 (Apple Silicon) and requires the Virtualization
// framework entitlement, so the helper Swift binary is compiled and ad-hoc signed
// automatically.
package main
import (
"flag"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
var (
vmName = flag.String("name", "llmacstation", "VM name (directory under ~/VM.bundle/)")
)
func main() {
flag.Parse()
if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" {
log.Fatal("This program only runs on macOS arm64 (Apple Silicon).")
}
home, err := os.UserHomeDir()
if err != nil {
log.Fatal(err)
}
bundleDir := filepath.Join(home, "VM.bundle")
vmDir := filepath.Join(bundleDir, *vmName)
ipswPath := filepath.Join(bundleDir, "RestoreImage.ipsw")
if _, err := os.Stat(filepath.Join(vmDir, "Disk.img")); err == nil {
log.Fatalf("VM %q already exists at %s. Delete it first or choose a different --name.", *vmName, vmDir)
}
os.MkdirAll(bundleDir, 0755)
os.MkdirAll(vmDir, 0755)
// Step 1: Build the Swift helper that does the VZ install.
log.Println("Building macOS VM installer helper...")
helperBin, err := buildSwiftHelper()
if err != nil {
log.Fatalf("Building Swift helper: %v", err)
}
defer os.RemoveAll(filepath.Dir(helperBin))
// Step 2: Run the helper to download IPSW (if needed) and install macOS.
log.Printf("Installing macOS into %s...", vmDir)
log.Println("(This downloads ~15GB on first run and takes several minutes to install.)")
cmd := exec.Command(helperBin, vmDir, ipswPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatalf("macOS installation failed: %v", err)
}
// Step 3: Write config.json.
configJSON := fmt.Sprintf(`{
"vmName": %q,
"memorySize": 8589934592,
"diskSize": 77309411328,
"mac": "52:cc:cc:cc:cc:01",
"hostname": %q
}`, *vmName, *vmName)
if err := os.WriteFile(filepath.Join(vmDir, "config.json"), []byte(configJSON), 0644); err != nil {
log.Fatalf("Writing config.json: %v", err)
}
// Step 4: Mount the disk and apply post-install fixups.
log.Println("Applying post-install fixups (skipping Setup Assistant)...")
if err := applyPostInstallFixups(vmDir); err != nil {
log.Fatalf("Post-install fixups: %v", err)
}
log.Printf("macOS VM %q created successfully at %s", *vmName, vmDir)
log.Println("Run vmtest tests with: go test ./tstest/natlab/vmtest/ --run-vm-tests -v -run TestMacOS")
}
// buildSwiftHelper compiles and signs the embedded Swift installer program.
func buildSwiftHelper() (string, error) {
tmpDir, err := os.MkdirTemp("", "build-macos-vm-*")
if err != nil {
return "", err
}
// Find the Swift source file next to this Go file.
// When run via "go run", we need to find it relative to the source.
srcDir, err := findSourceDir()
if err != nil {
return "", fmt.Errorf("finding source dir: %w", err)
}
swiftSrc := filepath.Join(srcDir, "install.swift")
if _, err := os.Stat(swiftSrc); err != nil {
return "", fmt.Errorf("Swift source not found at %s: %w", swiftSrc, err)
}
binPath := filepath.Join(tmpDir, "installer")
out, err := exec.Command("swiftc", "-O", "-o", binPath,
"-framework", "Virtualization", swiftSrc).CombinedOutput()
if err != nil {
return "", fmt.Errorf("swiftc: %v\n%s", err, out)
}
// Sign with the virtualization entitlement.
entPath := filepath.Join(tmpDir, "entitlements.plist")
if err := os.WriteFile(entPath, []byte(entitlementsPlist), 0644); err != nil {
return "", err
}
out, err = exec.Command("codesign", "--force", "--sign", "-",
"--entitlements", entPath, binPath).CombinedOutput()
if err != nil {
return "", fmt.Errorf("codesign: %v\n%s", err, out)
}
return binPath, nil
}
func findSourceDir() (string, error) {
// Try relative to the working directory first.
candidates := []string{
"tstest/build-macos-base-vm",
".",
}
for _, c := range candidates {
if _, err := os.Stat(filepath.Join(c, "install.swift")); err == nil {
return filepath.Abs(c)
}
}
// Try relative to the Go module root.
out, err := exec.Command("go", "env", "GOMOD").CombinedOutput()
if err == nil {
modRoot := filepath.Dir(strings.TrimSpace(string(out)))
p := filepath.Join(modRoot, "tstest", "build-macos-base-vm")
if _, err := os.Stat(filepath.Join(p, "install.swift")); err == nil {
return p, nil
}
}
return "", fmt.Errorf("cannot find install.swift")
}
// applyPostInstallFixups mounts the VM's disk image and modifies the
// filesystem so macOS boots without the Setup Assistant.
func applyPostInstallFixups(vmDir string) error {
diskPath := filepath.Join(vmDir, "Disk.img")
// Attach the disk image without auto-mounting.
out, err := exec.Command("hdiutil", "attach", diskPath, "-nomount").CombinedOutput()
if err != nil {
return fmt.Errorf("hdiutil attach: %v\n%s", err, out)
}
// Parse the top-level disk device from output (e.g. /dev/disk4).
var diskDev string
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 1 && strings.HasPrefix(fields[0], "/dev/disk") {
if diskDev == "" {
diskDev = fields[0]
}
}
}
if diskDev == "" {
return fmt.Errorf("no disk device found in hdiutil output:\n%s", out)
}
defer func() {
exec.Command("hdiutil", "detach", diskDev, "-force").Run()
}()
// Wait for APFS volumes to synthesize.
time.Sleep(2 * time.Second)
// Find the APFS Data volume. It's on a synthesized disk derived from
// the physical APFS container.
var dataVolDev string
allDisks, _ := exec.Command("diskutil", "list").CombinedOutput()
for _, line := range strings.Split(string(allDisks), "\n") {
if strings.Contains(line, "APFS Volume") && strings.Contains(line, "Data") {
fields := strings.Fields(line)
if len(fields) > 0 {
dataVolDev = fields[len(fields)-1]
}
}
}
if dataVolDev == "" {
return fmt.Errorf("no APFS Data volume found:\n%s", allDisks)
}
// Mount the Data volume via diskutil (handles APFS permissions correctly).
mountPoint, err := os.MkdirTemp("", "vm-data-*")
if err != nil {
return err
}
defer os.RemoveAll(mountPoint)
out, err = exec.Command("diskutil", "mount", "-mountPoint", mountPoint, dataVolDev).CombinedOutput()
if err != nil {
return fmt.Errorf("mounting Data volume %s: %v\n%s", dataVolDev, err, out)
}
defer exec.Command("diskutil", "unmount", mountPoint).Run()
// Create .AppleSetupDone to skip the Setup Assistant.
setupDone := filepath.Join(mountPoint, "private", "var", "db", ".AppleSetupDone")
if err := os.WriteFile(setupDone, nil, 0644); err != nil {
return fmt.Errorf("creating .AppleSetupDone: %v", err)
}
log.Printf("Created %s", setupDone)
return nil
}
const entitlementsPlist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.virtualization</key>
<true/>
</dict>
</plist>
`

View File

@ -47,7 +47,7 @@ import (
var (
runVMTests = flag.Bool("run-vm-tests", false, "run tests that require VMs with KVM")
verboseVMDebug = flag.Bool("verbose-vm-debug", false, "enable verbose debug logging for VM tests")
macosVMID = flag.String("macos-vm-id", "", "base macOS VM identifier in ~/VM.bundle/ for macOS VM tests")
macosVMID = flag.String("macos-vm-id", "llmacstation", "base macOS VM identifier in ~/VM.bundle/ for macOS VM tests")
)
// Env is a test environment that manages virtual networks and QEMU VMs.
@ -215,8 +215,14 @@ func (e *Env) Start() {
if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" {
t.Skip("macOS VM tests require macOS arm64 host")
}
if *macosVMID == "" {
t.Skip("macOS VM tests require --macos-vm-id flag")
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("getting home dir: %v", err)
}
vmDir := filepath.Join(home, "VM.bundle", *macosVMID)
if _, err := os.Stat(vmDir); err != nil {
t.Skipf("macOS base VM %q not found at %s; create it with:\n\tgo run ./tstest/build-macos-base-vm",
*macosVMID, vmDir)
}
}