mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-30 05:25:35 +00:00
8fad8c4b9b
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>
335 lines
11 KiB
Swift
335 lines
11 KiB
Swift
// 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()
|
|
}
|
|
}
|
|
}
|