diff --git a/Sources/tart/Commands/Clone.swift b/Sources/tart/Commands/Clone.swift index b6497e2f..f14ee51d 100644 --- a/Sources/tart/Commands/Clone.swift +++ b/Sources/tart/Commands/Clone.swift @@ -66,10 +66,19 @@ struct Clone: AsyncParsableCommand { let lock = try FileLock(lockURL: Config().tartHomeDir) try lock.lock() + let sourceState = try sourceVM.state() + let sourceConfig = try VMConfig(fromURL: sourceVM.configURL) + let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress()) - && sourceVM.state() != .Suspended + && sourceState != .Suspended try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC) + if sourceState != .Suspended, + let linux = sourceConfig.platform as? Linux, + linux.machineIdentifier == nil { + try tmpVMDir.initializeLinuxMachineIdentifier() + } + try localStorage.move(newName, from: tmpVMDir) try lock.unlock() diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index ad22bf18..95eacd18 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -271,7 +271,9 @@ struct Run: AsyncParsableCommand { var rootDiskOpts: String = "" #if arch(arm64) - @Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\".")) + @Flag( + help: ArgumentHelp("Disables or replaces devices that do not support VM suspension, such as audio, entropy and some input devices.", + discussion: "Useful for running a VM that can be suspended via \"tart suspend\".")) #endif var suspendable: Bool = false @@ -359,7 +361,11 @@ struct Run: AsyncParsableCommand { if suspendable { let config = try VMConfig.init(fromURL: vmDir.configURL) if !(config.platform is PlatformSuspendable) { - throw ValidationError("You can only suspend macOS VMs") + throw ValidationError("This platform is not suspendable") + } + + if let linux = config.platform as? Linux, linux.machineIdentifier == nil { + throw ValidationError("Linux VMs without a machine identifier cannot be suspended or resumed") } if noTrackpad { diff --git a/Sources/tart/Platform/Linux.swift b/Sources/tart/Platform/Linux.swift index 4636710f..1362c5e9 100644 --- a/Sources/tart/Platform/Linux.swift +++ b/Sources/tart/Platform/Linux.swift @@ -1,7 +1,42 @@ import Virtualization @available(macOS 13, *) -struct Linux: Platform { +struct Linux: PlatformSuspendable { + + var machineIdentifier: VZGenericMachineIdentifier? + + init(machineIdentifier: VZGenericMachineIdentifier = VZGenericMachineIdentifier()) { + self.machineIdentifier = machineIdentifier + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + guard let encodedMachineIdentifier = try container.decodeIfPresent( + String.self, forKey: .machineIdentifier + ) else { + self.machineIdentifier = nil + return + } + guard let data = Data.init(base64Encoded: encodedMachineIdentifier) else { + throw DecodingError.dataCorruptedError(forKey: .machineIdentifier, + in: container, + debugDescription: "failed to initialize Data using the provided value") + } + guard let machineIdentifier = VZGenericMachineIdentifier.init(dataRepresentation: data) else { + throw DecodingError.dataCorruptedError(forKey: .machineIdentifier, + in: container, + debugDescription: "failed to initialize VZGenericMachineIdentifier using the provided value") + } + self.machineIdentifier = machineIdentifier + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(machineIdentifier?.dataRepresentation.base64EncodedString(), forKey: .machineIdentifier) + } + func os() -> OS { .linux } @@ -16,6 +51,11 @@ struct Linux: Platform { func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration { let config = VZGenericPlatformConfiguration() + + if let machineIdentifier { + config.machineIdentifier = machineIdentifier + } + if #available(macOS 15, *) { config.isNestedVirtualizationEnabled = needsNestedVirtualization } @@ -47,4 +87,14 @@ struct Linux: Platform { // Linux doesn't support trackpad, so just return the regular pointing devices return pointingDevices() } + + func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] { + // VZUSBScreenCoordinatePointingDeviceConfiguration passes save/restore + // validation, but causes restoring a Linux VM to fail with "invalid argument". + [] + } + + func keyboardsSuspendable() -> [VZKeyboardConfiguration] { + keyboards() + } } diff --git a/Sources/tart/VMConfig.swift b/Sources/tart/VMConfig.swift index c6e9ba9a..62ef5849 100644 --- a/Sources/tart/VMConfig.swift +++ b/Sources/tart/VMConfig.swift @@ -30,6 +30,9 @@ enum CodingKeys: String, CodingKey { // macOS-specific keys case ecid case hardwareModel + + // Linux-specific keys + case machineIdentifier } struct VMDisplayConfig: Codable, Equatable { diff --git a/Sources/tart/VMDirectory.swift b/Sources/tart/VMDirectory.swift index 63196322..af1c7616 100644 --- a/Sources/tart/VMDirectory.swift +++ b/Sources/tart/VMDirectory.swift @@ -142,6 +142,23 @@ struct VMDirectory: Prunable { try vmConfig.save(toURL: configURL) } + func initializeLinuxMachineIdentifier() throws { + var vmConfig = try VMConfig(fromURL: configURL) + guard var vmLinux = vmConfig.platform as? Linux else { + throw RuntimeError.VMConfigurationError("cannot initialize a Linux machine identifier on a non-Linux VM") + } + guard vmLinux.machineIdentifier == nil else { + throw RuntimeError.VMConfigurationError( + "cannot initialize a Linux machine identifier when one already exists" + ) + } + + vmLinux.machineIdentifier = VZGenericMachineIdentifier() + vmConfig.platform = vmLinux + + try vmConfig.save(toURL: configURL) + } + func resizeDisk(_ sizeGB: UInt16, format: DiskImageFormat = .raw) throws { let diskExists = FileManager.default.fileExists(atPath: diskURL.path) diff --git a/Tests/TartTests/VMConfigTests.swift b/Tests/TartTests/VMConfigTests.swift index 34fb9012..56846707 100644 --- a/Tests/TartTests/VMConfigTests.swift +++ b/Tests/TartTests/VMConfigTests.swift @@ -1,6 +1,9 @@ import XCTest @testable import tart +import Foundation +import Virtualization + final class VMConfigTests: XCTestCase { func testVMDisplayConfig() throws { // Defaults units (points) @@ -15,4 +18,53 @@ final class VMConfigTests: XCTestCase { vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678px") XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: .pixel), vmDisplayConfig) } + + func testLinuxMachineIdentifierSerialization() throws { + let originalIdentifier = VZGenericMachineIdentifier() + let originalConfig = VMConfig( + platform: Linux(machineIdentifier: originalIdentifier), cpuCountMin: 2, memorySizeMin: 1024 * 1024 * 1024 + ) + let encodedConfigData = try originalConfig.toJSON() + + let decodedConfig = try VMConfig(fromJSON: encodedConfigData) + let decodedLinux = try XCTUnwrap(decodedConfig.platform as? Linux) + let decodedMachineIdentifier = try XCTUnwrap(decodedLinux.machineIdentifier) + + XCTAssertEqual( + decodedMachineIdentifier.dataRepresentation, + originalIdentifier.dataRepresentation, + "decoded machine identifier should match original identifier" + ) + + let platformConfiguration = try decodedLinux.platform( + nvramURL: URL(fileURLWithPath: "/dev/null"), + needsNestedVirtualization: false + ) + let decodedPlatformConfiguration = try XCTUnwrap( + platformConfiguration as? VZGenericPlatformConfiguration + ) + + XCTAssertEqual( + decodedPlatformConfiguration.machineIdentifier.dataRepresentation, + originalIdentifier.dataRepresentation, + "platform configuration should reuse decoded machine identifier" + ) + } + + func testLegacyLinuxConfigWithoutMachineIdentifier() throws { + let originalIdentifier = VZGenericMachineIdentifier() + let originalConfig = VMConfig( + platform: Linux(machineIdentifier: originalIdentifier), cpuCountMin: 2, memorySizeMin: 1024 * 1024 * 1024 + ) + let encodedConfigData = try originalConfig.toJSON() + + var configJSONObject = try XCTUnwrap(JSONSerialization.jsonObject(with: encodedConfigData) as? [String: Any]) + configJSONObject.removeValue(forKey: "machineIdentifier") + let legacyConfigData = try JSONSerialization.data(withJSONObject: configJSONObject) + + let decodedConfig = try VMConfig(fromJSON: legacyConfigData) + let decodedLinux = try XCTUnwrap(decodedConfig.platform as? Linux) + + XCTAssertNil(decodedLinux.machineIdentifier, "missing machineIdentifier should be decoded as nil") + } }