Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP : Run as snapshot for apple vm #3893

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
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
111 changes: 106 additions & 5 deletions Configuration/UTMAppleConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,8 @@ final class UTMAppleConfiguration: UTMConfigurable, Codable, ObservableObject {

@Published var isSerialEnabled: Bool
@Published var isConsoleDisplay: Bool

@Published var isRunAsSnapshot: Bool

@available(macOS 12, *)
var isKeyboardEnabled: Bool {
get {
Expand Down Expand Up @@ -335,6 +336,7 @@ final class UTMAppleConfiguration: UTMConfigurable, Codable, ObservableObject {
case isEntropyEnabled
case isSerialEnabled
case isConsoleDisplay
case isRunAsSnapshot
case isKeyboardEnabled
case isPointingEnabled
}
Expand All @@ -357,6 +359,7 @@ final class UTMAppleConfiguration: UTMConfigurable, Codable, ObservableObject {
isAppleVirtualization = true
isSerialEnabled = false
isConsoleDisplay = false
isRunAsSnapshot = false
memorySize = 4 * 1024 * 1024 * 1024
cpuCount = 4
}
Expand Down Expand Up @@ -398,6 +401,7 @@ final class UTMAppleConfiguration: UTMConfigurable, Codable, ObservableObject {
isEntropyEnabled = try values.decode(Bool.self, forKey: .isEntropyEnabled)
isSerialEnabled = try values.decode(Bool.self, forKey: .isSerialEnabled)
isConsoleDisplay = try values.decode(Bool.self, forKey: .isConsoleDisplay)
isRunAsSnapshot = try values.decodeIfPresent(Bool.self, forKey: .isRunAsSnapshot) ?? false
name = try values.decode(String.self, forKey: .name)
architecture = try values.decode(String.self, forKey: .architecture)
icon = try values.decodeIfPresent(String.self, forKey: .icon)
Expand Down Expand Up @@ -435,6 +439,7 @@ final class UTMAppleConfiguration: UTMConfigurable, Codable, ObservableObject {
try container.encode(isEntropyEnabled, forKey: .isEntropyEnabled)
try container.encode(isSerialEnabled, forKey: .isSerialEnabled)
try container.encode(isConsoleDisplay, forKey: .isConsoleDisplay)
try container.encode(isRunAsSnapshot, forKey: .isRunAsSnapshot)
try container.encode(name, forKey: .name)
try container.encode(architecture, forKey: .architecture)
try container.encodeIfPresent(icon, forKey: .icon)
Expand Down Expand Up @@ -616,6 +621,48 @@ final class UTMAppleConfiguration: UTMConfigurable, Codable, ObservableObject {
}
return urls
}

/// Remove the snapshot URL image's that were previously genereated,
/// this should be done as part of a VM cleanup.
func cleanupDriveSnapshot() throws {
for i in diskImages.indices {
try diskImages[i].cleanupDriveSnapshot()
}
}

/// Perform a snapshot clone of the current image URL to the snapshot URL
/// this is required for the snapshotURL image to "work"
///
/// This is only done if the provided parm, or system config is true
///
/// The "runAsSnapshot" is meant to be used with the
/// "runAsSnapshot" context menu feature, which would perform
/// the snapshot run "on demand" without config change
func setupDriveSnapshot(runAsSnapshot: Bool = false) throws {
// Seems like there is edge cases where cleanup didn't occur (hard crash/force close)
// So we should always perform a full cleanup, before setup
try cleanupDriveSnapshot()

// Logic to detrimine if runAsSnapshot should be assumed
var runAsSnapshot = runAsSnapshot
if isRunAsSnapshot {
runAsSnapshot = true
}

// Perform snapshots on a per drive level
for i in diskImages.indices {
// Setup the --snapshot on a per drive level
try diskImages[i].setupDriveSnapshot(runAsSnapshot: runAsSnapshot)
}

// Seems like this needs to be reinit? So that config changes properlly applies
apple.storageDevices = diskImages.compactMap({ diskImage in
guard let attachment = try? diskImage.vzDiskImage(runAsSnapshot: runAsSnapshot) else {
return nil
}
return VZVirtioBlockDeviceConfiguration(attachment: attachment)
})
}
}

struct Bootloader: Codable {
Expand Down Expand Up @@ -864,13 +911,15 @@ struct DiskImage: Codable, Hashable, Identifiable {
var sizeMib: Int
var isReadOnly: Bool
var isExternal: Bool
var isRunAsSnapshot: Bool
var imageURL: URL?
private var uuid = UUID() // for identifiable

private enum CodingKeys: String, CodingKey {
case sizeMib
case isReadOnly
case isExternal
case isRunAsSnapshot
case imagePath
case imageBookmark
}
Expand All @@ -891,12 +940,14 @@ struct DiskImage: Codable, Hashable, Identifiable {
sizeMib = newSize
isReadOnly = false
isExternal = false
isRunAsSnapshot = false
}

init(importImage url: URL, isReadOnly: Bool = false, isExternal: Bool = false) {
self.imageURL = url
self.isReadOnly = isReadOnly
self.isExternal = isExternal
self.isRunAsSnapshot = false
if let attributes = try? url.resourceValues(forKeys: [.fileSizeKey]), let fileSize = attributes.fileSize {
sizeMib = fileSize / bytesInMib
} else {
Expand All @@ -912,6 +963,10 @@ struct DiskImage: Codable, Hashable, Identifiable {
sizeMib = try container.decode(Int.self, forKey: .sizeMib)
isReadOnly = try container.decode(Bool.self, forKey: .isReadOnly)
isExternal = try container.decode(Bool.self, forKey: .isExternal)

// isRunAsSnapshot : is backwards compatible with older configs
isRunAsSnapshot = try container.decodeIfPresent(Bool.self, forKey: .isRunAsSnapshot) ?? false

if !isExternal, let imagePath = try container.decodeIfPresent(String.self, forKey: .imagePath) {
imageURL = dataURL.appendingPathComponent(imagePath)
} else if let bookmark = try container.decodeIfPresent(Data.self, forKey: .imageBookmark) {
Expand All @@ -925,6 +980,7 @@ struct DiskImage: Codable, Hashable, Identifiable {
try container.encode(sizeMib, forKey: .sizeMib)
try container.encode(isReadOnly, forKey: .isReadOnly)
try container.encode(isExternal, forKey: .isExternal)
try container.encode(isRunAsSnapshot, forKey: .isRunAsSnapshot)
if !isExternal {
try container.encodeIfPresent(imageURL?.lastPathComponent, forKey: .imagePath)
} else {
Expand All @@ -941,12 +997,57 @@ struct DiskImage: Codable, Hashable, Identifiable {
}
}

func vzDiskImage() throws -> VZDiskImageStorageDeviceAttachment? {
if let imageURL = imageURL {
return try VZDiskImageStorageDeviceAttachment(url: imageURL, readOnly: isReadOnly)
/// Returns the snapshot equivalent URL for the current image
/// Does not actually prepare the snapshot (this is done via setupDriveSnapshot)
func snapshotURL() throws -> URL? {
return imageURL?.appendingPathExtension("snapshot.img")
}

/// Remove the snapshot URL image, this can be done as part of VM cleanup
func cleanupDriveSnapshot() throws {
if let snapshotURL = try snapshotURL() {
// The file may not exists, if so nothing should happens. Also,
// despite documentation saying "removeItem" will return false.
// it will return with an error if removal fails (does not exist,etc)
//
// try? surpresses and ignores the error
try? FileManager.default.removeItem(at: snapshotURL)
}
}

/// Perform a snapshot clone of the current image URL to the snapshot URL
/// this is required for the snapshotURL image to "work"
///
/// This is only done if the provided parm, or drive config is true
func setupDriveSnapshot(runAsSnapshot: Bool = false) throws {
// does nothing if runAsSnapshot is false
if runAsSnapshot == false && isRunAsSnapshot == false {
return
}

// Make a copy of the provided imageURL, as snapshot
if let snapshotURL = try snapshotURL(), let imageURL = imageURL {
// lets setup the snapshot file
// AFAICT this does a shallow copy on APFS drives
try FileManager.default.copyItem(at: imageURL, to: snapshotURL)
}
}

/// Return the vzDiskImage, if either the runAsSnapshot or
/// the isRunAsSnapshot drive config is true, return using the snapshot URL instead
func vzDiskImage(runAsSnapshot: Bool = false) throws -> VZDiskImageStorageDeviceAttachment? {
if runAsSnapshot == true || isRunAsSnapshot == true {
// Assume the usage of snapshot URL
if let snapshotURL = try snapshotURL() {
return try VZDiskImageStorageDeviceAttachment(url: snapshotURL, readOnly: isReadOnly)
}
} else {
return nil
// Assume standard (non-snapshot) behaviour
if let imageURL = imageURL {
return try VZDiskImageStorageDeviceAttachment(url: imageURL, readOnly: isReadOnly)
}
}
return nil
}

func hash(into hasher: inout Hasher) {
Expand Down
14 changes: 14 additions & 0 deletions Managers/UTMAppleVirtualMachine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ import Virtualization
}
}
}

// This perform any cleanup for the "--snapshot" feature,
// if it was initialized previously
try appleConfig.cleanupDriveSnapshot()
}

override func vmStop(force: Bool) async throws {
Expand Down Expand Up @@ -324,6 +328,16 @@ import Virtualization
fsConfig.share = self?.makeDirectoryShare(from: newShares)
}
}

// This perform any reset's needed for the
// "--snapshot" feature (if its in use)
//
// The "runAsSnapshot" is meant to be used with the
// "runAsSnapshot" context menu feature, which would perform
// the snapshot run "on demand" without config change
// - when said context menu feature is implemented.
try appleConfig.setupDriveSnapshot(runAsSnapshot: false)

apple = VZVirtualMachine(configuration: appleConfig.apple, queue: vmQueue)
apple.delegate = self
}
Expand Down
1 change: 1 addition & 0 deletions Platform/macOS/VMConfigAppleDriveDetailsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ struct VMConfigAppleDriveDetailsView: View {
TextField("Name", text: .constant(diskImage.imageURL?.lastPathComponent ?? NSLocalizedString("(New Drive)", comment: "VMConfigAppleDriveDetailsView")))
.disabled(true)
Toggle("Read Only?", isOn: $diskImage.isReadOnly)
Toggle("Run using a snapshot? (similar to qemu --snapshot)", isOn: $diskImage.isRunAsSnapshot)
Button(action: onDelete) {
Label("Delete Drive", systemImage: "externaldrive.badge.minus")
.foregroundColor(.red)
Expand Down
1 change: 1 addition & 0 deletions Platform/macOS/VMConfigAppleSystemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ struct VMConfigAppleSystemView: View {
Toggle("Enable Keyboard", isOn: $config.isKeyboardEnabled)
Toggle("Enable Pointer", isOn: $config.isPointingEnabled)
}
Toggle("Enable 'Run using a snapshot' on all drives", isOn: $config.isRunAsSnapshot)
}
}
}
Expand Down