summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBrad Fitzpatrick <bradfitz@tailscale.com>2026-04-10 13:22:24 -0700
committerBrad Fitzpatrick <brad@danga.com>2026-04-11 12:50:53 -0700
commit674f866eccf727b59d24cdb09a990dc403892e4c (patch)
tree5f789354384ffad0681cf101ce5887049078e17a
parent0e8ae9d60c92ec578bc0ff7c2be0334df6908c8b (diff)
downloadtailscale-674f866eccf727b59d24cdb09a990dc403892e4c.tar.xz
tailscale-674f866eccf727b59d24cdb09a990dc403892e4c.zip
tstest/tailmac: add headless mode for automated VM testing
Add a --headless flag to the Host.app Run subcommand for running macOS VMs without a GUI, enabling use from test frameworks. Key changes: - HostCli.swift: When --headless is set, run the VM via VMController + RunLoop.main.run() instead of NSApplicationMain. Using the RunLoop (not dispatchMain) is required because VZ framework callbacks depend on RunLoop sources. - VMController.swift: Add headless parameter to createVirtualMachine that configures a single socket-based NIC (no NAT NIC). This matches the NIC configuration used when creating/saving VMs, so saved state restoration works correctly. A NIC count mismatch causes VZ to silently fail to execute guest code. - TailMacConfigHelper.swift: Clean up socket network device logging. - Config.swift: Move VM storage from ~/VM.bundle to ~/.cache/tailscale/vmtest/macos/. - TailMac.swift: Fix dispatchMain→RunLoop.main.run() in the create command (same VZ RunLoop requirement). Updates #13038 Change-Id: Iea51c043aa92e8fc6257139b9f0e2e7677072fa2 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
-rw-r--r--tstest/tailmac/Swift/Common/Config.swift6
-rw-r--r--tstest/tailmac/Swift/Common/TailMacConfigHelper.swift5
-rw-r--r--tstest/tailmac/Swift/Host/HostCli.swift21
-rw-r--r--tstest/tailmac/Swift/Host/VMController.swift12
-rw-r--r--tstest/tailmac/Swift/TailMac/TailMac.swift2
5 files changed, 36 insertions, 10 deletions
diff --git a/tstest/tailmac/Swift/Common/Config.swift b/tstest/tailmac/Swift/Common/Config.swift
index 53d768020..53281628a 100644
--- a/tstest/tailmac/Swift/Common/Config.swift
+++ b/tstest/tailmac/Swift/Common/Config.swift
@@ -103,10 +103,10 @@ class Config: Codable {
}
-// The VM Bundle URL holds the restore image and a set of VM images
-// By default, VM's are persisted at ~/VM.bundle
+// The VM Bundle URL holds the restore image and a set of VM images.
+// VMs are stored under ~/.cache/tailscale/vmtest/macos/.
var vmBundleURL: URL = {
- let vmBundlePath = NSHomeDirectory() + "/VM.bundle/"
+ let vmBundlePath = NSHomeDirectory() + "/.cache/tailscale/vmtest/macos/"
createDir(vmBundlePath)
let bundleURL = URL(fileURLWithPath: vmBundlePath)
return bundleURL
diff --git a/tstest/tailmac/Swift/Common/TailMacConfigHelper.swift b/tstest/tailmac/Swift/Common/TailMacConfigHelper.swift
index fc7f2d89d..6c1db77fc 100644
--- a/tstest/tailmac/Swift/Common/TailMacConfigHelper.swift
+++ b/tstest/tailmac/Swift/Common/TailMacConfigHelper.swift
@@ -83,7 +83,7 @@ struct TailMacConfigHelper {
// Outbound network packets
let serverSocket = config.serverSocket
- // Inbound network packets
+ // Inbound network packets — bind a client socket so the server can reply.
let clientSockId = config.vmID
let clientSocket = "/tmp/qemu-dgram-\(clientSockId).sock"
@@ -118,7 +118,7 @@ struct TailMacConfigHelper {
socklen_t(MemoryLayout<sockaddr_un>.size))
if connectRes == -1 {
- print("Error binding virtual network server socket - \(String(cString: strerror(errno)))")
+ print("Error connecting to server socket \(serverSocket) - \(String(cString: strerror(errno)))")
return networkDevice
}
@@ -127,7 +127,6 @@ struct TailMacConfigHelper {
print("Connected to server at \(serverSocket)")
print("Socket fd is \(socket)")
-
let handle = FileHandle(fileDescriptor: socket)
let device = VZFileHandleNetworkDeviceAttachment(fileHandle: handle)
networkDevice.attachment = device
diff --git a/tstest/tailmac/Swift/Host/HostCli.swift b/tstest/tailmac/Swift/Host/HostCli.swift
index 9c9ae6fa0..177c25172 100644
--- a/tstest/tailmac/Swift/Host/HostCli.swift
+++ b/tstest/tailmac/Swift/Host/HostCli.swift
@@ -20,12 +20,31 @@ extension HostCli {
struct Run: ParsableCommand {
@Option var id: String
@Option var share: String?
+ @Flag(help: "Run without GUI (for automated testing)") var headless: Bool = false
mutating func run() {
config = Config(id)
config.sharedDir = share
print("Running vm with identifier \(id) and sharedDir \(share ?? "<none>")")
- _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
+
+ if headless {
+ DispatchQueue.main.async {
+ let controller = VMController()
+ controller.createVirtualMachine(headless: true)
+
+ let fileManager = FileManager.default
+ if fileManager.fileExists(atPath: config.saveFileURL.path) {
+ print("Restoring virtual machine state from \(config.saveFileURL)")
+ controller.restoreVirtualMachine()
+ } else {
+ print("Starting virtual machine")
+ controller.startVirtualMachine()
+ }
+ }
+ RunLoop.main.run()
+ } else {
+ _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
+ }
}
}
}
diff --git a/tstest/tailmac/Swift/Host/VMController.swift b/tstest/tailmac/Swift/Host/VMController.swift
index a19d7222e..68324c507 100644
--- a/tstest/tailmac/Swift/Host/VMController.swift
+++ b/tstest/tailmac/Swift/Host/VMController.swift
@@ -81,7 +81,7 @@ class VMController: NSObject, VZVirtualMachineDelegate {
return macPlatform
}
- func createVirtualMachine() {
+ func createVirtualMachine(headless: Bool = false) {
let virtualMachineConfiguration = VZVirtualMachineConfiguration()
virtualMachineConfiguration.platform = createMacPlaform()
@@ -90,7 +90,15 @@ class VMController: NSObject, VZVirtualMachineDelegate {
virtualMachineConfiguration.memorySize = helper.computeMemorySize()
virtualMachineConfiguration.graphicsDevices = [helper.createGraphicsDeviceConfiguration()]
virtualMachineConfiguration.storageDevices = [helper.createBlockDeviceConfiguration()]
- virtualMachineConfiguration.networkDevices = [helper.createNetworkDeviceConfiguration(), helper.createSocketNetworkDeviceConfiguration()]
+ if headless {
+ // In headless mode, use only the socket-based NIC. This matches
+ // the single-NIC configuration used when creating the base VM.
+ // Using a different NIC count would make saved state restoration
+ // fail silently.
+ virtualMachineConfiguration.networkDevices = [helper.createSocketNetworkDeviceConfiguration()]
+ } else {
+ virtualMachineConfiguration.networkDevices = [helper.createNetworkDeviceConfiguration(), helper.createSocketNetworkDeviceConfiguration()]
+ }
virtualMachineConfiguration.pointingDevices = [helper.createPointingDeviceConfiguration()]
virtualMachineConfiguration.keyboards = [helper.createKeyboardConfiguration()]
virtualMachineConfiguration.socketDevices = [helper.createSocketDeviceConfiguration()]
diff --git a/tstest/tailmac/Swift/TailMac/TailMac.swift b/tstest/tailmac/Swift/TailMac/TailMac.swift
index 3859b9b0b..2271d3bb2 100644
--- a/tstest/tailmac/Swift/TailMac/TailMac.swift
+++ b/tstest/tailmac/Swift/TailMac/TailMac.swift
@@ -329,7 +329,7 @@ extension Tailmac {
}
}
- dispatchMain()
+ RunLoop.main.run()
}
}
}