From 01d9359fbde825b98da263cefca5d6e1bf8864b8 Mon Sep 17 00:00:00 2001 From: Randolph Voorhies Date: Tue, 28 Jul 2026 13:35:48 -0700 Subject: [PATCH] Support suspending and resuming Linux VMs Persist a VZGenericMachineIdentifier in Linux VM configurations and reuse it when building VZGenericPlatformConfiguration. Saved machine state is tied to this identifier, so generating a new one prevents restoration. Legacy configurations remain loadable but cannot be suspended. When cloning a stopped legacy Linux VM, initialize the missing identifier in the clone. Existing identifiers and suspended state remain unchanged. Linux now conforms to PlatformSuspendable and retains USB keyboard support in suspendable mode, but omits VZUSBScreenCoordinatePointingDeviceConfiguration because it passes save/restore validation but causes restoration to fail. --- Sources/tart/Commands/Clone.swift | 11 +++++- Sources/tart/Commands/Run.swift | 10 ++++-- Sources/tart/Platform/Linux.swift | 52 ++++++++++++++++++++++++++++- Sources/tart/VMConfig.swift | 3 ++ Sources/tart/VMDirectory.swift | 17 ++++++++++ Tests/TartTests/VMConfigTests.swift | 52 +++++++++++++++++++++++++++++ 6 files changed, 141 insertions(+), 4 deletions(-) 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") + } }