mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-18 11:55:36 +02:00
Support suspending and resuming Linux VMs (#1297)
Restoring saved state requires the VZGenericMachineIdentifier that the
VM was saved with, but Linux VMs were getting a new one every time they
started (including when trying to restore from saved state). The
identifier is now persisted in the VM config and reused on every start.
Apple's SDK header for VZGenericPlatformConfiguration.machineIdentifier
says:
> Running two virtual machines concurrently with the same identifier
> results in undefined behavior in the guest operating system. When
> restoring a virtual machine from saved state, this `machineIdentifier`
> must match the `machineIdentifier` of the saved virtual machine.
So identifiers for Linux guests are now treated like MAC addresses:
- Cloning a non-suspended VM generates a new id for the clone.
- Suspended clones keep their id because their saved state requires
it.
- Running a VM whose id matches an already running VM generates a new
id.
- If the colliding VM being run was suspended, its saved state will
be discarded.
Configurations without an identifier will still load, but can't be
suspended.
Suspendable mode for Linux VMs omits the pointing device because
VZUSBScreenCoordinatePointingDeviceConfiguration makes restores fail.
This commit is contained in:
@@ -106,6 +106,8 @@ struct Clone: AsyncParsableCommand {
|
||||
try rejectExistingDestination(localStorage)
|
||||
|
||||
let sourceState = try sourceVM.state()
|
||||
let sourceConfig = try VMConfig(fromURL: sourceVM.configURL)
|
||||
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
&& sourceState != .Suspended
|
||||
|
||||
@@ -128,6 +130,10 @@ struct Clone: AsyncParsableCommand {
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
}
|
||||
|
||||
if sourceState != .Suspended, sourceConfig.platform is Linux {
|
||||
try tmpVMDir.regenerateLinuxMachineIdentifier()
|
||||
}
|
||||
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
|
||||
try lock.unlock()
|
||||
|
||||
@@ -285,7 +285,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
|
||||
|
||||
@@ -374,7 +376,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 {
|
||||
@@ -421,14 +427,15 @@ struct Run: AsyncParsableCommand {
|
||||
let localStorage = try VMStorageLocal()
|
||||
let vmDir = try localStorage.open(name)
|
||||
|
||||
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
try storageLock.lock()
|
||||
|
||||
// Validate disk format support
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
if !vmConfig.diskFormat.isSupported {
|
||||
throw ValidationError("Disk format '\(vmConfig.diskFormat.rawValue)' is not supported on this system.")
|
||||
}
|
||||
|
||||
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
try storageLock.lock()
|
||||
// check if there is a running VM with the same MAC address
|
||||
let hasRunningMACCollision = try localStorage.list().contains {
|
||||
// check if there is a running VM with the same MAC but different name
|
||||
@@ -440,6 +447,23 @@ struct Run: AsyncParsableCommand {
|
||||
try vmDir.regenerateMACAddress()
|
||||
}
|
||||
|
||||
// check if there is a running VM with the same Linux machine identifier but different name
|
||||
if let linuxMachineIdentifier = (vmConfig.platform as? Linux)?.machineIdentifier {
|
||||
let hasRunningLinuxMachineIdentifierCollision = try localStorage.list().contains {
|
||||
if try !$1.running() || $1.name == vmDir.name {
|
||||
return false
|
||||
}
|
||||
let otherConfig = try VMConfig(fromURL: $1.configURL)
|
||||
return (otherConfig.platform as? Linux)?.machineIdentifier == linuxMachineIdentifier
|
||||
}
|
||||
|
||||
if hasRunningLinuxMachineIdentifierCollision {
|
||||
print("There is already a running VM with the same Linux machine identifier!")
|
||||
print("Resetting VM to assign a new Linux machine identifier...")
|
||||
try vmDir.regenerateLinuxMachineIdentifier()
|
||||
}
|
||||
}
|
||||
|
||||
if netSoftnet && isInteractiveSession() {
|
||||
try Softnet.configureSUIDBitIfNeeded()
|
||||
}
|
||||
|
||||
@@ -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(noUSB: noUSB)
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||
// VZUSBScreenCoordinatePointingDeviceConfiguration passes save/restore
|
||||
// validation, but causes restoring a Linux VM to fail with "invalid argument".
|
||||
[]
|
||||
}
|
||||
|
||||
func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||
keyboards(noUSB: noUSB)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ enum CodingKeys: String, CodingKey {
|
||||
// macOS-specific keys
|
||||
case ecid
|
||||
case hardwareModel
|
||||
|
||||
// Linux-specific keys
|
||||
case machineIdentifier
|
||||
}
|
||||
|
||||
struct VMDisplayConfig: Codable, Equatable {
|
||||
|
||||
@@ -230,6 +230,21 @@ struct VMDirectory: Prunable {
|
||||
try vmConfig.save(toURL: configURL)
|
||||
}
|
||||
|
||||
func regenerateLinuxMachineIdentifier() throws {
|
||||
var vmConfig = try VMConfig(fromURL: configURL)
|
||||
guard var vmLinux = vmConfig.platform as? Linux else {
|
||||
throw RuntimeError.VMConfigurationError("cannot regenerate a Linux machine identifier on a non-Linux VM")
|
||||
}
|
||||
|
||||
vmLinux.machineIdentifier = VZGenericMachineIdentifier()
|
||||
vmConfig.platform = vmLinux
|
||||
|
||||
// cleanup state if any
|
||||
try? FileManager.default.removeItem(at: stateURL)
|
||||
|
||||
try vmConfig.save(toURL: configURL)
|
||||
}
|
||||
|
||||
func resizeDisk(
|
||||
_ sizeGB: UInt16,
|
||||
format: DiskImageFormat = .raw,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class VMDirectoryTests: XCTestCase {
|
||||
|
||||
func testRegenerateLinuxMachineIdentifierReplacesIdentifierAndDeletesState() throws {
|
||||
let vmDir = try temporaryVMDirectory()
|
||||
let originalIdentifier = VZGenericMachineIdentifier()
|
||||
let originalConfig = try writeLinuxConfig(machineIdentifier: originalIdentifier, to: vmDir)
|
||||
try touch(vmDir.stateURL)
|
||||
|
||||
try vmDir.regenerateLinuxMachineIdentifier()
|
||||
|
||||
let config = try VMConfig(fromURL: vmDir.configURL)
|
||||
let linux = try XCTUnwrap(config.platform as? Linux)
|
||||
let newIdentifier = try XCTUnwrap(linux.machineIdentifier)
|
||||
XCTAssertNotEqual(newIdentifier.dataRepresentation, originalIdentifier.dataRepresentation)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: vmDir.stateURL.path))
|
||||
XCTAssertEqual(config.macAddress.string, originalConfig.macAddress.string)
|
||||
}
|
||||
|
||||
func testRegenerateLinuxMachineIdentifierAssignsMissingIdentifier() throws {
|
||||
let vmDir = try temporaryVMDirectory()
|
||||
try writeLinuxConfig(machineIdentifier: nil, to: vmDir)
|
||||
|
||||
try vmDir.regenerateLinuxMachineIdentifier()
|
||||
|
||||
let linux = try XCTUnwrap(VMConfig(fromURL: vmDir.configURL).platform as? Linux)
|
||||
XCTAssertNotNil(linux.machineIdentifier)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func writeLinuxConfig(machineIdentifier: VZGenericMachineIdentifier?, to vmDir: VMDirectory) throws -> VMConfig {
|
||||
var linux = Linux()
|
||||
linux.machineIdentifier = machineIdentifier
|
||||
let config = VMConfig(platform: linux, cpuCountMin: 2, memorySizeMin: 1024 * 1024 * 1024)
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
return config
|
||||
}
|
||||
|
||||
private func temporaryVMDirectory() throws -> VMDirectory {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false)
|
||||
addTeardownBlock {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
return VMDirectory(baseURL: url)
|
||||
}
|
||||
|
||||
private func touch(_ url: URL) throws {
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: url.path, contents: Data()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user