tstest/tailmac: add customized macOS virtualization tooling (#13146)

updates tailcale/corp#22371

Adds custom macOS vm tooling.  See the README for
the general gist, but this will spin up VMs with unixgram
capable network interfaces listening to a named socket,
and with a virtio socket device for host-guest communication.

We can add other devices like consoles, serial, etc as needed.

The whole things is buildable with a single make command, and
everything is controllable via the command line using the TailMac
utility.

This should all be generally functional but takes a few shortcuts
with error handling and the like.  The virtio socket device support
has not been tested and may require some refinement.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
This commit is contained in:
Jonathan Nobels
2024-08-19 15:01:19 -04:00
committed by GitHub
parent 1e8f8ee5f1
commit 8fad8c4b9b
29 changed files with 2954 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import Foundation
import Virtualization
class RestoreImage: NSObject {
private var downloadObserver: NSKeyValueObservation?
// MARK: Observe the download progress.
var restoreImageURL: URL
init(_ dest: URL) {
restoreImageURL = dest
}
public func download(completionHandler: @escaping () -> Void) {
print("Attempting to download latest available restore image.")
VZMacOSRestoreImage.fetchLatestSupported { [self](result: Result<VZMacOSRestoreImage, Error>) in
switch result {
case let .failure(error):
fatalError(error.localizedDescription)
case let .success(restoreImage):
downloadRestoreImage(restoreImage: restoreImage, completionHandler: completionHandler)
}
}
}
private func downloadRestoreImage(restoreImage: VZMacOSRestoreImage, completionHandler: @escaping () -> Void) {
let downloadTask = URLSession.shared.downloadTask(with: restoreImage.url) { localURL, response, error in
if let error = error {
fatalError("Download failed. \(error.localizedDescription).")
}
do {
try FileManager.default.moveItem(at: localURL!, to: self.restoreImageURL)
} catch {
fatalError("Failed to move downloaded restore image to \(self.restoreImageURL) \(error).")
}
completionHandler()
}
var lastPct = 0
downloadObserver = downloadTask.progress.observe(\.fractionCompleted, options: [.initial, .new]) { (progress, change) in
let pct = Int(change.newValue! * 100)
if pct != lastPct {
print("Restore image download progress: \(pct)%")
lastPct = pct
}
}
downloadTask.resume()
}
}

View File

@@ -0,0 +1,334 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import Foundation
import Virtualization
import ArgumentParser
var usage =
"""
Installs and configures VMs suitable for use with natlab
To create a new VM (this will grab a restore image if needed)
tailmac create --id <vm_id>
To refresh an existing restore image:
tailmac refresh
To clone a vm (this will clone the mac and port as well)
tailmac clone --identfier <old_vm_id> --target-id <new_vm_id>
To reconfigure a vm:
tailmac configure --id <vm_id> --mac 11:22:33:44:55:66 --port 12345 --mem 8000000000000 -sock "/tmp/mySock.sock"
To run a vm:
tailmac run --id <vm_id>
To stop a vm: (this may take a minute - the vm needs to persist it's state)
tailmac stop --id <vm_id>
To halt a vm without persisting its state
tailmac halt --id <vm_id>
To delete a vm:
tailmac delete --id <vm_id>
To list the available VM images:
tailmac ls
"""
@main
struct Tailmac: ParsableCommand {
static var configuration = CommandConfiguration(
abstract: "A utility for setting up VM images",
usage: usage,
subcommands: [Create.self, Clone.self, Delete.self, Configure.self, Stop.self, Run.self, Ls.self, Halt.self],
defaultSubcommand: Ls.self)
}
extension Tailmac {
struct Ls: ParsableCommand {
mutating func run() {
do {
let dirs = try FileManager.default.contentsOfDirectory(atPath: vmBundleURL.path())
var images = [String]()
// This assumes we don't put anything else interesting in our VM.bundle dir
// You may need to add some other exclusions or checks here if that's the case.
for dir in dirs {
if !dir.contains("ipsw") {
images.append(URL(fileURLWithPath: dir).lastPathComponent)
}
}
print("Available images:\n\(images)")
} catch {
fatalError("Failed to query available images \(error)")
}
}
}
}
extension Tailmac {
struct Stop: ParsableCommand {
@Option(help: "The vm identifier") var id: String
mutating func run() {
print("Stopping vm with id \(id). This may take some time!")
let nc = DistributedNotificationCenter()
nc.post(name: Notifications.stop, object: nil, userInfo: ["id": id])
}
}
}
extension Tailmac {
struct Halt: ParsableCommand {
@Option(help: "The vm identifier") var id: String
mutating func run() {
print("Halting vm with id \(id)")
let nc = DistributedNotificationCenter()
nc.post(name: Notifications.halt, object: nil, userInfo: ["id": id])
}
}
}
extension Tailmac {
struct Run: ParsableCommand {
@Option(help: "The vm identifier") var id: String
@Flag(help: "Tail the TailMac log output instead of returning immediatly") var tail
mutating func run() {
let process = Process()
let stdOutPipe = Pipe()
let appPath = "./Host.app/Contents/MacOS/Host"
process.executableURL = URL(
fileURLWithPath: appPath,
isDirectory: false,
relativeTo: NSRunningApplication.current.bundleURL
)
if !FileManager.default.fileExists(atPath: appPath) {
fatalError("Could not find Host.app. This must be co-located with the tailmac utility")
}
process.arguments = ["run", "--id", id]
do {
process.standardOutput = stdOutPipe
try process.run()
} catch {
fatalError("Unable to launch the vm process")
}
// This doesn't print until we exit which is not ideal, but at least we
// get the output
if tail != 0 {
let outHandle = stdOutPipe.fileHandleForReading
let queue = OperationQueue()
NotificationCenter.default.addObserver(
forName: NSNotification.Name.NSFileHandleDataAvailable,
object: outHandle, queue: queue)
{
notification -> Void in
let data = outHandle.availableData
if data.count > 0 {
if let str = String(data: data, encoding: String.Encoding.utf8) {
print(str)
}
}
outHandle.waitForDataInBackgroundAndNotify()
}
outHandle.waitForDataInBackgroundAndNotify()
process.waitUntilExit()
}
}
}
}
extension Tailmac {
struct Configure: ParsableCommand {
@Option(help: "The vm identifier") var id: String
@Option(help: "The mac address of the socket network interface") var mac: String?
@Option(help: "The port for the virtio socket device") var port: String?
@Option(help: "The named socket for the socket network interface") var sock: String?
@Option(help: "The desired RAM in bytes") var mem: String?
@Option(help: "The ethernet address for a standard NAT adapter") var ethermac: String?
mutating func run() {
let config = Config(id)
let vmExists = FileManager.default.fileExists(atPath: config.vmDataURL.path())
if !vmExists {
print("VM with id \(id) doesn't exist. Cannot configure.")
return
}
if let mac {
config.mac = mac
}
if let port, let portInt = UInt32(port) {
config.port = portInt
}
if let ethermac {
config.ethermac = ethermac
}
if let mem, let membytes = UInt64(mem) {
config.memorySize = membytes
}
if let sock {
config.serverSocket = sock
}
config.persist()
let str = String(data:try! JSONEncoder().encode(config), encoding: .utf8)!
print("New Config: \(str)")
}
}
}
extension Tailmac {
struct Delete: ParsableCommand {
@Option(help: "The vm identifer") var id: String?
mutating func run() {
guard let id else {
print("Usage: Installer delete --id=<id>")
return
}
let config = Config(id)
let vmExists = FileManager.default.fileExists(atPath: config.vmDataURL.path())
if !vmExists {
print("VM with id \(id) doesn't exist. Cannot delete.")
return
}
do {
try FileManager.default.removeItem(at: config.vmDataURL)
} catch {
print("Whoops... Deletion failed \(error)")
}
}
}
}
extension Tailmac {
struct Clone: ParsableCommand {
@Option(help: "The vm identifier") var id: String
@Option(help: "The vm identifier for the cloned vm") var targetId: String
mutating func run() {
let config = Config(id)
let targetConfig = Config(targetId)
if id == targetId {
fatalError("The ids match. Clone failed.")
}
let vmExists = FileManager.default.fileExists(atPath: config.vmDataURL.path())
if !vmExists {
print("VM with id \(id) doesn't exist. Cannot clone.")
return
}
print("Cloning \(config.vmDataURL) to \(targetConfig.vmDataURL)")
do {
try FileManager.default.copyItem(at: config.vmDataURL, to: targetConfig.vmDataURL)
} catch {
print("Whoops... Cloning failed \(error)")
}
}
}
}
extension Tailmac {
struct RefreshImage: ParsableCommand {
mutating func run() {
let config = Config()
let exists = FileManager.default.fileExists(atPath: config.restoreImageURL.path())
if exists {
try? FileManager.default.removeItem(at: config.restoreImageURL)
}
let restoreImage = RestoreImage(config.restoreImageURL)
restoreImage.download {
print("Restore image refreshed")
}
}
}
}
extension Tailmac {
struct Create: ParsableCommand {
@Option(help: "The vm identifier. Each VM instance needs a unique ID.") var id: String
@Option(help: "The mac address of the socket network interface") var mac: String?
@Option(help: "The port for the virtio socket device") var port: String?
@Option(help: "The named socket for the socket network interface") var sock: String?
@Option(help: "The desired RAM in bytes") var mem: String?
@Option(help: "The ethernet address for a standard NAT adapter") var ethermac: String?
@Option(help: "The image name to build from. If omitted we will use RestoreImage.ipsw in ~/VM.bundle and download it if needed") var image: String?
mutating func run() {
buildVM(id)
}
func buildVM(_ id: String) {
print("Configuring vm with id \(id)")
let config = Config(id)
let installer = VMInstaller(config)
let vmExists = FileManager.default.fileExists(atPath: config.vmDataURL.path())
if vmExists {
print("VM with id \(id) already exists. No action taken.")
return
}
createDir(config.vmDataURL.path())
if let mac {
config.mac = mac
}
if let port, let portInt = UInt32(port) {
config.port = portInt
}
if let ethermac {
config.ethermac = ethermac
}
if let mem, let membytes = UInt64(mem) {
config.memorySize = membytes
}
if let sock {
config.serverSocket = sock
}
config.persist()
let restoreImagePath = image ?? config.restoreImageURL.path()
let exists = FileManager.default.fileExists(atPath: restoreImagePath)
if exists {
print("Using existing restore image at \(restoreImagePath)")
installer.installMacOS(ipswURL: URL(fileURLWithPath: restoreImagePath))
} else {
if image != nil {
fatalError("Unable to find custom restore image")
}
print("Downloading default restore image to \(config.restoreImageURL)")
let restoreImage = RestoreImage(URL(fileURLWithPath: restoreImagePath))
restoreImage.download {
// Install from the restore image that you downloaded.
installer.installMacOS(ipswURL: URL(fileURLWithPath: restoreImagePath))
}
}
dispatchMain()
}
}
}

View File

@@ -0,0 +1,140 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import Foundation
import Virtualization
class VMInstaller: NSObject {
private var installationObserver: NSKeyValueObservation?
private var virtualMachine: VZVirtualMachine!
private var config: Config
private var helper: TailMacConfigHelper
init(_ config: Config) {
self.config = config
helper = TailMacConfigHelper(config: config)
}
public func installMacOS(ipswURL: URL) {
print("Attempting to install from IPSW at \(ipswURL).")
VZMacOSRestoreImage.load(from: ipswURL, completionHandler: { [self](result: Result<VZMacOSRestoreImage, Error>) in
switch result {
case let .failure(error):
fatalError(error.localizedDescription)
case let .success(restoreImage):
installMacOS(restoreImage: restoreImage)
}
})
}
// MARK: - Internal helper functions.
private func installMacOS(restoreImage: VZMacOSRestoreImage) {
guard let macOSConfiguration = restoreImage.mostFeaturefulSupportedConfiguration else {
fatalError("No supported configuration available.")
}
if !macOSConfiguration.hardwareModel.isSupported {
fatalError("macOSConfiguration configuration isn't supported on the current host.")
}
DispatchQueue.main.async { [self] in
setupVirtualMachine(macOSConfiguration: macOSConfiguration)
startInstallation(restoreImageURL: restoreImage.url)
}
}
// MARK: Create the Mac platform configuration.
private func createMacPlatformConfiguration(macOSConfiguration: VZMacOSConfigurationRequirements) -> VZMacPlatformConfiguration {
let macPlatformConfiguration = VZMacPlatformConfiguration()
let auxiliaryStorage: VZMacAuxiliaryStorage
do {
auxiliaryStorage = try VZMacAuxiliaryStorage(creatingStorageAt: config.auxiliaryStorageURL,
hardwareModel: macOSConfiguration.hardwareModel,
options: [])
} catch {
fatalError("Unable to create aux storage at \(config.auxiliaryStorageURL) \(error)")
}
macPlatformConfiguration.auxiliaryStorage = auxiliaryStorage
macPlatformConfiguration.hardwareModel = macOSConfiguration.hardwareModel
macPlatformConfiguration.machineIdentifier = VZMacMachineIdentifier()
// Store the hardware model and machine identifier to disk so that you
// can retrieve them for subsequent boots.
try! macPlatformConfiguration.hardwareModel.dataRepresentation.write(to: config.hardwareModelURL)
try! macPlatformConfiguration.machineIdentifier.dataRepresentation.write(to: config.machineIdentifierURL)
return macPlatformConfiguration
}
private func setupVirtualMachine(macOSConfiguration: VZMacOSConfigurationRequirements) {
let virtualMachineConfiguration = VZVirtualMachineConfiguration()
virtualMachineConfiguration.platform = createMacPlatformConfiguration(macOSConfiguration: macOSConfiguration)
virtualMachineConfiguration.cpuCount = helper.computeCPUCount()
if virtualMachineConfiguration.cpuCount < macOSConfiguration.minimumSupportedCPUCount {
fatalError("CPUCount isn't supported by the macOS configuration.")
}
virtualMachineConfiguration.memorySize = helper.computeMemorySize()
if virtualMachineConfiguration.memorySize < macOSConfiguration.minimumSupportedMemorySize {
fatalError("memorySize isn't supported by the macOS configuration.")
}
createDiskImage()
virtualMachineConfiguration.bootLoader = helper.createBootLoader()
virtualMachineConfiguration.graphicsDevices = [helper.createGraphicsDeviceConfiguration()]
virtualMachineConfiguration.storageDevices = [helper.createBlockDeviceConfiguration()]
virtualMachineConfiguration.networkDevices = [helper.createNetworkDeviceConfiguration(), helper.createSocketNetworkDeviceConfiguration()]
virtualMachineConfiguration.pointingDevices = [helper.createPointingDeviceConfiguration()]
virtualMachineConfiguration.keyboards = [helper.createKeyboardConfiguration()]
try! virtualMachineConfiguration.validate()
try! virtualMachineConfiguration.validateSaveRestoreSupport()
virtualMachine = VZVirtualMachine(configuration: virtualMachineConfiguration)
}
private func startInstallation(restoreImageURL: URL) {
let installer = VZMacOSInstaller(virtualMachine: virtualMachine, restoringFromImageAt: restoreImageURL)
print("Starting installation.")
installer.install(completionHandler: { (result: Result<Void, Error>) in
if case let .failure(error) = result {
fatalError(error.localizedDescription)
} else {
print("Installation succeeded.")
}
})
// Observe installation progress.
installationObserver = installer.progress.observe(\.fractionCompleted, options: [.initial, .new]) { (progress, change) in
print("Installation progress: \(change.newValue! * 100).")
}
}
// Create an empty disk image for the virtual machine.
private func createDiskImage() {
let diskFd = open(config.diskImageURL.path, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR)
if diskFd == -1 {
fatalError("Cannot create disk image.")
}
// 72 GB disk space.
var result = ftruncate(diskFd, config.diskSize)
if result != 0 {
fatalError("ftruncate() failed.")
}
result = close(diskFd)
if result != 0 {
fatalError("Failed to close the disk image.")
}
}
}

BIN
tstest/tailmac/Swift/TailMac/main Executable file

Binary file not shown.