diff --git a/Sources/tart/Commands/Create.swift b/Sources/tart/Commands/Create.swift index 3682472d..01f04e9e 100644 --- a/Sources/tart/Commands/Create.swift +++ b/Sources/tart/Commands/Create.swift @@ -9,15 +9,18 @@ struct Create: AsyncParsableCommand { @Argument(help: "VM name") 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? + @Flag(help: "create a Linux VM") + var linux: Bool = false + @Option(help: ArgumentHelp("Disk size in Gb")) var diskSize: UInt16 = 50 func validate() throws { - if fromIPSW == nil { - throw ValidationError("Please specify a --from-ipsw option!") + if fromIPSW == nil && !linux { + throw ValidationError("Please specify either a --from-ipsw or --linux option!") } } @@ -25,10 +28,20 @@ struct Create: AsyncParsableCommand { do { let tmpVMDir = try VMDirectory.temporary() try await withTaskCancellationHandler(operation: { - if fromIPSW! == "latest" { - _ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize) - } else { - _ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW!), diskSizeGB: diskSize) + if let fromIPSW = fromIPSW { + if fromIPSW == "latest" { + _ = try await VM(vmDir: tmpVMDir, ipswURL: nil, 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) diff --git a/Sources/tart/OCI/Manifest.swift b/Sources/tart/OCI/Manifest.swift index 6a79e05d..a3924122 100644 --- a/Sources/tart/OCI/Manifest.swift +++ b/Sources/tart/OCI/Manifest.swift @@ -46,8 +46,8 @@ struct OCIManifest: Codable, Equatable { } struct OCIConfig: Codable { - var architecture: String = "arm64" - var os: String = "darwin" + var architecture: Architecture = .arm64 + var os: OS = .darwin func toJSON() throws -> Data { try Config.jsonEncoder().encode(self) diff --git a/Sources/tart/Platform/Architecture.swift b/Sources/tart/Platform/Architecture.swift new file mode 100644 index 00000000..f74b7caf --- /dev/null +++ b/Sources/tart/Platform/Architecture.swift @@ -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 +} diff --git a/Sources/tart/Platform/Darwin.swift b/Sources/tart/Platform/Darwin.swift new file mode 100644 index 00000000..009cf016 --- /dev/null +++ b/Sources/tart/Platform/Darwin.swift @@ -0,0 +1,87 @@ +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 + } +} diff --git a/Sources/tart/Platform/Linux.swift b/Sources/tart/Platform/Linux.swift new file mode 100644 index 00000000..6938d5c9 --- /dev/null +++ b/Sources/tart/Platform/Linux.swift @@ -0,0 +1,33 @@ +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 + } +} diff --git a/Sources/tart/Platform/OS.swift b/Sources/tart/Platform/OS.swift new file mode 100644 index 00000000..13b94400 --- /dev/null +++ b/Sources/tart/Platform/OS.swift @@ -0,0 +1,6 @@ +import Virtualization + +enum OS: String, Codable { + case darwin + case linux +} diff --git a/Sources/tart/Platform/Platform.swift b/Sources/tart/Platform/Platform.swift new file mode 100644 index 00000000..b7255fc9 --- /dev/null +++ b/Sources/tart/Platform/Platform.swift @@ -0,0 +1,8 @@ +import Virtualization + +protocol Platform: Codable { + func os() -> OS + func bootLoader(nvramURL: URL) throws -> VZBootLoader + func platform(nvramURL: URL) -> VZPlatformConfiguration + func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration +} diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index 9864275e..da8324b9 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -10,6 +10,12 @@ struct NoMainScreenFoundError: Error { struct DownloadFailed: Error { } +struct UnsupportedOSError: Error { +} + +struct UnsupportedArchitectureError: Error { +} + class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Virtualization.Framework's virtual machine @Published var virtualMachine: VZVirtualMachine @@ -29,17 +35,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { withSoftnet: Bool = false, additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [] ) throws { - let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL) - name = vmDir.name config = try VMConfig.init(fromURL: vmDir.configURL) + if config.arch != CurrentArchitecture() { + throw UnsupportedArchitectureError() + } + // Initialize the virtual machine and its configuration if withSoftnet { 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) virtualMachine = VZVirtualMachine(configuration: configuration) @@ -116,7 +125,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { } // Create NVRAM - let auxStorage = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel) + _ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel) // Create disk try vmDir.resizeDisk(diskSizeGB) @@ -124,7 +133,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { name = vmDir.name // Create config config = VMConfig( - hardwareModel: requirements.hardwareModel, + platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel), cpuCountMin: requirements.minimumSupportedCPUCount, memorySizeMin: requirements.minimumSupportedMemorySize ) @@ -137,8 +146,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { softnet = try Softnet(vmMACAddress: config.macAddress.string) } - let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config, - softnet: softnet, additionalDiskAttachments: additionalDiskAttachments) + let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, + vmConfig: config, softnet: softnet, + additionalDiskAttachments: additionalDiskAttachments) virtualMachine = VZVirtualMachine(configuration: configuration) super.init() @@ -159,6 +169,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 { if let softnet = softnet { try softnet.run() @@ -199,7 +224,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { static func craftConfiguration( diskURL: URL, - auxStorage: VZMacAuxiliaryStorage, + nvramURL: URL, vmConfig: VMConfig, softnet: Softnet? = nil, additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] @@ -207,42 +232,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { let configuration = VZVirtualMachineConfiguration() // Boot loader - configuration.bootLoader = VZMacOSBootLoader() + configuration.bootLoader = try vmConfig.platform.bootLoader(nvramURL: nvramURL) // CPU and memory configuration.cpuCount = vmConfig.cpuCount configuration.memorySize = vmConfig.memorySize // Platform - let platform = VZMacPlatformConfiguration() - - platform.machineIdentifier = vmConfig.ecid - platform.auxiliaryStorage = auxStorage - platform.hardwareModel = vmConfig.hardwareModel - - configuration.platform = platform + configuration.platform = vmConfig.platform.platform(nvramURL: nvramURL) // Display - let graphicsDeviceConfiguration = VZMacGraphicsDeviceConfiguration() - 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] + configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)] // Audio let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration() diff --git a/Sources/tart/VMConfig.swift b/Sources/tart/VMConfig.swift index c552ede1..ad2c2f7e 100644 --- a/Sources/tart/VMConfig.swift +++ b/Sources/tart/VMConfig.swift @@ -16,14 +16,18 @@ class LessThanMinimalResourcesError: NSObject, LocalizedError { enum CodingKeys: String, CodingKey { case version - case ecid - case hardwareModel + case os + case arch case cpuCountMin case cpuCount case memorySizeMin case memorySize case macAddress case display + + // macOS-specific keys + case ecid + case hardwareModel } struct VMDisplayConfig: Codable { @@ -33,25 +37,25 @@ struct VMDisplayConfig: Codable { struct VMConfig: Codable { var version: Int = 1 - var ecid: VZMacMachineIdentifier - var hardwareModel: VZMacHardwareModel + var os: OS + var arch: Architecture + var platform: Platform var cpuCountMin: Int private(set) var cpuCount: Int var memorySizeMin: UInt64 private(set) var memorySize: UInt64 var macAddress: VZMACAddress - var display: VMDisplayConfig = VMDisplayConfig() init( - ecid: VZMacMachineIdentifier = VZMacMachineIdentifier(), - hardwareModel: VZMacHardwareModel, - cpuCountMin: Int, - memorySizeMin: UInt64, - macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered() + platform: Platform, + cpuCountMin: Int, + memorySizeMin: UInt64, + macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered() ) { - self.ecid = ecid - self.hardwareModel = hardwareModel + self.os = platform.os() + self.arch = CurrentArchitecture() + self.platform = platform self.macAddress = macAddress self.cpuCountMin = cpuCountMin self.memorySizeMin = memorySizeMin @@ -81,29 +85,18 @@ struct VMConfig: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) version = try container.decode(Int.self, forKey: .version) - - 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") + os = try container.decodeIfPresent(OS.self, forKey: .os) ?? .darwin + arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64 + switch os { + case .darwin: + platform = try Darwin(from: decoder) + 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) cpuCount = try container.decode(Int.self, forKey: .cpuCount) memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin) @@ -125,8 +118,9 @@ struct VMConfig: Codable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(version, forKey: .version) - try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid) - try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel) + try container.encode(os, forKey: .os) + try container.encode(arch, forKey: .arch) + try platform.encode(to: encoder) try container.encode(cpuCountMin, forKey: .cpuCountMin) try container.encode(cpuCount, forKey: .cpuCount) try container.encode(memorySizeMin, forKey: .memorySizeMin) diff --git a/Sources/tart/VMDirectory+OCI.swift b/Sources/tart/VMDirectory+OCI.swift index c6f35d2b..e3a05ca0 100644 --- a/Sources/tart/VMDirectory+OCI.swift +++ b/Sources/tart/VMDirectory+OCI.swift @@ -142,7 +142,7 @@ extension VMDirectory { layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest)) // 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 manifest = OCIManifest( config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),