diff --git a/tstest/build-macos-base-vm/install.swift b/tstest/build-macos-base-vm/install.swift index 181e1a14e..a8ceae082 100644 --- a/tstest/build-macos-base-vm/install.swift +++ b/tstest/build-macos-base-vm/install.swift @@ -2,73 +2,54 @@ // 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. +// It uses Apple's Virtualization.framework for two operations: // -// Usage: installer +// fetch-ipsw-url — prints the URL of the latest supported IPSW +// install — installs macOS from a local IPSW into a VM import Foundation import Virtualization -guard CommandLine.arguments.count == 3 else { - fputs("usage: installer \n", stderr) +guard CommandLine.arguments.count >= 2 else { + fputs("usage: installer {fetch-ipsw-url | install }\n", stderr) exit(1) } -let vmDir = CommandLine.arguments[1] -let ipswPath = CommandLine.arguments[2] +let mode = CommandLine.arguments[1] -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...") +switch mode { +case "fetch-ipsw-url": 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() + // Print URL to stdout for the Go caller to parse. + print(image.url.absoluteString) + exit(0) } } -} + RunLoop.main.run() -// Step 2: Install macOS from IPSW. -func installMacOS(ipswURL: URL) { - print("Loading IPSW...") - VZMacOSRestoreImage.load(from: ipswURL) { result in +case "install": + guard CommandLine.arguments.count == 4 else { + fputs("usage: installer install \n", stderr) + exit(1) + } + let vmDir = CommandLine.arguments[2] + let ipswPath = CommandLine.arguments[3] + + 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 + let memorySize: UInt64 = 8 * 1024 * 1024 * 1024 + + fputs("Loading IPSW...\n", stderr) + VZMacOSRestoreImage.load(from: URL(fileURLWithPath: ipswPath)) { result in switch result { case .failure(let error): fputs("Failed to load IPSW: \(error)\n", stderr) @@ -83,82 +64,74 @@ func installMacOS(ipswURL: URL) { exit(1) } DispatchQueue.main.async { - doInstall(restoreImage: restoreImage, macOSConfig: macOSConfig) + // Create disk image (sparse). + 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() + 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) + + fputs("Starting macOS installation...\n", stderr) + let installer = VZMacOSInstaller(virtualMachine: vm, restoringFromImageAt: restoreImage.url) + installer.install { result in + switch result { + case .success: + fputs("Installation complete.\n", stderr) + 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) + fputs(" install: \(pct)%\n", stderr) + } } } } + RunLoop.main.run() + +default: + fputs("unknown mode: \(mode)\n", stderr) + exit(1) } - -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 index 71070b5ad..fdfd39447 100644 --- a/tstest/build-macos-base-vm/main.go +++ b/tstest/build-macos-base-vm/main.go @@ -10,8 +10,9 @@ // // go run ./tstest/build-macos-base-vm // -// The VM is created at ~/.cache/tailscale/vmtest/macos// and can be used -// by vmtest tests that include macOS nodes. The IPSW is cached alongside it. +// The VM is created at ~/.cache/tailscale/vmtest/macos//. The IPSW +// restore image is cached in ~/.cache/tailscale/vmtest/macos-ipsw/ and +// only re-downloaded when Apple publishes a newer version. // // 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 @@ -21,7 +22,9 @@ package main import ( "flag" "fmt" + "io" "log" + "net/http" "os" "os/exec" "path/filepath" @@ -45,9 +48,9 @@ func main() { if err != nil { log.Fatal(err) } - bundleDir := filepath.Join(home, ".cache", "tailscale", "vmtest", "macos") - vmDir := filepath.Join(bundleDir, *vmName) - ipswPath := filepath.Join(bundleDir, "RestoreImage.ipsw") + cacheBase := filepath.Join(home, ".cache", "tailscale", "vmtest") + vmDir := filepath.Join(cacheBase, "macos", *vmName) + ipswDir := filepath.Join(cacheBase, "macos-ipsw") if _, err := os.Stat(filepath.Join(vmDir, "Disk.img")); err == nil { if !*rebuild { @@ -60,10 +63,10 @@ func main() { } } - os.MkdirAll(bundleDir, 0755) os.MkdirAll(vmDir, 0755) + os.MkdirAll(ipswDir, 0755) - // Step 1: Build the Swift helper that does the VZ install. + // Step 1: Build the Swift helper. log.Println("Building macOS VM installer helper...") helperBin, err := buildSwiftHelper() if err != nil { @@ -71,29 +74,47 @@ func main() { } 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 + // Step 2: Get the latest IPSW URL from Apple via the VZ framework. + log.Println("Checking for latest macOS restore image...") + out, err := exec.Command(helperBin, "fetch-ipsw-url").Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + log.Fatalf("Fetching IPSW URL: %v\n%s", err, ee.Stderr) + } + log.Fatalf("Fetching IPSW URL: %v", err) + } + ipswURL := strings.TrimSpace(string(out)) + log.Printf("Latest IPSW: %s", ipswURL) + + // Step 3: Download the IPSW, using the cached copy if unchanged. + ipswPath, err := ensureIPSW(ipswDir, ipswURL) + if err != nil { + log.Fatalf("Downloading IPSW: %v", err) + } + + // Step 4: Install macOS from the IPSW. + log.Printf("Installing macOS into %s (this takes a few minutes)...", vmDir) + cmd := exec.Command(helperBin, "install", vmDir, ipswPath) + cmd.Stdout = os.Stderr // Swift helper prints progress to stderr cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { log.Fatalf("macOS installation failed: %v", err) } - // Step 3: Write config.json. + // Step 5: Write config.json for the tailmac Host.app. configJSON := fmt.Sprintf(`{ - "vmName": %q, + "vmID": %q, + "serverSocket": "/tmp/qemu-dgram.sock", "memorySize": 8589934592, - "diskSize": 77309411328, "mac": "52:cc:cc:cc:cc:01", - "hostname": %q -}`, *vmName, *vmName) + "ethermac": "52:cc:cc:cc:ce:01", + "port": 51009 +}`, *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. + // Step 6: 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) @@ -103,6 +124,120 @@ func main() { log.Println("Run vmtest tests with: go test ./tstest/natlab/vmtest/ --run-vm-tests -v -run TestMacOS") } +// ensureIPSW downloads the IPSW to ipswDir if it's not already cached or if +// the remote version has changed. Only one IPSW is kept in the directory. +// Returns the path to the local IPSW file. +func ensureIPSW(ipswDir, ipswURL string) (string, error) { + // Use the filename from the URL (e.g. "UniversalMac_26.4.1_25E253_Restore.ipsw"). + urlBase := filepath.Base(ipswURL) + if urlBase == "" || urlBase == "." || urlBase == "/" { + urlBase = "Restore.ipsw" + } + localPath := filepath.Join(ipswDir, urlBase) + + // If we already have this exact file, do a conditional GET to check freshness. + if fi, err := os.Stat(localPath); err == nil && fi.Size() > 0 { + fresh, err := checkIPSWFresh(localPath, ipswURL) + if err != nil { + log.Printf("Warning: freshness check failed, using cached IPSW: %v", err) + return localPath, nil + } + if fresh { + log.Printf("Using cached IPSW at %s (%d MB)", localPath, fi.Size()/1024/1024) + return localPath, nil + } + log.Println("Cached IPSW is stale, re-downloading...") + } + + // Remove any other .ipsw files in the directory (keep at most one). + entries, _ := os.ReadDir(ipswDir) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".ipsw") || strings.HasSuffix(e.Name(), ".ipsw.etag") { + os.Remove(filepath.Join(ipswDir, e.Name())) + } + } + + log.Printf("Downloading %s (~15GB)...", ipswURL) + tmpPath := localPath + ".tmp" + f, err := os.Create(tmpPath) + if err != nil { + return "", err + } + defer func() { + f.Close() + os.Remove(tmpPath) + }() + + resp, err := http.Get(ipswURL) + if err != nil { + return "", fmt.Errorf("HTTP GET: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %s", resp.Status) + } + + total := resp.ContentLength + pr := &progressReader{r: resp.Body, total: total} + if _, err := io.Copy(f, pr); err != nil { + return "", fmt.Errorf("downloading: %w", err) + } + if err := f.Close(); err != nil { + return "", err + } + if err := os.Rename(tmpPath, localPath); err != nil { + return "", err + } + + // Save the ETag for future freshness checks. + if etag := resp.Header.Get("ETag"); etag != "" { + os.WriteFile(localPath+".etag", []byte(etag), 0644) + } + + log.Printf("Downloaded IPSW to %s", localPath) + return localPath, nil +} + +// checkIPSWFresh does a HEAD request with If-None-Match (ETag) to see if +// the cached IPSW is still current. Returns true if the cache is fresh. +func checkIPSWFresh(localPath, ipswURL string) (bool, error) { + req, err := http.NewRequest("HEAD", ipswURL, nil) + if err != nil { + return false, err + } + if etag, err := os.ReadFile(localPath + ".etag"); err == nil && len(etag) > 0 { + req.Header.Set("If-None-Match", string(etag)) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, err + } + resp.Body.Close() + return resp.StatusCode == http.StatusNotModified, nil +} + +type progressReader struct { + r io.Reader + total int64 + read int64 + last int // last printed percent +} + +func (pr *progressReader) Read(p []byte) (int, error) { + n, err := pr.r.Read(p) + pr.read += int64(n) + if pr.total > 0 { + pct := int(pr.read * 100 / pr.total) + if pct != pr.last { + pr.last = pct + if pct%5 == 0 { + log.Printf(" download: %d%% (%d / %d MB)", pct, pr.read/1024/1024, pr.total/1024/1024) + } + } + } + return n, err +} + // buildSwiftHelper compiles and signs the embedded Swift installer program. func buildSwiftHelper() (string, error) { tmpDir, err := os.MkdirTemp("", "build-macos-vm-*") @@ -110,8 +245,6 @@ func buildSwiftHelper() (string, error) { 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) @@ -128,7 +261,6 @@ func buildSwiftHelper() (string, error) { 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 @@ -143,7 +275,6 @@ func buildSwiftHelper() (string, error) { } func findSourceDir() (string, error) { - // Try relative to the working directory first. candidates := []string{ "tstest/build-macos-base-vm", ".", @@ -153,7 +284,6 @@ func findSourceDir() (string, error) { 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))) @@ -170,13 +300,11 @@ func findSourceDir() (string, error) { 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) @@ -212,7 +340,6 @@ func applyPostInstallFixups(vmDir string) error { return fmt.Errorf("waiting for APFS Data volume: %w", err) } - // Mount the Data volume via diskutil (handles APFS permissions correctly). mountPoint, err := os.MkdirTemp("", "vm-data-*") if err != nil { return err @@ -225,7 +352,6 @@ func applyPostInstallFixups(vmDir string) error { } defer exec.Command("diskutil", "unmount", mountPoint).Run() - // Create .AppleSetupDone to skip the Setup Assistant. dbDir := filepath.Join(mountPoint, "private", "var", "db") if err := os.MkdirAll(dbDir, 0755); err != nil { return fmt.Errorf("creating var/db: %v", err)