Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Sources/tart/Commands/Clone.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 8 additions & 2 deletions Sources/tart/Commands/Run.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down
52 changes: 51 additions & 1 deletion Sources/tart/Platform/Linux.swift
Original file line number Diff line number Diff line change
@@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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()
}
}
3 changes: 3 additions & 0 deletions Sources/tart/VMConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ enum CodingKeys: String, CodingKey {
// macOS-specific keys
case ecid
case hardwareModel

// Linux-specific keys
case machineIdentifier
}

struct VMDisplayConfig: Codable, Equatable {
Expand Down
17 changes: 17 additions & 0 deletions Sources/tart/VMDirectory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
52 changes: 52 additions & 0 deletions Tests/TartTests/VMConfigTests.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import XCTest
@testable import tart

import Foundation
import Virtualization

final class VMConfigTests: XCTestCase {
func testVMDisplayConfig() throws {
// Defaults units (points)
Expand All @@ -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")
}
}