mirror of
https://github.com/tailscale/tailscale.git
synced 2025-10-27 03:32:03 +00:00
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:
58
tstest/tailmac/Swift/TailMac/RestoreImage.swift
Normal file
58
tstest/tailmac/Swift/TailMac/RestoreImage.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
334
tstest/tailmac/Swift/TailMac/TailMac.swift
Normal file
334
tstest/tailmac/Swift/TailMac/TailMac.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
140
tstest/tailmac/Swift/TailMac/VMInstaller.swift
Normal file
140
tstest/tailmac/Swift/TailMac/VMInstaller.swift
Normal 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
BIN
tstest/tailmac/Swift/TailMac/main
Executable file
Binary file not shown.
Reference in New Issue
Block a user