Compare commits

..
6 Commits
Author SHA1 Message Date
Fedor Korotkov c54b140750 Support Docker Helpers (#205)
Fixes #167
2022-08-29 20:26:53 +04:00
Fedor Korotkov 048a5506df Support trackpad on macOS and clipboard sharing on Linux (#202)
Fixes #66
Relates to #14 since fixes on Linux
2022-08-27 20:05:45 +04:00
Nikolay Edigaryev 553b36349b README.md: clarify "Pulling a Remote Image" section (#196) 2022-08-25 15:43:30 +04:00
Fedor Korotkov d0bf286aa1 Document Linux VM image creation (#193) 2022-08-24 23:21:50 +04:00
Nikolay Edigaryev 195a4b3a72 Make UnsupportedOSError more user-friendly (#192)
* Make UnsupportedOSError more user-friendly

* tart prune: allow only specifying a --gc flag
2022-08-24 15:03:54 -04:00
Nikolay Edigaryev 59393df2e1 Linux support (#190)
* Linux support

* Support macOS 12

* Improve readability a bit

* Remove useless variable
2022-08-23 22:38:50 +04:00
17 changed files with 392 additions and 113 deletions
+38 -2
View File
@@ -1,6 +1,6 @@
![Tart – open source virtualization for your automation needs](Resources/TartSocial.png) ![Tart – open source virtualization for your automation needs](Resources/TartSocial.png)
*Tart* is a virtualization toolset to build, run and manage virtual machines on Apple Silicon. *Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
Built by CI engineers for your automation needs. Here are some highlights of Tart: Built by CI engineers for your automation needs. Here are some highlights of Tart:
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/14966395?baseline=14966339). * Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/14966395?baseline=14966339).
@@ -84,6 +84,11 @@ For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/bina
### Creating from scratch ### Creating from scratch
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regarding of the underlying OS a particular VM image has.
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
#### Creating a macOS VM image from scratch
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
use `latest` instead of a path to `*.ipsw` to download the latest available version: use `latest` instead of a path to `*.ipsw` to download the latest available version:
@@ -100,6 +105,27 @@ After the initial booting of the VM you'll need to manually go through the macOS
4. Disable Screen Saver. 4. Disable Screen Saver.
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password. 5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
#### Creating a Linux VM image from scratch
```bash
# Create a bare VM
tart create --linux ubuntu
# Install Ubuntu
tart run --disk focal-desktop-arm64.iso ubuntu
# Run VM
tart run ubuntu
```
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
```shell
sudo apt update
sudo apt install -y openssh-server
sudo ufw allow ssh
```
### Configuring a VM ### Configuring a VM
By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command. By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command.
@@ -165,10 +191,20 @@ tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:
#### Pulling a Remote Image #### Pulling a Remote Image
You can either pull an image:
```shell ```shell
tart pull acme.io/remoteorg/name:latest my-local-vm-name tart pull acme.io/remoteorg/name:latest
``` ```
...or instantiate a VM from a remote image:
```shell
tart clone acme.io/remoteorg/name:latest my-local-vm-name
```
This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
## FAQ ## FAQ
<details> <details>
+20 -7
View File
@@ -9,15 +9,18 @@ struct Create: AsyncParsableCommand {
@Argument(help: "VM name") @Argument(help: "VM name")
var name: String var name: String
@Option(help: ArgumentHelp("Path to the IPSW file (or \"latest\") to fetch the latest appropriate IPSW", valueName: "path")) @Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file (or \"latest\") to fetch the latest appropriate IPSW", valueName: "path"))
var fromIPSW: String? var fromIPSW: String?
@Flag(help: "create a Linux VM")
var linux: Bool = false
@Option(help: ArgumentHelp("Disk size in Gb")) @Option(help: ArgumentHelp("Disk size in Gb"))
var diskSize: UInt16 = 50 var diskSize: UInt16 = 50
func validate() throws { func validate() throws {
if fromIPSW == nil { if fromIPSW == nil && !linux {
throw ValidationError("Please specify a --from-ipsw option!") throw ValidationError("Please specify either a --from-ipsw or --linux option!")
} }
} }
@@ -25,10 +28,20 @@ struct Create: AsyncParsableCommand {
do { do {
let tmpVMDir = try VMDirectory.temporary() let tmpVMDir = try VMDirectory.temporary()
try await withTaskCancellationHandler(operation: { try await withTaskCancellationHandler(operation: {
if fromIPSW! == "latest" { if let fromIPSW = fromIPSW {
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize) if fromIPSW == "latest" {
} else { _ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW!), diskSizeGB: diskSize) } else {
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW), diskSizeGB: diskSize)
}
}
if linux {
if #available(macOS 13, *) {
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
} else {
throw UnsupportedOSError()
}
} }
try VMStorageLocal().move(name, from: tmpVMDir) try VMStorageLocal().move(name, from: tmpVMDir)
+1 -1
View File
@@ -45,7 +45,7 @@ struct Login: AsyncParsableCommand {
do { do {
let registry = try Registry(host: host, namespace: "", insecure: insecure, let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProvider: credentialsProvider) credentialsProviders: [credentialsProvider])
try await registry.ping() try await registry.ping()
} catch { } catch {
print("invalid credentials: \(error)") print("invalid credentials: \(error)")
+2 -2
View File
@@ -20,8 +20,8 @@ struct Prune: AsyncParsableCommand {
var gc: Bool = false var gc: Bool = false
func validate() throws { func validate() throws {
if olderThan == nil && cacheBudget == nil { if olderThan == nil && cacheBudget == nil && !gc {
throw ValidationError("at least one criteria must be specified") throw ValidationError("at least one pruning criteria must be specified")
} }
} }
@@ -0,0 +1,63 @@
import Foundation
class HelperProgramCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? {
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
return nil
}
let config = try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: dockerConfigURL))
if let helperProgram = config.credHelpers[host] {
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
}
return nil
}
private func executeHelper(binaryName: String, host: String) throws -> (String, String)? {
guard let executableURL = resolveBinaryPath(binaryName) else {
throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH")
}
let process = Process.init()
process.executableURL = executableURL
process.arguments = ["get"]
let outPipe = Pipe()
let inPipe = Pipe()
process.standardOutput = outPipe
process.standardError = outPipe
process.standardInput = inPipe
process.launch()
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
inPipe.fileHandleForWriting.closeFile()
process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
}
let getOutput = try JSONDecoder().decode(
DockerGetOutput.self, from: outPipe.fileHandleForReading.readDataToEndOfFile()
)
return (getOutput.Username, getOutput.Secret)
}
func store(host: String, user: String, password: String) throws {
throw CredentialsProviderError.Failed(message: "Docker helpers don't support storing!")
}
}
struct DockerConfig: Codable {
var credHelpers: Dictionary<String, String> = Dictionary()
}
struct DockerGetOutput: Codable {
var Username: String
var Secret: String
}
+2 -2
View File
@@ -46,8 +46,8 @@ struct OCIManifest: Codable, Equatable {
} }
struct OCIConfig: Codable { struct OCIConfig: Codable {
var architecture: String = "arm64" var architecture: Architecture = .arm64
var os: String = "darwin" var os: OS = .darwin
func toJSON() throws -> Data { func toJSON() throws -> Data {
try Config.jsonEncoder().encode(self) try Config.jsonEncoder().encode(self)
+16 -7
View File
@@ -88,29 +88,29 @@ class Registry {
let baseURL: URL let baseURL: URL
let namespace: String let namespace: String
let credentialsProvider: CredentialsProvider let credentialsProviders: [CredentialsProvider]
var currentAuthToken: Authentication? = nil var currentAuthToken: Authentication? = nil
init(urlComponents: URLComponents, init(urlComponents: URLComponents,
namespace: String, namespace: String,
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider() credentialsProviders: [CredentialsProvider] = [HelperProgramCredentialsProvider(), KeychainCredentialsProvider()]
) throws { ) throws {
baseURL = urlComponents.url! baseURL = urlComponents.url!
self.namespace = namespace self.namespace = namespace
self.credentialsProvider = credentialsProvider self.credentialsProviders = credentialsProviders
} }
convenience init( convenience init(
host: String, host: String,
namespace: String, namespace: String,
insecure: Bool = false, insecure: Bool = false,
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider() credentialsProviders: [CredentialsProvider] = [HelperProgramCredentialsProvider(), KeychainCredentialsProvider()]
) throws { ) throws {
let proto = insecure ? "http" : "https" let proto = insecure ? "http" : "https"
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")! let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProvider: credentialsProvider) try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProviders: credentialsProviders)
} }
func ping() async throws { func ping() async throws {
@@ -303,7 +303,7 @@ class Registry {
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw) let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
if wwwAuthenticate.scheme == "Basic" { if wwwAuthenticate.scheme == "Basic" {
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) { if let (user, password) = try lookupCredentials(host: baseURL.host!) {
currentAuthToken = BasicAuthentication(user: user, password: password) currentAuthToken = BasicAuthentication(user: user, password: password)
} }
@@ -340,7 +340,7 @@ class Registry {
var headers: Dictionary<String, String> = Dictionary() var headers: Dictionary<String, String> = Dictionary()
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) { if let (user, password) = try lookupCredentials(host: baseURL.host!) {
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString() let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
headers["Authorization"] = "Basic \(encodedCredentials!)" headers["Authorization"] = "Basic \(encodedCredentials!)"
} }
@@ -356,6 +356,15 @@ class Registry {
currentAuthToken = try TokenResponse.parse(fromData: bodyData) currentAuthToken = try TokenResponse.parse(fromData: bodyData)
} }
private func lookupCredentials(host: String) throws -> (String, String)? {
for provider in credentialsProviders {
if let (user, password) = try provider.retrieve(host: host) {
return (user, password)
}
}
return nil
}
private func authAwareRequest(request: HTTPClientRequest) async throws -> HTTPClientResponse { private func authAwareRequest(request: HTTPClientRequest) async throws -> HTTPClientResponse {
var request = request var request = request
+14
View File
@@ -0,0 +1,14 @@
import Foundation
enum Architecture: String, Codable {
case arm64
case amd64
}
func CurrentArchitecture() -> Architecture {
#if arch(arm64)
return .arm64
#elseif arch(x86_64)
return .amd64
#endif
}
+97
View File
@@ -0,0 +1,97 @@
import Virtualization
struct Darwin: Platform {
var ecid: VZMacMachineIdentifier
var hardwareModel: VZMacHardwareModel
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
self.ecid = ecid
self.hardwareModel = hardwareModel
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let encodedECID = try container.decode(String.self, forKey: .ecid)
guard let data = Data.init(base64Encoded: encodedECID) else {
throw DecodingError.dataCorruptedError(forKey: .ecid,
in: container,
debugDescription: "failed to initialize Data using the provided value")
}
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .ecid,
in: container,
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
}
self.ecid = ecid
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
}
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
}
self.hardwareModel = hardwareModel
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
}
func os() -> OS {
.darwin
}
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
VZMacOSBootLoader()
}
func platform(nvramURL: URL) -> VZPlatformConfiguration {
let result = VZMacPlatformConfiguration()
result.machineIdentifier = ecid
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
result.hardwareModel = hardwareModel
return result
}
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZMacGraphicsDeviceConfiguration()
if let hostMainScreen = NSScreen.main {
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
result.displays = [
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
]
return result
}
result.displays = [
VZMacGraphicsDisplayConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height,
// A reasonable guess according to Apple's documentation[1]
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
pixelsPerInch: 72
)
]
return result
}
func pointingDevices() -> [VZPointingDeviceConfiguration] {
if #available(macOS 13, *) {
// Trackpad is only supported starting with macOS Ventura
// macOS Monterey will continue using a USB device == .darwin
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
} else {
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import Virtualization
@available(macOS 13, *)
struct Linux: Platform {
func os() -> OS {
.linux
}
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
let result = VZEFIBootLoader()
result.variableStore = VZEFIVariableStore(url: nvramURL)
return result
}
func platform(nvramURL: URL) -> VZPlatformConfiguration {
VZGenericPlatformConfiguration()
}
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZVirtioGraphicsDeviceConfiguration()
result.scanouts = [
VZVirtioGraphicsScanoutConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height
)
]
return result
}
func pointingDevices() -> [VZPointingDeviceConfiguration] {
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
}
+6
View File
@@ -0,0 +1,6 @@
import Virtualization
enum OS: String, Codable {
case darwin
case linux
}
+9
View File
@@ -0,0 +1,9 @@
import Virtualization
protocol Platform: Codable {
func os() -> OS
func bootLoader(nvramURL: URL) throws -> VZBootLoader
func platform(nvramURL: URL) -> VZPlatformConfiguration
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
func pointingDevices() -> [VZPointingDeviceConfiguration]
}
+1 -18
View File
@@ -12,7 +12,7 @@ class Softnet {
init(vmMACAddress: String) throws { init(vmMACAddress: String) throws {
let binaryName = "softnet" let binaryName = "softnet"
guard let executableURL = Self.resolveBinaryPath(binaryName) else { guard let executableURL = resolveBinaryPath(binaryName) else {
throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH") throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH")
} }
@@ -43,23 +43,6 @@ class Softnet {
process.waitUntilExit() process.waitUntilExit()
} }
private static func resolveBinaryPath(_ name: String) -> URL? {
guard let path = ProcessInfo.processInfo.environment["PATH"] else {
return nil
}
for pathComponent in path.split(separator: ":") {
let url = URL(fileURLWithPath: String(pathComponent))
.appendingPathComponent(name, isDirectory: false)
if FileManager.default.fileExists(atPath: url.path) {
return url
}
}
return nil
}
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws { private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
var option_value = sizeBytes var option_value = sizeBytes
let option_len = socklen_t(MemoryLayout<Int>.size) let option_len = socklen_t(MemoryLayout<Int>.size)
+17
View File
@@ -5,3 +5,20 @@ extension Collection {
indices.contains(index) ? self[index] : nil indices.contains(index) ? self[index] : nil
} }
} }
func resolveBinaryPath(_ name: String) -> URL? {
guard let path = ProcessInfo.processInfo.environment["PATH"] else {
return nil
}
for pathComponent in path.split(separator: ":") {
let url = URL(fileURLWithPath: String(pathComponent))
.appendingPathComponent(name, isDirectory: false)
if FileManager.default.fileExists(atPath: url.path) {
return url
}
}
return nil
}
+38 -37
View File
@@ -10,6 +10,13 @@ struct NoMainScreenFoundError: Error {
struct DownloadFailed: Error { struct DownloadFailed: Error {
} }
struct UnsupportedOSError: Error, CustomStringConvertible {
private(set) var description: String = "error: Linux VMs are only supported on macOS 13.0 (Ventura) or newer"
}
struct UnsupportedArchitectureError: Error {
}
class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Virtualization.Framework's virtual machine // Virtualization.Framework's virtual machine
@Published var virtualMachine: VZVirtualMachine @Published var virtualMachine: VZVirtualMachine
@@ -29,17 +36,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
withSoftnet: Bool = false, withSoftnet: Bool = false,
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [] additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
) throws { ) throws {
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
name = vmDir.name name = vmDir.name
config = try VMConfig.init(fromURL: vmDir.configURL) config = try VMConfig.init(fromURL: vmDir.configURL)
if config.arch != CurrentArchitecture() {
throw UnsupportedArchitectureError()
}
// Initialize the virtual machine and its configuration // Initialize the virtual machine and its configuration
if withSoftnet { if withSoftnet {
softnet = try Softnet(vmMACAddress: config.macAddress.string) softnet = try Softnet(vmMACAddress: config.macAddress.string)
} }
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config, let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
nvramURL: vmDir.nvramURL, vmConfig: config,
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments) softnet: softnet, additionalDiskAttachments: additionalDiskAttachments)
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
@@ -116,7 +126,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
// Create NVRAM // Create NVRAM
let auxStorage = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel) _ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
// Create disk // Create disk
try vmDir.resizeDisk(diskSizeGB) try vmDir.resizeDisk(diskSizeGB)
@@ -124,7 +134,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
name = vmDir.name name = vmDir.name
// Create config // Create config
config = VMConfig( config = VMConfig(
hardwareModel: requirements.hardwareModel, platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
cpuCountMin: requirements.minimumSupportedCPUCount, cpuCountMin: requirements.minimumSupportedCPUCount,
memorySizeMin: requirements.minimumSupportedMemorySize memorySizeMin: requirements.minimumSupportedMemorySize
) )
@@ -137,8 +147,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
softnet = try Softnet(vmMACAddress: config.macAddress.string) softnet = try Softnet(vmMACAddress: config.macAddress.string)
} }
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config, let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments) vmConfig: config, softnet: softnet,
additionalDiskAttachments: additionalDiskAttachments)
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
super.init() super.init()
@@ -159,6 +170,21 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
} }
@available(macOS 13, *)
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
// Create NVRAM
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
// Create disk
try vmDir.resizeDisk(diskSizeGB)
// Create config
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024)
try config.save(toURL: vmDir.configURL)
return try VM(vmDir: vmDir)
}
func run(_ recovery: Bool) async throws { func run(_ recovery: Bool) async throws {
if let softnet = softnet { if let softnet = softnet {
try softnet.run() try softnet.run()
@@ -199,7 +225,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
static func craftConfiguration( static func craftConfiguration(
diskURL: URL, diskURL: URL,
auxStorage: VZMacAuxiliaryStorage, nvramURL: URL,
vmConfig: VMConfig, vmConfig: VMConfig,
softnet: Softnet? = nil, softnet: Softnet? = nil,
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment]
@@ -207,42 +233,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
let configuration = VZVirtualMachineConfiguration() let configuration = VZVirtualMachineConfiguration()
// Boot loader // Boot loader
configuration.bootLoader = VZMacOSBootLoader() configuration.bootLoader = try vmConfig.platform.bootLoader(nvramURL: nvramURL)
// CPU and memory // CPU and memory
configuration.cpuCount = vmConfig.cpuCount configuration.cpuCount = vmConfig.cpuCount
configuration.memorySize = vmConfig.memorySize configuration.memorySize = vmConfig.memorySize
// Platform // Platform
let platform = VZMacPlatformConfiguration() configuration.platform = vmConfig.platform.platform(nvramURL: nvramURL)
platform.machineIdentifier = vmConfig.ecid
platform.auxiliaryStorage = auxStorage
platform.hardwareModel = vmConfig.hardwareModel
configuration.platform = platform
// Display // Display
let graphicsDeviceConfiguration = VZMacGraphicsDeviceConfiguration() configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
if let hostMainScreen = NSScreen.main {
let vmScreenSize = NSSize(
width: vmConfig.display.width,
height: vmConfig.display.height
)
graphicsDeviceConfiguration.displays = [
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
]
} else {
graphicsDeviceConfiguration.displays = [
VZMacGraphicsDisplayConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height,
// Reasonable guess like https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
pixelsPerInch: 72
)
]
}
configuration.graphicsDevices = [graphicsDeviceConfiguration]
// Audio // Audio
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration() let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
@@ -255,7 +256,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Keyboard and mouse // Keyboard and mouse
configuration.keyboards = [VZUSBKeyboardConfiguration()] configuration.keyboards = [VZUSBKeyboardConfiguration()]
configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()] configuration.pointingDevices = vmConfig.platform.pointingDevices()
// Networking // Networking
let vio = VZVirtioNetworkDeviceConfiguration() let vio = VZVirtioNetworkDeviceConfiguration()
+30 -36
View File
@@ -16,14 +16,18 @@ class LessThanMinimalResourcesError: NSObject, LocalizedError {
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case version case version
case ecid case os
case hardwareModel case arch
case cpuCountMin case cpuCountMin
case cpuCount case cpuCount
case memorySizeMin case memorySizeMin
case memorySize case memorySize
case macAddress case macAddress
case display case display
// macOS-specific keys
case ecid
case hardwareModel
} }
struct VMDisplayConfig: Codable { struct VMDisplayConfig: Codable {
@@ -33,25 +37,25 @@ struct VMDisplayConfig: Codable {
struct VMConfig: Codable { struct VMConfig: Codable {
var version: Int = 1 var version: Int = 1
var ecid: VZMacMachineIdentifier var os: OS
var hardwareModel: VZMacHardwareModel var arch: Architecture
var platform: Platform
var cpuCountMin: Int var cpuCountMin: Int
private(set) var cpuCount: Int private(set) var cpuCount: Int
var memorySizeMin: UInt64 var memorySizeMin: UInt64
private(set) var memorySize: UInt64 private(set) var memorySize: UInt64
var macAddress: VZMACAddress var macAddress: VZMACAddress
var display: VMDisplayConfig = VMDisplayConfig() var display: VMDisplayConfig = VMDisplayConfig()
init( init(
ecid: VZMacMachineIdentifier = VZMacMachineIdentifier(), platform: Platform,
hardwareModel: VZMacHardwareModel, cpuCountMin: Int,
cpuCountMin: Int, memorySizeMin: UInt64,
memorySizeMin: UInt64, macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
) { ) {
self.ecid = ecid self.os = platform.os()
self.hardwareModel = hardwareModel self.arch = CurrentArchitecture()
self.platform = platform
self.macAddress = macAddress self.macAddress = macAddress
self.cpuCountMin = cpuCountMin self.cpuCountMin = cpuCountMin
self.memorySizeMin = memorySizeMin self.memorySizeMin = memorySizeMin
@@ -81,29 +85,18 @@ struct VMConfig: Codable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
version = try container.decode(Int.self, forKey: .version) version = try container.decode(Int.self, forKey: .version)
os = try container.decodeIfPresent(OS.self, forKey: .os) ?? .darwin
let encodedECID = try container.decode(String.self, forKey: .ecid) arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
guard let data = Data.init(base64Encoded: encodedECID) else { switch os {
throw DecodingError.dataCorruptedError(forKey: .ecid, case .darwin:
in: container, platform = try Darwin(from: decoder)
debugDescription: "failed to initialize Data using the provided value") case .linux:
if #available(macOS 13, *) {
platform = try Linux(from: decoder)
} else {
throw UnsupportedOSError()
}
} }
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .ecid,
in: container,
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
}
self.ecid = ecid
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
}
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
}
self.hardwareModel = hardwareModel
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin) cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
cpuCount = try container.decode(Int.self, forKey: .cpuCount) cpuCount = try container.decode(Int.self, forKey: .cpuCount)
memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin) memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin)
@@ -125,8 +118,9 @@ struct VMConfig: Codable {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(version, forKey: .version) try container.encode(version, forKey: .version)
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid) try container.encode(os, forKey: .os)
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel) try container.encode(arch, forKey: .arch)
try platform.encode(to: encoder)
try container.encode(cpuCountMin, forKey: .cpuCountMin) try container.encode(cpuCountMin, forKey: .cpuCountMin)
try container.encode(cpuCount, forKey: .cpuCount) try container.encode(cpuCount, forKey: .cpuCount)
try container.encode(memorySizeMin, forKey: .memorySizeMin) try container.encode(memorySizeMin, forKey: .memorySizeMin)
+1 -1
View File
@@ -142,7 +142,7 @@ extension VMDirectory {
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest)) layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
// Craft a stub OCI config for Docker Hub compatibility // Craft a stub OCI config for Docker Hub compatibility
let ociConfigJSON = try OCIConfig().toJSON() let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb) let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
let manifest = OCIManifest( let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest), config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),