diff --git a/tstest/build-macos-base-vm/install.swift b/tstest/build-macos-base-vm/install.swift new file mode 100644 index 000000000..181e1a14e --- /dev/null +++ b/tstest/build-macos-base-vm/install.swift @@ -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 + +import Foundation +import Virtualization + +guard CommandLine.arguments.count == 3 else { + fputs("usage: installer \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() diff --git a/tstest/build-macos-base-vm/main.go b/tstest/build-macos-base-vm/main.go new file mode 100644 index 000000000..465e04cfa --- /dev/null +++ b/tstest/build-macos-base-vm/main.go @@ -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 = ` + + + + com.apple.security.virtualization + + + +` diff --git a/tstest/natlab/vmtest/vmtest.go b/tstest/natlab/vmtest/vmtest.go index 5f5320e6d..512aadb23 100644 --- a/tstest/natlab/vmtest/vmtest.go +++ b/tstest/natlab/vmtest/vmtest.go @@ -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) } }