mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 04:06:35 +02:00
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:
parent
7fe5a954b4
commit
a981211011
164
tstest/build-macos-base-vm/install.swift
Normal file
164
tstest/build-macos-base-vm/install.swift
Normal 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()
|
||||
238
tstest/build-macos-base-vm/main.go
Normal file
238
tstest/build-macos-base-vm/main.go
Normal 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>
|
||||
`
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user