mirror of https://github.com/cirruslabs/tart.git
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.
This commit is contained in:
parent
cbc160a592
commit
01d9359fbd
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ enum CodingKeys: String, CodingKey {
|
|||
// macOS-specific keys
|
||||
case ecid
|
||||
case hardwareModel
|
||||
|
||||
// Linux-specific keys
|
||||
case machineIdentifier
|
||||
}
|
||||
|
||||
struct VMDisplayConfig: Codable, Equatable {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue