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>
This commit is contained in:
Brad Fitzpatrick
2026-04-10 13:22:24 -07:00
committed by Brad Fitzpatrick
parent 0e8ae9d60c
commit 674f866ecc
5 changed files with 36 additions and 10 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
}
}
}

View File

@@ -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()]

View File

@@ -329,7 +329,7 @@ extension Tailmac {
}
}
dispatchMain()
RunLoop.main.run()
}
}
}