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:
committed by
Brad Fitzpatrick
parent
0e8ae9d60c
commit
674f866ecc
@@ -103,10 +103,10 @@ class Config: Codable {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The VM Bundle URL holds the restore image and a set of VM images
|
// The VM Bundle URL holds the restore image and a set of VM images.
|
||||||
// By default, VM's are persisted at ~/VM.bundle
|
// VMs are stored under ~/.cache/tailscale/vmtest/macos/.
|
||||||
var vmBundleURL: URL = {
|
var vmBundleURL: URL = {
|
||||||
let vmBundlePath = NSHomeDirectory() + "/VM.bundle/"
|
let vmBundlePath = NSHomeDirectory() + "/.cache/tailscale/vmtest/macos/"
|
||||||
createDir(vmBundlePath)
|
createDir(vmBundlePath)
|
||||||
let bundleURL = URL(fileURLWithPath: vmBundlePath)
|
let bundleURL = URL(fileURLWithPath: vmBundlePath)
|
||||||
return bundleURL
|
return bundleURL
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ struct TailMacConfigHelper {
|
|||||||
// Outbound network packets
|
// Outbound network packets
|
||||||
let serverSocket = config.serverSocket
|
let serverSocket = config.serverSocket
|
||||||
|
|
||||||
// Inbound network packets
|
// Inbound network packets — bind a client socket so the server can reply.
|
||||||
let clientSockId = config.vmID
|
let clientSockId = config.vmID
|
||||||
let clientSocket = "/tmp/qemu-dgram-\(clientSockId).sock"
|
let clientSocket = "/tmp/qemu-dgram-\(clientSockId).sock"
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ struct TailMacConfigHelper {
|
|||||||
socklen_t(MemoryLayout<sockaddr_un>.size))
|
socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||||
|
|
||||||
if connectRes == -1 {
|
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
|
return networkDevice
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +127,6 @@ struct TailMacConfigHelper {
|
|||||||
print("Connected to server at \(serverSocket)")
|
print("Connected to server at \(serverSocket)")
|
||||||
print("Socket fd is \(socket)")
|
print("Socket fd is \(socket)")
|
||||||
|
|
||||||
|
|
||||||
let handle = FileHandle(fileDescriptor: socket)
|
let handle = FileHandle(fileDescriptor: socket)
|
||||||
let device = VZFileHandleNetworkDeviceAttachment(fileHandle: handle)
|
let device = VZFileHandleNetworkDeviceAttachment(fileHandle: handle)
|
||||||
networkDevice.attachment = device
|
networkDevice.attachment = device
|
||||||
|
|||||||
@@ -20,12 +20,31 @@ extension HostCli {
|
|||||||
struct Run: ParsableCommand {
|
struct Run: ParsableCommand {
|
||||||
@Option var id: String
|
@Option var id: String
|
||||||
@Option var share: String?
|
@Option var share: String?
|
||||||
|
@Flag(help: "Run without GUI (for automated testing)") var headless: Bool = false
|
||||||
|
|
||||||
mutating func run() {
|
mutating func run() {
|
||||||
config = Config(id)
|
config = Config(id)
|
||||||
config.sharedDir = share
|
config.sharedDir = share
|
||||||
print("Running vm with identifier \(id) and sharedDir \(share ?? "<none>")")
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class VMController: NSObject, VZVirtualMachineDelegate {
|
|||||||
return macPlatform
|
return macPlatform
|
||||||
}
|
}
|
||||||
|
|
||||||
func createVirtualMachine() {
|
func createVirtualMachine(headless: Bool = false) {
|
||||||
let virtualMachineConfiguration = VZVirtualMachineConfiguration()
|
let virtualMachineConfiguration = VZVirtualMachineConfiguration()
|
||||||
|
|
||||||
virtualMachineConfiguration.platform = createMacPlaform()
|
virtualMachineConfiguration.platform = createMacPlaform()
|
||||||
@@ -90,7 +90,15 @@ class VMController: NSObject, VZVirtualMachineDelegate {
|
|||||||
virtualMachineConfiguration.memorySize = helper.computeMemorySize()
|
virtualMachineConfiguration.memorySize = helper.computeMemorySize()
|
||||||
virtualMachineConfiguration.graphicsDevices = [helper.createGraphicsDeviceConfiguration()]
|
virtualMachineConfiguration.graphicsDevices = [helper.createGraphicsDeviceConfiguration()]
|
||||||
virtualMachineConfiguration.storageDevices = [helper.createBlockDeviceConfiguration()]
|
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.pointingDevices = [helper.createPointingDeviceConfiguration()]
|
||||||
virtualMachineConfiguration.keyboards = [helper.createKeyboardConfiguration()]
|
virtualMachineConfiguration.keyboards = [helper.createKeyboardConfiguration()]
|
||||||
virtualMachineConfiguration.socketDevices = [helper.createSocketDeviceConfiguration()]
|
virtualMachineConfiguration.socketDevices = [helper.createSocketDeviceConfiguration()]
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ extension Tailmac {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatchMain()
|
RunLoop.main.run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user