mirror of
https://github.com/utmapp/UTM.git
synced 2026-10-11 21:59:56 +00:00
snapshots: manage QEMU snapshots through a backend
Snapshots were read straight out of the QEMU images by the scripting commands, which only worked while the VM was off and knew nothing beyond the tags in the image. Put a backend behind the operations instead. Which one is chosen depends on whether the VM is running, and each says what it can do in that state: - A running VM goes through the QEMU monitor, which saves and loads the whole machine along with the disks. - A VM that is off goes through qemu-img, which can only save and revert disk contents. Nothing may touch the images while QEMU has them open, so a snapshot of disks alone is left alone until the VM stops. Either way a snapshot spans every writable image, the UEFI variable store included, because QEMU refuses to load one that any of them is missing. A snapshot that some of them lack, such as one taken before a drive was added, is kept but cannot be restored, which is not the same as its state being gone: reverting only some of the drives would leave them at different points in time. Reclaiming space rebuilds an image without its snapshots, so it is refused while there are any. UTMSnapshotService sits above the backend and keeps the manifest and the stored state consistent: it marks entries whose state has disappeared when an operation fails, and the first time a VM is opened it adopts whatever snapshots are already in the images so that snapshots taken by other tools are not lost. Both wait until the VM is stopped, because a running QEMU lists the snapshots of every drive together, removable ones included. Suspending is folded into the same mechanism. A VM now suspends to a snapshot of its own rather than always the same name, so a suspended state can be kept as a snapshot without copying it, and restoring a snapshot makes the VM resume from it. The state is deleted once nothing refers to it, where it used to be left behind in the image. One that cannot be deleted right away stays listed in the manifest and is tried again the next time. Assisted-by: Claude:claude-fable-5-1 Assisted-by: Claude:claude-opus-5
This commit is contained in:
@@ -652,7 +652,7 @@ import Virtualization // for getting network interfaces
|
||||
f()
|
||||
}
|
||||
|
||||
private var hasCustomBios: Bool {
|
||||
var hasCustomBios: Bool {
|
||||
for drive in drives {
|
||||
if drive.imageType == .disk || drive.imageType == .cd {
|
||||
if drive.interface == .pflash {
|
||||
|
||||
@@ -911,6 +911,10 @@ enum AlertItem: Identifiable {
|
||||
/// - Parameter driveUrl: Original drive to convert
|
||||
/// - Parameter isCompressed: Compress existing data
|
||||
func reclaimSpace(for driveUrl: URL, withCompression isCompressed: Bool = false) async throws {
|
||||
// converting leaves the snapshots in the image behind, and a suspended state is one of them
|
||||
guard try await UTMQemuImage.snapshots(image: driveUrl).isEmpty else {
|
||||
throw UTMDataError.driveHasSnapshots
|
||||
}
|
||||
let baseUrl = driveUrl.deletingLastPathComponent()
|
||||
let dstUrl = Self.newImage(from: driveUrl, to: baseUrl, withExtension: "qcow2")
|
||||
defer {
|
||||
@@ -1195,6 +1199,7 @@ enum UTMDataError: Error {
|
||||
case jitStreamerUrlInvalid(String)
|
||||
case notImplemented
|
||||
case reconnectFailed
|
||||
case driveHasSnapshots
|
||||
}
|
||||
|
||||
extension UTMDataError: LocalizedError {
|
||||
@@ -1228,6 +1233,8 @@ extension UTMDataError: LocalizedError {
|
||||
return NSLocalizedString("This functionality is not yet implemented.", comment: "UTMData")
|
||||
case .reconnectFailed:
|
||||
return NSLocalizedString("Failed to reconnect to the server.", comment: "UTMData")
|
||||
case .driveHasSnapshots:
|
||||
return NSLocalizedString("Space cannot be reclaimed from a drive while it holds snapshots or a suspended state.", comment: "UTMData")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,8 +299,7 @@ class UTMScriptingVirtualMachineImpl: NSObject, UTMScriptable {
|
||||
|
||||
@objc func listSnapshots(_ command: NSScriptCommand) {
|
||||
withScriptCommand(command) { [self] in
|
||||
let entries = try await UTMSnapshotService.listSnapshots(on: vm)
|
||||
return entries.map { $0.name }
|
||||
return try await UTMSnapshotService.listSnapshots(on: vm)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
//
|
||||
// Copyright © 2026 Turing Software, LLC. All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import QEMUKitInternal
|
||||
|
||||
/// Snapshot tag that a VM was always suspended to before suspend states could be kept as snapshots
|
||||
let kUTMQemuDefaultSuspendSnapshotName = "suspend"
|
||||
|
||||
extension UTMQemuVirtualMachine {
|
||||
/// Images that take part in a snapshot
|
||||
@MainActor var snapshotImageURLs: [URL] {
|
||||
var imageURLs = config.drives.compactMap { drive -> URL? in
|
||||
guard drive.imageType == .disk && !drive.isExternal && !drive.isReadOnly else {
|
||||
return nil
|
||||
}
|
||||
return drive.imageURL
|
||||
}
|
||||
// the same condition under which QEMU is given the variable store
|
||||
if config.qemu.hasUefiBoot, !config.hasCustomBios, let efiVarsURL = config.qemu.efiVarsURL, FileManager.default.fileExists(atPath: efiVarsURL.path) {
|
||||
imageURLs.insert(efiVarsURL, at: 0)
|
||||
}
|
||||
return imageURLs
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live
|
||||
|
||||
/// Internal snapshots of a running QEMU, managed through the monitor.
|
||||
///
|
||||
/// QEMU has the images open so nothing else may touch them, least of all qemu-img. It saves and
|
||||
/// loads the running state along with the disks. A snapshot of just the disks cannot be loaded
|
||||
/// into a running guest and is left alone until the VM is off.
|
||||
struct UTMQemuLiveSnapshotBackend: UTMSnapshotBackend {
|
||||
let vm: UTMQemuVirtualMachine
|
||||
|
||||
let imageURLs: [URL]
|
||||
|
||||
/// QEMU lists the snapshots of every drive together, removable ones included
|
||||
let isInventoryExact = false
|
||||
|
||||
let defaultSuspendIdentifier = kUTMQemuDefaultSuspendSnapshotName
|
||||
|
||||
func supports(_ operation: UTMSnapshotOperation, hasState: Bool) -> Bool {
|
||||
operation == .create || hasState
|
||||
}
|
||||
|
||||
func entries() async throws -> [UTMSnapshotBackendEntry] {
|
||||
guard let monitor = await vm.monitor else {
|
||||
throw UTMSnapshotError.invalidVmState
|
||||
}
|
||||
return try monitor.snapshots().map { snapshot in
|
||||
UTMSnapshotBackendEntry(identifier: snapshot.name,
|
||||
date: snapshot.date,
|
||||
size: snapshot.vmStateSize,
|
||||
hasState: snapshot.vmStateSize > 0)
|
||||
}
|
||||
}
|
||||
|
||||
func create(suspendIdentifier: String?) async throws -> UTMSnapshotBackendEntry {
|
||||
// the state is saved anew because a VM can keep running after it was suspended
|
||||
let identifier = UUID().uuidString
|
||||
try await vm.saveSnapshot(name: identifier)
|
||||
guard let entry = try await entries().first(where: { $0.identifier == identifier }) else {
|
||||
throw UTMSnapshotError.notFound(identifier)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func restore(_ entry: UTMSnapshotBackendEntry) async throws -> Bool {
|
||||
try await vm.restoreSnapshot(name: entry.identifier)
|
||||
return false
|
||||
}
|
||||
|
||||
func delete(identifier: String) async throws {
|
||||
try await vm.deleteSnapshot(name: identifier)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Frozen
|
||||
|
||||
/// Internal snapshots in the images of a QEMU that is not running, managed through qemu-img.
|
||||
///
|
||||
/// A snapshot spans every image, including the one for the UEFI variables. QEMU saves and loads
|
||||
/// all of them together and refuses to load a snapshot that any of them is missing.
|
||||
struct UTMQemuFrozenSnapshotBackend: UTMSnapshotBackend {
|
||||
let imageURLs: [URL]
|
||||
|
||||
/// Kept from starting while the images change, unless the caller already sees to that
|
||||
var vm: UTMQemuVirtualMachine? = nil
|
||||
|
||||
let isInventoryExact = true
|
||||
|
||||
let defaultSuspendIdentifier = kUTMQemuDefaultSuspendSnapshotName
|
||||
|
||||
func supports(_ operation: UTMSnapshotOperation, hasState: Bool) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func entries() async throws -> [UTMSnapshotBackendEntry] {
|
||||
var entries = [String: UTMSnapshotBackendEntry]()
|
||||
var imageCounts = [String: Int]()
|
||||
for imageURL in imageURLs {
|
||||
for snapshot in try await UTMQemuImage.snapshots(image: imageURL) {
|
||||
// the VM state is only in one of the images
|
||||
let previous = entries[snapshot.name]
|
||||
entries[snapshot.name] = UTMSnapshotBackendEntry(identifier: snapshot.name,
|
||||
date: max(snapshot.date, previous?.date ?? .distantPast),
|
||||
size: snapshot.vmStateSize + (previous?.size ?? 0),
|
||||
hasState: snapshot.vmStateSize > 0 || previous?.hasState == true)
|
||||
imageCounts[snapshot.name, default: 0] += 1
|
||||
}
|
||||
}
|
||||
return entries.values.map { entry in
|
||||
var entry = entry
|
||||
entry.isComplete = imageCounts[entry.identifier] == imageURLs.count
|
||||
return entry
|
||||
}
|
||||
}
|
||||
|
||||
func create(suspendIdentifier: String?) async throws -> UTMSnapshotBackendEntry {
|
||||
if let suspendIdentifier = suspendIdentifier {
|
||||
// nothing changed since the VM was suspended
|
||||
guard let entry = try await entries().first(where: { $0.identifier == suspendIdentifier }), entry.isComplete else {
|
||||
throw UTMSnapshotError.notFound(suspendIdentifier)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
for imageURL in imageURLs {
|
||||
guard try await UTMQemuImage.info(image: imageURL).format == "qcow2" else {
|
||||
throw UTMSnapshotError.notSupported
|
||||
}
|
||||
}
|
||||
let identifier = UUID().uuidString
|
||||
try await changingImages(as: .saving) {
|
||||
do {
|
||||
for imageURL in imageURLs {
|
||||
try await UTMQemuImage.createSnapshot(image: imageURL, name: identifier)
|
||||
}
|
||||
} catch {
|
||||
try? await deleteFromImages(identifier: identifier)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
guard let entry = try await entries().first(where: { $0.identifier == identifier }) else {
|
||||
throw UTMSnapshotError.notFound(identifier)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func restore(_ entry: UTMSnapshotBackendEntry) async throws -> Bool {
|
||||
guard entry.isComplete else {
|
||||
throw UTMSnapshotError.incomplete(entry.identifier)
|
||||
}
|
||||
// the disks are reverted now even when QEMU loads the running state on the next start,
|
||||
// so they hold the snapshot should that state be discarded instead
|
||||
try await changingImages(as: .restoring) {
|
||||
for imageURL in imageURLs {
|
||||
try await UTMQemuImage.applySnapshot(image: imageURL, name: entry.identifier)
|
||||
}
|
||||
}
|
||||
return entry.hasState
|
||||
}
|
||||
|
||||
func delete(identifier: String) async throws {
|
||||
try await changingImages(as: .saving) {
|
||||
try await deleteFromImages(identifier: identifier)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteFromImages(identifier: String) async throws {
|
||||
for imageURL in try await imageURLs(containing: identifier) {
|
||||
try await UTMQemuImage.deleteSnapshot(image: imageURL, name: identifier)
|
||||
}
|
||||
}
|
||||
|
||||
private func changingImages(as state: UTMVirtualMachineState, _ body: () async throws -> Void) async throws {
|
||||
if let vm = vm {
|
||||
try await vm.changingImages(as: state, body)
|
||||
} else {
|
||||
try await body()
|
||||
}
|
||||
}
|
||||
|
||||
private func imageURLs(containing identifier: String) async throws -> [URL] {
|
||||
var found = [URL]()
|
||||
for imageURL in imageURLs {
|
||||
if try await UTMQemuImage.snapshots(image: imageURL).contains(where: { $0.name == identifier }) {
|
||||
found.append(imageURL)
|
||||
}
|
||||
}
|
||||
guard !found.isEmpty else {
|
||||
throw UTMSnapshotError.notFound(identifier)
|
||||
}
|
||||
return found
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import SwiftPortmap
|
||||
#endif
|
||||
|
||||
private var SpiceIoServiceGuestAgentContext = 0
|
||||
private let kSuspendSnapshotName = "suspend"
|
||||
private let kProbeSuspendDelay = 1*NSEC_PER_SEC
|
||||
|
||||
/// QEMU backend virtual machine
|
||||
@@ -103,8 +102,6 @@ final class UTMQemuVirtualMachine: UTMSpiceVirtualMachine {
|
||||
|
||||
private(set) var snapshotUnsupportedError: Error?
|
||||
|
||||
/// When set, the next `_start` will load this snapshot tag instead of a fresh/suspended boot.
|
||||
private var pendingRestoreSnapshotName: String?
|
||||
|
||||
private var isScopedAccess: Bool = false
|
||||
|
||||
@@ -431,28 +428,21 @@ extension UTMQemuVirtualMachine {
|
||||
try Task.checkCancellation()
|
||||
|
||||
// load saved state if requested
|
||||
//
|
||||
// A user snapshot restore on a stopped VM (`pendingRestoreSnapshotName`) reuses this
|
||||
// startup path: boot paused, loadvm the requested tag, then continue. Once the VM is
|
||||
// running, any previous internal suspend state is obsolete and is removed below.
|
||||
let isSuspended = await registryEntry.isSuspended
|
||||
let userRestoreSnapshotName = pendingRestoreSnapshotName
|
||||
pendingRestoreSnapshotName = nil
|
||||
let withMounting: Bool
|
||||
if let userRestoreSnapshotName = userRestoreSnapshotName {
|
||||
try await _restoreSnapshot(name: userRestoreSnapshotName)
|
||||
try Task.checkCancellation()
|
||||
withMounting = false
|
||||
} else {
|
||||
if !isRunningAsDisposible && isSuspended {
|
||||
try await monitor.qemuRestoreSnapshot(kSuspendSnapshotName)
|
||||
var isStateMissing = false
|
||||
if !isRunningAsDisposible && isSuspended {
|
||||
let suspendSnapshotName = suspendSnapshotName
|
||||
if try monitor.snapshots().contains(where: { $0.name == suspendSnapshotName }) {
|
||||
try await _restoreSnapshot(name: suspendSnapshotName)
|
||||
try Task.checkCancellation()
|
||||
} else {
|
||||
// a state that is gone leaves the guest to boot fresh, as nothing of it was loaded
|
||||
isStateMissing = true
|
||||
}
|
||||
withMounting = !isSuspended
|
||||
}
|
||||
|
||||
// set up SPICE sharing and removable drives
|
||||
try await self.restoreExternalDrives(withMounting: withMounting)
|
||||
try await self.restoreExternalDrives(withMounting: !isSuspended || isStateMissing)
|
||||
if let ioService = interface as? UTMSpiceIO {
|
||||
try await self.restoreSharedDirectory(for: ioService)
|
||||
} else {
|
||||
@@ -463,7 +453,7 @@ extension UTMQemuVirtualMachine {
|
||||
// continue VM boot
|
||||
try await monitor.continueBoot()
|
||||
|
||||
// delete saved suspend state (user snapshots are preserved)
|
||||
// delete saved suspend state
|
||||
if isSuspended {
|
||||
try? await deleteSnapshot()
|
||||
}
|
||||
@@ -622,31 +612,94 @@ extension UTMQemuVirtualMachine {
|
||||
state = prev
|
||||
}
|
||||
do {
|
||||
try await _saveSnapshot(name: name ?? kSuspendSnapshotName)
|
||||
if name == nil {
|
||||
if let name = name {
|
||||
try await _saveSnapshot(name: name)
|
||||
} else {
|
||||
// a new name each time lets the previous one be kept as a snapshot
|
||||
let suspendName = UUID().uuidString
|
||||
try await _saveSnapshot(name: suspendName)
|
||||
let staleNames = try UTMSnapshotManifest.replaceSuspendState(in: pathUrl, with: suspendName)
|
||||
await registryEntry.setIsSuspended(true)
|
||||
try saveScreenshot()
|
||||
// a state that cannot go now stays listed and goes the next time
|
||||
try? await deleteStaleSnapshots(staleNames)
|
||||
}
|
||||
} catch {
|
||||
throw UTMQemuVirtualMachineError.saveSnapshotFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the VM from starting while qemu-img changes its images, which corrupts any that QEMU
|
||||
/// has open. It may have them open without a monitor, so the VM has to be stopped.
|
||||
func changingImages(as state: UTMVirtualMachineState, _ body: () async throws -> Void) async throws {
|
||||
guard self.state == .stopped else {
|
||||
throw UTMQemuVirtualMachineError.invalidVmState
|
||||
}
|
||||
self.state = state
|
||||
defer {
|
||||
self.state = .stopped
|
||||
}
|
||||
try await body()
|
||||
}
|
||||
|
||||
/// Snapshot with the state to resume from, older versions always used the same one
|
||||
private var suspendSnapshotName: String {
|
||||
UTMSnapshotManifest.suspendState(in: pathUrl) ?? kUTMQemuDefaultSuspendSnapshotName
|
||||
}
|
||||
|
||||
private func _deleteSnapshot(name: String) async throws {
|
||||
if let monitor = await monitor { // if QEMU is running
|
||||
// QEMU only reports a missing snapshot as a failure, which would leave it listed
|
||||
guard try monitor.snapshots().contains(where: { $0.name == name }) else {
|
||||
throw UTMSnapshotError.notFound(name)
|
||||
}
|
||||
let result = try await monitor.qemuDeleteSnapshot(name)
|
||||
if result.localizedCaseInsensitiveContains("Error") {
|
||||
throw UTMQemuVirtualMachineError.qemuError(result)
|
||||
}
|
||||
try? updateLastModified()
|
||||
} else {
|
||||
try await changingImages(as: .saving) {
|
||||
try await UTMQemuFrozenSnapshotBackend(imageURLs: await snapshotImageURLs).delete(identifier: name)
|
||||
}
|
||||
}
|
||||
try? updateLastModified()
|
||||
}
|
||||
|
||||
func deleteSnapshot(name: String? = nil) async throws {
|
||||
if name == nil {
|
||||
await registryEntry.setIsSuspended(false)
|
||||
if let name = name {
|
||||
try await _deleteSnapshot(name: name)
|
||||
return
|
||||
}
|
||||
await registryEntry.setIsSuspended(false)
|
||||
// the state stays if it was kept as a snapshot
|
||||
let staleNames = try UTMSnapshotManifest.releaseSuspendState(in: pathUrl, default: kUTMQemuDefaultSuspendSnapshotName)
|
||||
#if !os(macOS)
|
||||
// without qemu-img a state can only go while QEMU runs, which the next suspend sees to
|
||||
guard await monitor != nil else {
|
||||
return
|
||||
}
|
||||
#endif
|
||||
try await deleteStaleSnapshots(staleNames)
|
||||
}
|
||||
|
||||
/// Delete states that nothing needs anymore. Each stays listed in the manifest until it is
|
||||
/// gone so that one that fails is tried again instead of staying in the images for good.
|
||||
private func deleteStaleSnapshots(_ names: [String]) async throws {
|
||||
var firstError: Error?
|
||||
for name in names {
|
||||
do {
|
||||
try await _deleteSnapshot(name: name)
|
||||
} catch UTMSnapshotError.notFound(_) {
|
||||
// already gone
|
||||
} catch {
|
||||
firstError = firstError ?? error
|
||||
continue
|
||||
}
|
||||
try UTMSnapshotManifest.forgetState(name, in: pathUrl)
|
||||
}
|
||||
if let error = firstError {
|
||||
throw error
|
||||
}
|
||||
try await _deleteSnapshot(name: name ?? kSuspendSnapshotName)
|
||||
}
|
||||
|
||||
private func _resume() async throws {
|
||||
@@ -684,25 +737,13 @@ extension UTMQemuVirtualMachine {
|
||||
}
|
||||
|
||||
func restoreSnapshot(name: String? = nil) async throws {
|
||||
// A stopped VM is restored by booting it directly into the requested snapshot tag,
|
||||
// reusing the suspend startup path via `pendingRestoreSnapshotName`.
|
||||
if state == .stopped, let name {
|
||||
pendingRestoreSnapshotName = name
|
||||
do {
|
||||
try await start()
|
||||
} catch {
|
||||
pendingRestoreSnapshotName = nil
|
||||
throw error
|
||||
}
|
||||
return
|
||||
}
|
||||
guard state == .paused || state == .started else {
|
||||
throw UTMQemuVirtualMachineError.invalidVmState
|
||||
}
|
||||
let prev = state
|
||||
state = .restoring
|
||||
do {
|
||||
try await _restoreSnapshot(name: name ?? kSuspendSnapshotName)
|
||||
try await _restoreSnapshot(name: name ?? suspendSnapshotName)
|
||||
state = prev
|
||||
} catch {
|
||||
if prev == .started, let monitor = await monitor {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// Copyright © 2026 Turing Software, LLC. All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// State of a snapshot as it is stored by a backend
|
||||
struct UTMSnapshotBackendEntry: Equatable {
|
||||
/// Unique in the backend, what `UTMSnapshotManifest.Snapshot.backendIdentifier` links to
|
||||
let identifier: String
|
||||
|
||||
let date: Date
|
||||
|
||||
/// Bytes occupied by the snapshot
|
||||
let size: Int64
|
||||
|
||||
/// Includes the running state of the VM and not just the disks
|
||||
let hasState: Bool
|
||||
|
||||
/// Covers every image that takes part in a snapshot.
|
||||
///
|
||||
/// One that does not, such as a snapshot taken before a drive was added, cannot be restored
|
||||
/// without leaving the drives holding contents from different points in time.
|
||||
var isComplete: Bool = true
|
||||
|
||||
/// Snapshot this one was taken from, for backends that keep track of it
|
||||
var parentIdentifier: String? = nil
|
||||
}
|
||||
|
||||
/// What can be done with the state of a snapshot
|
||||
enum UTMSnapshotOperation {
|
||||
/// Save the current state of the VM
|
||||
case create
|
||||
/// Replace the current state of the VM
|
||||
case restore
|
||||
case delete
|
||||
}
|
||||
|
||||
/// Where the state of snapshots is kept. There is an implementation for each kind of VM and,
|
||||
/// where they differ, for a VM that is running (live) and one that is not (frozen).
|
||||
///
|
||||
/// A backend knows nothing about `UTMSnapshotManifest`, use `UTMSnapshotService` instead.
|
||||
protocol UTMSnapshotBackend {
|
||||
/// Identifier of the state a suspended VM resumes from when the manifest does not name one
|
||||
var defaultSuspendIdentifier: String { get }
|
||||
|
||||
/// Where a snapshot is saved, so without any there is nothing to save
|
||||
var imageURLs: [URL] { get }
|
||||
|
||||
/// `entries()` lists the snapshots of exactly `imageURLs` and knows which are complete, which
|
||||
/// is needed to add snapshots from the backend or to find those whose state is gone
|
||||
var isInventoryExact: Bool { get }
|
||||
|
||||
/// An operation is possible while the VM is in the state the backend was picked for.
|
||||
/// - Parameters:
|
||||
/// - operation: Operation to perform
|
||||
/// - hasState: The snapshot includes the running state of the VM, ignored for `.create`
|
||||
func supports(_ operation: UTMSnapshotOperation, hasState: Bool) -> Bool
|
||||
|
||||
/// Every snapshot found in the backend, including those UTM did not create
|
||||
func entries() async throws -> [UTMSnapshotBackendEntry]
|
||||
|
||||
/// Save the current state of the VM.
|
||||
/// - Parameter suspendIdentifier: State the VM is suspended to, if it is. It already holds the
|
||||
/// current state so a backend may return it as the snapshot.
|
||||
func create(suspendIdentifier: String?) async throws -> UTMSnapshotBackendEntry
|
||||
|
||||
/// Replace the current state of the VM with a snapshot.
|
||||
/// - Returns: True if this only takes effect once the VM resumes from the snapshot when it is started
|
||||
func restore(_ entry: UTMSnapshotBackendEntry) async throws -> Bool
|
||||
|
||||
func delete(identifier: String) async throws
|
||||
}
|
||||
@@ -15,138 +15,554 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Logging
|
||||
|
||||
private let snapshotLogger = Logger(label: "com.utmapp.UTM.snapshot") { label in
|
||||
UTMLoggingSwift(label: label)
|
||||
}
|
||||
|
||||
/// Coordinates named full-VM snapshots for the QEMU backend.
|
||||
/// Manages the snapshots of a VM regardless of its backend and of whether it is running.
|
||||
///
|
||||
/// This is distinct from the internal suspend/resume feature which uses the reserved name
|
||||
/// `"suspend"` and `registryEntry.isSuspended`; named snapshots never mark a VM as suspended.
|
||||
/// The state of a snapshot is kept by a `UTMSnapshotBackend`. Everything else is kept in the
|
||||
/// `UTMSnapshotManifest` of the bundle which links to that state. Only this service changes the
|
||||
/// snapshots in a manifest so that the two stay consistent.
|
||||
///
|
||||
/// A snapshot never changes unless it is overwritten. Restoring one replaces the current state of
|
||||
/// the VM with a copy and the snapshot it was restored from is remembered as what the current
|
||||
/// state is based on, which is how snapshots get their lineage.
|
||||
///
|
||||
/// Suspending is closely related: the state a VM is suspended to can be kept as a snapshot and
|
||||
/// restoring a snapshot makes a stopped VM resume from it. The VM itself keeps track of what it
|
||||
/// resumes from through the suspend state operations of `UTMSnapshotManifest`.
|
||||
@MainActor
|
||||
enum UTMSnapshotService {
|
||||
/// Snapshot name reserved for the internal suspend/resume feature.
|
||||
private static let reservedSuspendName = "suspend"
|
||||
fileprivate typealias Entry = UTMSnapshotManifest.Snapshot
|
||||
|
||||
/// Create a named snapshot capturing the full VM state (RAM + devices + disk).
|
||||
/// Posted with the bundle of the VM as its object whenever its snapshots change
|
||||
static let didChangeNotification = UTMSnapshotManifest.didChangeNotification
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
/// Snapshots can be managed for this VM, whether it is running or not
|
||||
static func isSupported(for vm: any UTMVirtualMachine) -> Bool {
|
||||
#if os(macOS)
|
||||
if vm is UTMQemuVirtualMachine {
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
// qemu-img is needed for a VM that is not running and is only part of the macOS app
|
||||
return false
|
||||
}
|
||||
|
||||
/// Snapshots of a VM and how the current state relates to them. The first time, those that
|
||||
/// are already in the backend are added.
|
||||
static func timeline(for vm: any UTMVirtualMachine) async throws -> UTMSnapshotTimeline {
|
||||
var manifest = try UTMSnapshotManifest.load(from: vm.pathUrl) ?? UTMSnapshotManifest()
|
||||
if !manifest.isBackendImported, let backend = try? backend(for: vm), backend.isInventoryExact {
|
||||
manifest = try await importUnlinkedSnapshots(from: backend, on: vm)
|
||||
}
|
||||
return UTMSnapshotTimeline(snapshots: manifest.snapshots.map { UTMSnapshot($0, on: vm) },
|
||||
currentParentID: manifest.currentParentID,
|
||||
isCurrentStateSaved: isCurrentStateSaved(in: manifest, on: vm))
|
||||
}
|
||||
|
||||
/// An operation is possible while the VM is in its current state.
|
||||
///
|
||||
/// QEMU replaces an existing snapshot with the same name.
|
||||
/// Snapshots can be managed whether the VM is running or not, but not everything can be done
|
||||
/// either way. A running QEMU can only deal with snapshots that include the running state and
|
||||
/// the disks of a running Apple VM cannot be replaced.
|
||||
/// - Parameters:
|
||||
/// - operation: Operation to perform
|
||||
/// - snapshot: Snapshot to restore or delete
|
||||
/// - vm: VM the snapshot belongs to
|
||||
static func isSupported(_ operation: UTMSnapshotOperation, for snapshot: UTMSnapshot? = nil, on vm: any UTMVirtualMachine) -> Bool {
|
||||
guard let backend = try? backend(for: vm) else {
|
||||
return false
|
||||
}
|
||||
if operation == .create && backend.imageURLs.isEmpty {
|
||||
return false
|
||||
}
|
||||
return backend.supports(operation, hasState: snapshot?.hasState ?? true)
|
||||
}
|
||||
|
||||
/// Nothing changed since the snapshot the current state is based on, which is known when the
|
||||
/// VM is suspended to the state of that very snapshot.
|
||||
///
|
||||
/// Saving the current state again would only duplicate that snapshot.
|
||||
private static func isCurrentStateSaved(in manifest: UTMSnapshotManifest, on vm: any UTMVirtualMachine) -> Bool {
|
||||
// a VM that is not stopped may have run since it was suspended
|
||||
guard vm.state == .stopped, let backend = try? backend(for: vm), let parentID = manifest.currentParentID else {
|
||||
return false
|
||||
}
|
||||
let suspendIdentifier = vm.registryEntry.isSuspended ? manifest.suspendIdentifier ?? backend.defaultSuspendIdentifier : nil
|
||||
return manifest[parentID]?.backendIdentifier == suspendIdentifier
|
||||
}
|
||||
|
||||
fileprivate static func screenshotURL(for snapshot: Entry, on vm: any UTMVirtualMachine) -> URL? {
|
||||
snapshot.screenshotName.map { UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl).appendingPathComponent($0) }
|
||||
}
|
||||
|
||||
// MARK: - Operations
|
||||
|
||||
/// Save the current state of a VM as a new snapshot.
|
||||
/// - Parameters:
|
||||
/// - title: Display name, a default is used if nil
|
||||
/// - vm: VM that is either running or not
|
||||
@discardableResult
|
||||
static func createSnapshot(title: String? = nil, on vm: any UTMVirtualMachine) async throws -> UTMSnapshot {
|
||||
let backend = try backend(for: vm)
|
||||
guard !backend.imageURLs.isEmpty else {
|
||||
throw UTMSnapshotError.noDrives
|
||||
}
|
||||
guard backend.supports(.create, hasState: true) else {
|
||||
throw UTMSnapshotError.requiresStoppedVm
|
||||
}
|
||||
let suspendIdentifier = try suspendIdentifier(of: vm, in: backend)
|
||||
let entry = try await backend.create(suspendIdentifier: suspendIdentifier)
|
||||
logger.debug("Created snapshot '\(entry.identifier)'")
|
||||
let screenshotName = saveScreenshot(of: vm)
|
||||
let isSuspendStateKept = suspendIdentifier != nil && entry.hasState && vm.state != .started
|
||||
let title = title.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.flatMap { $0.isEmpty ? nil : $0 }
|
||||
let (snapshot, staleIdentifiers) = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> (Entry, [String]) in
|
||||
if isSuspendStateKept {
|
||||
// the VM still resumes from the state which is now the one of the snapshot
|
||||
manifest.replaceSuspendState(suspendIdentifier, with: entry.identifier)
|
||||
}
|
||||
let snapshot = Entry(title: title ?? manifest.defaultTitle,
|
||||
parentID: manifest.currentParentID,
|
||||
date: entry.date,
|
||||
size: entry.size,
|
||||
hasState: entry.hasState,
|
||||
backendIdentifier: entry.identifier,
|
||||
screenshotName: screenshotName)
|
||||
manifest.insert(snapshot)
|
||||
manifest.currentParentID = snapshot.id
|
||||
return (snapshot, manifest.staleIdentifiers)
|
||||
}
|
||||
await deleteStaleStates(staleIdentifiers, from: backend, on: vm)
|
||||
return UTMSnapshot(snapshot, on: vm)
|
||||
}
|
||||
|
||||
/// Replace what a snapshot holds with the current state of a VM.
|
||||
///
|
||||
/// Everything else about the snapshot stays, including the snapshots based on it.
|
||||
static func overwriteSnapshot(_ id: UUID, on vm: any UTMVirtualMachine) async throws {
|
||||
let backend = try backend(for: vm)
|
||||
guard let original = try UTMSnapshotManifest.load(from: vm.pathUrl)?[id] else {
|
||||
throw UTMSnapshotError.notFound(id.uuidString)
|
||||
}
|
||||
guard !backend.imageURLs.isEmpty else {
|
||||
throw UTMSnapshotError.noDrives
|
||||
}
|
||||
guard backend.supports(.create, hasState: true) && backend.supports(.delete, hasState: original.hasState) else {
|
||||
throw UTMSnapshotError.requiresStoppedVm
|
||||
}
|
||||
let suspendIdentifier = try suspendIdentifier(of: vm, in: backend)
|
||||
let entry = try await backend.create(suspendIdentifier: suspendIdentifier)
|
||||
logger.debug("Overwriting snapshot '\(original.backendIdentifier)' with '\(entry.identifier)'")
|
||||
let screenshotName = saveScreenshot(of: vm)
|
||||
let isSuspendStateKept = suspendIdentifier != nil && entry.hasState && vm.state != .started
|
||||
let staleIdentifiers = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> [String] in
|
||||
if isSuspendStateKept {
|
||||
manifest.replaceSuspendState(suspendIdentifier, with: entry.identifier)
|
||||
}
|
||||
if var snapshot = manifest[id] {
|
||||
snapshot.backendIdentifier = entry.identifier
|
||||
snapshot.size = entry.size
|
||||
snapshot.hasState = entry.hasState
|
||||
snapshot.screenshotName = screenshotName
|
||||
snapshot.dateModified = Date()
|
||||
snapshot.isOrphaned = false
|
||||
snapshot.isIncomplete = false
|
||||
manifest[id] = snapshot
|
||||
}
|
||||
manifest.currentParentID = id
|
||||
manifest.markStale(original.backendIdentifier)
|
||||
return manifest.staleIdentifiers
|
||||
}
|
||||
await deleteStaleStates(staleIdentifiers, from: backend, on: vm)
|
||||
if let screenshotName = original.screenshotName {
|
||||
try? FileManager.default.removeItem(at: UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl).appendingPathComponent(screenshotName))
|
||||
}
|
||||
}
|
||||
|
||||
/// - Returns: The snapshot with the title it ended up with
|
||||
@discardableResult
|
||||
static func renameSnapshot(_ id: UUID, to title: String, on vm: any UTMVirtualMachine) throws -> UTMSnapshot {
|
||||
let title = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let snapshot = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> Entry in
|
||||
guard var snapshot = manifest[id] else {
|
||||
throw UTMSnapshotError.notFound(id.uuidString)
|
||||
}
|
||||
guard !title.isEmpty && snapshot.title != title else {
|
||||
return snapshot
|
||||
}
|
||||
snapshot.title = title
|
||||
snapshot.dateModified = Date()
|
||||
manifest[id] = snapshot
|
||||
return snapshot
|
||||
}
|
||||
return UTMSnapshot(snapshot, on: vm)
|
||||
}
|
||||
|
||||
/// Delete a snapshot, along with its state once nothing else links to it.
|
||||
static func deleteSnapshot(_ id: UUID, on vm: any UTMVirtualMachine) async throws {
|
||||
let backend = try backend(for: vm)
|
||||
guard let manifest = try UTMSnapshotManifest.load(from: vm.pathUrl), let snapshot = manifest[id] else {
|
||||
throw UTMSnapshotError.notFound(id.uuidString)
|
||||
}
|
||||
guard backend.supports(.delete, hasState: snapshot.hasState) else {
|
||||
throw UTMSnapshotError.requiresStoppedVm
|
||||
}
|
||||
var remaining = manifest
|
||||
remaining.remove(id)
|
||||
if !remaining.isReferenced(backendIdentifier: snapshot.backendIdentifier) {
|
||||
do {
|
||||
try await backend.delete(identifier: snapshot.backendIdentifier)
|
||||
logger.debug("Deleted snapshot '\(snapshot.backendIdentifier)'")
|
||||
} catch UTMSnapshotError.notFound(_) {
|
||||
// orphaned
|
||||
} catch {
|
||||
// some drives may have lost the state already, so it must not look restorable
|
||||
_ = try? await identifyOrphanedSnapshots(on: vm)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest in
|
||||
manifest.remove(id)
|
||||
}
|
||||
if let screenshotName = snapshot.screenshotName {
|
||||
try? FileManager.default.removeItem(at: UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl).appendingPathComponent(screenshotName))
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the current state of a VM with a snapshot.
|
||||
///
|
||||
/// A running VM continues from the snapshot right away. Otherwise it resumes from it, or boots
|
||||
/// from its disks if the snapshot has no running state, the next time it is started.
|
||||
static func restoreSnapshot(_ id: UUID, on vm: any UTMVirtualMachine) async throws {
|
||||
let backend = try backend(for: vm)
|
||||
guard let snapshot = try UTMSnapshotManifest.load(from: vm.pathUrl)?[id] else {
|
||||
throw UTMSnapshotError.notFound(id.uuidString)
|
||||
}
|
||||
guard backend.supports(.restore, hasState: snapshot.hasState) else {
|
||||
throw UTMSnapshotError.requiresStoppedVm
|
||||
}
|
||||
guard let entry = try await backend.entries().first(where: { $0.identifier == snapshot.backendIdentifier }) else {
|
||||
try? await identifyOrphanedSnapshots(on: vm)
|
||||
throw UTMSnapshotError.orphaned(snapshot.title)
|
||||
}
|
||||
guard entry.isComplete else {
|
||||
try? await identifyOrphanedSnapshots(on: vm)
|
||||
throw UTMSnapshotError.incomplete(snapshot.title)
|
||||
}
|
||||
// a state left named after a start that failed is replaced as well
|
||||
let previousSuspendIdentifier = try suspendIdentifier(of: vm, in: backend)
|
||||
let isResumedOnStart = try await backend.restore(entry)
|
||||
logger.debug("Restored snapshot '\(entry.identifier)'")
|
||||
let staleIdentifiers = try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest -> [String] in
|
||||
manifest.replaceSuspendState(previousSuspendIdentifier, with: isResumedOnStart ? entry.identifier : nil)
|
||||
manifest.currentParentID = id
|
||||
return manifest.staleIdentifiers
|
||||
}
|
||||
vm.registryEntry.isSuspended = isResumedOnStart
|
||||
// the state that was suspended to is replaced
|
||||
await deleteStaleStates(staleIdentifiers, from: backend, on: vm)
|
||||
// the current state is a copy of the snapshot now, so show what the snapshot shows
|
||||
restoreScreenshot(of: snapshot, on: vm)
|
||||
}
|
||||
|
||||
// MARK: - Consistency with the backend
|
||||
|
||||
/// Find snapshots whose state is no longer in the backend, such as after another tool
|
||||
/// modified it, and mark them in the manifest.
|
||||
///
|
||||
/// Call this when something that depends on the state of a snapshot fails.
|
||||
static func identifyOrphanedSnapshots(on vm: any UTMVirtualMachine) async throws {
|
||||
let backend = try backend(for: vm)
|
||||
guard backend.isInventoryExact else {
|
||||
return
|
||||
}
|
||||
let entries = try await backend.entries()
|
||||
try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest in
|
||||
for index in manifest.snapshots.indices {
|
||||
let entry = entries.first { $0.identifier == manifest.snapshots[index].backendIdentifier }
|
||||
manifest.snapshots[index].isOrphaned = entry == nil
|
||||
// the state is there but does not cover every drive, which is not the same as gone
|
||||
manifest.snapshots[index].isIncomplete = entry.map { !$0.isComplete } ?? false
|
||||
}
|
||||
manifest.forgetStaleStates(notIn: entries)
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a snapshot for every state in the backend that none links to, such as those made by
|
||||
/// another tool, and refresh what the backend knows about the others.
|
||||
///
|
||||
/// Anything the backend does not store is generated.
|
||||
@discardableResult
|
||||
private static func importUnlinkedSnapshots(from backend: any UTMSnapshotBackend, on vm: any UTMVirtualMachine) async throws -> UTMSnapshotManifest {
|
||||
let entries = try await backend.entries().sorted { $0.date < $1.date }
|
||||
return try UTMSnapshotManifest.update(in: vm.pathUrl) { manifest in
|
||||
for index in manifest.snapshots.indices {
|
||||
if let entry = entries.first(where: { $0.identifier == manifest.snapshots[index].backendIdentifier }) {
|
||||
manifest.snapshots[index].size = entry.size
|
||||
manifest.snapshots[index].hasState = entry.hasState
|
||||
manifest.snapshots[index].isOrphaned = false
|
||||
manifest.snapshots[index].isIncomplete = !entry.isComplete
|
||||
}
|
||||
}
|
||||
manifest.forgetStaleStates(notIn: entries)
|
||||
for entry in entries where !manifest.isReferenced(backendIdentifier: entry.identifier) {
|
||||
guard entry.identifier != backend.defaultSuspendIdentifier && !manifest.staleIdentifiers.contains(entry.identifier) else {
|
||||
// only ever used for suspending, or waiting to be deleted
|
||||
continue
|
||||
}
|
||||
logger.debug("Importing snapshot '\(entry.identifier)'")
|
||||
// an identifier that is not generated was chosen by someone
|
||||
let title = UUID(uuidString: entry.identifier) == nil ? manifest.uniqueTitle(entry.identifier) : manifest.defaultTitle
|
||||
let parent = manifest.snapshots.first { $0.backendIdentifier == entry.parentIdentifier }
|
||||
var snapshot = Entry(title: title,
|
||||
parentID: parent?.id,
|
||||
date: entry.date,
|
||||
size: entry.size,
|
||||
hasState: entry.hasState,
|
||||
backendIdentifier: entry.identifier)
|
||||
snapshot.isIncomplete = !entry.isComplete
|
||||
manifest.insert(snapshot)
|
||||
}
|
||||
manifest.isBackendImported = true
|
||||
return manifest
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snapshots by name
|
||||
|
||||
/// Create a snapshot or overwrite the one with the same title.
|
||||
static func createSnapshot(name: String, on vm: any UTMVirtualMachine) async throws {
|
||||
_ = try requireQemuBackend(vm)
|
||||
try validate(name: name)
|
||||
if let error = vm.snapshotUnsupportedError {
|
||||
throw error
|
||||
if let snapshot = try await timeline(for: vm).snapshots.first(where: { $0.title == name }) {
|
||||
try await overwriteSnapshot(snapshot.id, on: vm)
|
||||
} else {
|
||||
try await createSnapshot(title: name, on: vm)
|
||||
}
|
||||
// capturing RAM through the QEMU monitor requires the VM to be running or paused
|
||||
guard vm.state == .started || vm.state == .paused else {
|
||||
throw UTMSnapshotError.invalidVmState
|
||||
}
|
||||
snapshotLogger.debug("Creating snapshot '\(name)' on QEMU VM")
|
||||
try await vm.saveSnapshot(name: name)
|
||||
}
|
||||
|
||||
/// List full-VM snapshots stored in the VM's bundled writable disk images, newest first.
|
||||
static func listSnapshots(on vm: any UTMVirtualMachine) async throws -> [UTMQemuImage.QemuSnapshotInfo] {
|
||||
let qemu = try requireQemuBackend(vm)
|
||||
guard vm.state == .stopped else {
|
||||
throw UTMSnapshotError.listRequiresStoppedVm
|
||||
}
|
||||
var snapshotsByName = [String: UTMQemuImage.QemuSnapshotInfo]()
|
||||
var imageURLs = qemu.config.drives.compactMap { drive -> URL? in
|
||||
guard drive.imageType == .disk && !drive.isExternal && !drive.isReadOnly else {
|
||||
return nil
|
||||
}
|
||||
return drive.imageURL
|
||||
}
|
||||
if qemu.config.qemu.hasUefiBoot,
|
||||
let efiVarsURL = qemu.config.qemu.efiVarsURL,
|
||||
FileManager.default.fileExists(atPath: efiVarsURL.path) {
|
||||
imageURLs.insert(efiVarsURL, at: 0)
|
||||
}
|
||||
for imageURL in imageURLs {
|
||||
let imageInfo = try await UTMQemuImage.info(image: imageURL)
|
||||
for snapshot in imageInfo.snapshots ?? [] where snapshot.vmStateSize > 0 && snapshot.name != reservedSuspendName {
|
||||
snapshotsByName[snapshot.name] = snapshot
|
||||
}
|
||||
}
|
||||
return snapshotsByName.values.sorted { $0.date > $1.date }
|
||||
/// Titles of all snapshots, newest first
|
||||
static func listSnapshots(on vm: any UTMVirtualMachine) async throws -> [String] {
|
||||
_ = try backend(for: vm)
|
||||
return try await timeline(for: vm).snapshots.map { $0.title }
|
||||
}
|
||||
|
||||
/// Restore the VM to a previously captured named snapshot.
|
||||
static func restoreSnapshot(name: String, on vm: any UTMVirtualMachine) async throws {
|
||||
_ = try requireQemuBackend(vm)
|
||||
try validate(name: name)
|
||||
guard vm.state == .stopped else {
|
||||
throw UTMSnapshotError.restoreRequiresStoppedVm
|
||||
}
|
||||
guard try await listSnapshots(on: vm).contains(where: { $0.name == name }) else {
|
||||
try await restoreSnapshot(try await snapshot(named: name, on: vm).id, on: vm)
|
||||
}
|
||||
|
||||
static func deleteSnapshot(name: String, on vm: any UTMVirtualMachine) async throws {
|
||||
try await deleteSnapshot(try await snapshot(named: name, on: vm).id, on: vm)
|
||||
}
|
||||
|
||||
private static func snapshot(named name: String, on vm: any UTMVirtualMachine) async throws -> UTMSnapshot {
|
||||
guard let snapshot = try await timeline(for: vm).snapshots.first(where: { $0.title == name }) else {
|
||||
throw UTMSnapshotError.notFound(name)
|
||||
}
|
||||
snapshotLogger.debug("Restoring snapshot '\(name)' on QEMU VM")
|
||||
try await vm.restoreSnapshot(name: name)
|
||||
}
|
||||
|
||||
/// Delete a named snapshot and its backend state.
|
||||
static func deleteSnapshot(name: String, on vm: any UTMVirtualMachine) async throws {
|
||||
_ = try requireQemuBackend(vm)
|
||||
try validate(name: name)
|
||||
// QEMU deletes the tag from the running qcow2 via the monitor, so it must be running.
|
||||
if vm.state != .started && vm.state != .paused {
|
||||
throw UTMSnapshotError.invalidVmState
|
||||
}
|
||||
snapshotLogger.debug("Deleting snapshot '\(name)' on QEMU VM")
|
||||
try await vm.deleteSnapshot(name: name)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func requireQemuBackend(_ vm: any UTMVirtualMachine) throws -> UTMQemuVirtualMachine {
|
||||
guard let qemu = vm as? UTMQemuVirtualMachine else {
|
||||
/// Pick the backend for the kind of VM and for whether it is running
|
||||
private static func backend(for vm: any UTMVirtualMachine) throws -> any UTMSnapshotBackend {
|
||||
guard !vm.isRunningAsDisposible else {
|
||||
throw UTMSnapshotError.notSupported
|
||||
}
|
||||
return qemu
|
||||
guard isSupported(for: vm) else {
|
||||
throw UTMSnapshotError.notSupported
|
||||
}
|
||||
if let qemu = vm as? UTMQemuVirtualMachine {
|
||||
switch vm.state {
|
||||
case .started, .paused: return UTMQemuLiveSnapshotBackend(vm: qemu, imageURLs: qemu.snapshotImageURLs)
|
||||
case .stopped: return UTMQemuFrozenSnapshotBackend(imageURLs: qemu.snapshotImageURLs, vm: qemu)
|
||||
default: throw UTMSnapshotError.invalidVmState
|
||||
}
|
||||
}
|
||||
throw UTMSnapshotError.notSupported
|
||||
}
|
||||
|
||||
private static func validate(name: String) throws {
|
||||
if name.caseInsensitiveCompare(reservedSuspendName) == .orderedSame {
|
||||
throw UTMSnapshotError.reservedName(name)
|
||||
/// State the VM is suspended to, if it is
|
||||
private static func suspendIdentifier(of vm: any UTMVirtualMachine, in backend: any UTMSnapshotBackend) throws -> String? {
|
||||
guard vm.registryEntry.isSuspended else {
|
||||
return nil
|
||||
}
|
||||
let pattern = "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$"
|
||||
guard name.range(of: pattern, options: .regularExpression) != nil else {
|
||||
throw UTMSnapshotError.invalidName(name)
|
||||
return try UTMSnapshotManifest.load(from: vm.pathUrl)?.suspendIdentifier ?? backend.defaultSuspendIdentifier
|
||||
}
|
||||
|
||||
/// Delete states that nothing needs anymore. Each stays listed in the manifest until it is
|
||||
/// gone so that one that fails, such as one a running VM cannot delete, is tried again the
|
||||
/// next time instead of staying in the backend for good.
|
||||
private static func deleteStaleStates(_ identifiers: [String], from backend: any UTMSnapshotBackend, on vm: any UTMVirtualMachine) async {
|
||||
for identifier in identifiers {
|
||||
do {
|
||||
try await backend.delete(identifier: identifier)
|
||||
logger.debug("Deleted stale state '\(identifier)'")
|
||||
} catch UTMSnapshotError.notFound(_) {
|
||||
// already gone
|
||||
} catch {
|
||||
logger.debug("Keeping stale state '\(identifier)': \(error)")
|
||||
continue
|
||||
}
|
||||
try? UTMSnapshotManifest.forgetState(identifier, in: vm.pathUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the most recent screenshot of the VM for a snapshot.
|
||||
/// - Returns: Name of the file in the screenshots directory
|
||||
private static func saveScreenshot(of vm: any UTMVirtualMachine) -> String? {
|
||||
let fileManager = FileManager.default
|
||||
let screenshotsURL = UTMSnapshotManifest.screenshotsURL(in: vm.pathUrl)
|
||||
let name = UUID().uuidString + ".png"
|
||||
if vm.state != .stopped {
|
||||
try? vm.saveScreenshot()
|
||||
}
|
||||
do {
|
||||
try fileManager.createDirectory(at: screenshotsURL, withIntermediateDirectories: true)
|
||||
try fileManager.copyItem(at: vm.pathUrl.appendingPathComponent(kUTMBundleScreenshotFilename), to: screenshotsURL.appendingPathComponent(name))
|
||||
return name
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Show what a snapshot shows for the current state of a VM.
|
||||
///
|
||||
/// A snapshot taken while the screen was not being kept has no picture of its own, and then
|
||||
/// the one on show belongs to a state the VM is no longer in, so it goes.
|
||||
private static func restoreScreenshot(of snapshot: Entry, on vm: any UTMVirtualMachine) {
|
||||
let fileManager = FileManager.default
|
||||
let currentURL = vm.pathUrl.appendingPathComponent(kUTMBundleScreenshotFilename)
|
||||
try? fileManager.removeItem(at: currentURL)
|
||||
if let screenshotURL = screenshotURL(for: snapshot, on: vm), fileManager.fileExists(atPath: screenshotURL.path) {
|
||||
try? fileManager.copyItem(at: screenshotURL, to: currentURL)
|
||||
}
|
||||
try? vm.reloadScreenshotFromFile()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Read model
|
||||
|
||||
/// A snapshot of a VM as it is shown and scripted
|
||||
struct UTMSnapshot: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
|
||||
/// User-editable display name
|
||||
var title: String
|
||||
|
||||
/// Snapshot the current state was based on when this one was created, `nil` if there was none
|
||||
var parentID: UUID?
|
||||
|
||||
var dateCreated: Date
|
||||
|
||||
/// Last time the snapshot was renamed or overwritten
|
||||
var dateModified: Date
|
||||
|
||||
/// Bytes occupied in the backend
|
||||
var size: Int64
|
||||
|
||||
/// Includes the running state of the VM, otherwise it starts up from the saved disks
|
||||
var hasState: Bool
|
||||
|
||||
/// The saved state is missing so the snapshot can only be deleted
|
||||
var isOrphaned: Bool
|
||||
|
||||
/// The saved state does not cover every drive so the snapshot cannot be restored
|
||||
var isIncomplete: Bool
|
||||
|
||||
/// Screen contents when the snapshot was taken, if one was captured
|
||||
var screenshotURL: URL?
|
||||
|
||||
@MainActor
|
||||
fileprivate init(_ entry: UTMSnapshotManifest.Snapshot, on vm: any UTMVirtualMachine) {
|
||||
id = entry.id
|
||||
title = entry.title
|
||||
parentID = entry.parentID
|
||||
dateCreated = entry.dateCreated
|
||||
dateModified = entry.dateModified
|
||||
size = entry.size
|
||||
hasState = entry.hasState
|
||||
isOrphaned = entry.isOrphaned
|
||||
isIncomplete = entry.isIncomplete
|
||||
screenshotURL = UTMSnapshotService.screenshotURL(for: entry, on: vm)
|
||||
}
|
||||
}
|
||||
|
||||
/// The snapshots of a VM along with the current state they are listed under
|
||||
struct UTMSnapshotTimeline: Equatable {
|
||||
/// Newest first
|
||||
var snapshots: [UTMSnapshot]
|
||||
|
||||
/// Snapshot the current state descends from, `nil` if there is none
|
||||
var currentParentID: UUID?
|
||||
|
||||
/// Nothing changed since the snapshot the current state is based on
|
||||
var isCurrentStateSaved: Bool
|
||||
}
|
||||
|
||||
private extension UTMSnapshotManifest {
|
||||
/// Stop resuming from `previous`, the state the VM was found suspended to, which the manifest
|
||||
/// may not name when it was suspended by an older version
|
||||
mutating func replaceSuspendState(_ previous: String?, with backendIdentifier: String?) {
|
||||
if suspendIdentifier == nil {
|
||||
suspendIdentifier = previous
|
||||
}
|
||||
setSuspendState(backendIdentifier)
|
||||
}
|
||||
|
||||
/// States that were to be deleted and are gone from an exact inventory need no deleting
|
||||
mutating func forgetStaleStates(notIn entries: [UTMSnapshotBackendEntry]) {
|
||||
staleIdentifiers.removeAll { identifier in !entries.contains { $0.identifier == identifier } }
|
||||
}
|
||||
|
||||
var defaultTitle: String {
|
||||
var number = snapshots.count + 1
|
||||
var title: String
|
||||
repeat {
|
||||
title = String.localizedStringWithFormat(NSLocalizedString("Snapshot %lld", comment: "UTMSnapshotService"), number)
|
||||
number += 1
|
||||
} while snapshots.contains { $0.title == title }
|
||||
return title
|
||||
}
|
||||
|
||||
func uniqueTitle(_ base: String) -> String {
|
||||
var title = base
|
||||
var number = 2
|
||||
while snapshots.contains(where: { $0.title == title }) {
|
||||
title = "\(base) \(number)"
|
||||
number += 1
|
||||
}
|
||||
return title
|
||||
}
|
||||
}
|
||||
|
||||
enum UTMSnapshotError: Error {
|
||||
case reservedName(String)
|
||||
case invalidName(String)
|
||||
case notFound(String)
|
||||
case orphaned(String)
|
||||
case incomplete(String)
|
||||
case noDrives
|
||||
case notSupported
|
||||
case invalidVmState
|
||||
case listRequiresStoppedVm
|
||||
case restoreRequiresStoppedVm
|
||||
case requiresStoppedVm
|
||||
}
|
||||
|
||||
extension UTMSnapshotError: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .reservedName(let name):
|
||||
return String.localizedStringWithFormat(NSLocalizedString("The name '%@' is reserved and cannot be used for a snapshot.", comment: "UTMSnapshotService"), name)
|
||||
case .invalidName(let name):
|
||||
return String.localizedStringWithFormat(NSLocalizedString("The name '%@' is not a valid snapshot name. Use letters, numbers, and the characters _.- (up to 64 characters).", comment: "UTMSnapshotService"), name)
|
||||
case .notFound(let name):
|
||||
return String.localizedStringWithFormat(NSLocalizedString("The snapshot '%@' does not exist.", comment: "UTMSnapshotService"), name)
|
||||
case .orphaned(let name):
|
||||
return String.localizedStringWithFormat(NSLocalizedString("The data of the snapshot '%@' is missing. It may have been removed by another application.", comment: "UTMSnapshotService"), name)
|
||||
case .incomplete(let name):
|
||||
return String.localizedStringWithFormat(NSLocalizedString("The snapshot '%@' cannot be restored because it does not cover every drive of the virtual machine, such as a drive added after it was taken.", comment: "UTMSnapshotService"), name)
|
||||
case .noDrives:
|
||||
return NSLocalizedString("A snapshot needs a drive that the virtual machine can write to.", comment: "UTMSnapshotService")
|
||||
case .notSupported:
|
||||
return NSLocalizedString("Snapshots are not supported for this virtual machine.", comment: "UTMSnapshotService")
|
||||
case .invalidVmState:
|
||||
return NSLocalizedString("The virtual machine is in an invalid state for this snapshot operation.", comment: "UTMSnapshotService")
|
||||
case .listRequiresStoppedVm:
|
||||
return NSLocalizedString("The virtual machine must be stopped before listing snapshots.", comment: "UTMSnapshotService")
|
||||
case .restoreRequiresStoppedVm:
|
||||
return NSLocalizedString("The virtual machine must be stopped before restoring a snapshot.", comment: "UTMSnapshotService")
|
||||
case .requiresStoppedVm:
|
||||
return NSLocalizedString("The virtual machine must be stopped for this snapshot operation.", comment: "UTMSnapshotService")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,12 +140,18 @@
|
||||
8453DCB2278CE3D10037A0DA /* qemu-img.framework in Embed Libraries */ = {isa = PBXBuildFile; fileRef = 8453DCB0278CE33E0037A0DA /* qemu-img.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
8453DCB4278CE5410037A0DA /* UTMQemuImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */; };
|
||||
393A92DAE84A47029BA5F829 /* UTMSnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */; };
|
||||
738DDF2A88F46375A0D72177 /* UTMQemuSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */; };
|
||||
EFAB3C24B8C39BC85EEFE028 /* UTMSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */; };
|
||||
AFC3FBE9C92083C52D800437 /* UTMSnapshotManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */; };
|
||||
8453DCB5278CE5410037A0DA /* UTMQemuImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */; };
|
||||
41FCD5A63CB34D7F8E20F687 /* UTMSnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */; };
|
||||
8F8F6CA6377BBED5737019BB /* UTMQemuSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */; };
|
||||
12618B76026270B5C1C8CD29 /* UTMSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */; };
|
||||
BCB19D49AC1352354193ACB6 /* UTMSnapshotManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */; };
|
||||
8453DCB6278CE5410037A0DA /* UTMQemuImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */; };
|
||||
FA897DF5FDA34A4F8F6782AD /* UTMSnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */; };
|
||||
63353664DA55B4F6AC8BD07B /* UTMQemuSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */; };
|
||||
6E14FA50A12736E32EA02200 /* UTMSnapshotBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */; };
|
||||
E1B70310A596175A0E9F3025 /* UTMSnapshotManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */; };
|
||||
845F1705289B1EEB00944904 /* UTMAppleConfigurationGenericPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F1704289B1EEB00944904 /* UTMAppleConfigurationGenericPlatform.swift */; };
|
||||
845F1707289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F1706289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift */; };
|
||||
@@ -1792,6 +1798,8 @@
|
||||
8453DCB0278CE33E0037A0DA /* qemu-img.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "qemu-img.framework"; path = "$(SYSROOT_DIR)/Frameworks/qemu-img.framework"; sourceTree = "<group>"; };
|
||||
8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMQemuImage.swift; sourceTree = "<group>"; };
|
||||
EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMSnapshotService.swift; sourceTree = "<group>"; };
|
||||
F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMQemuSnapshotBackend.swift; sourceTree = "<group>"; };
|
||||
DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMSnapshotBackend.swift; sourceTree = "<group>"; };
|
||||
0F9CF0C724EB2633D0B87ACE /* UTMSnapshotManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMSnapshotManifest.swift; sourceTree = "<group>"; };
|
||||
845F1704289B1EEB00944904 /* UTMAppleConfigurationGenericPlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleConfigurationGenericPlatform.swift; sourceTree = "<group>"; };
|
||||
845F1706289B5E2600944904 /* VMAppleSettingsAddDeviceMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMAppleSettingsAddDeviceMenuView.swift; sourceTree = "<group>"; };
|
||||
@@ -2997,6 +3005,8 @@
|
||||
CE9D197B226542FE00355E14 /* UTMProcess.m */,
|
||||
8453DCB3278CE5410037A0DA /* UTMQemuImage.swift */,
|
||||
EFC4D9A586DD424D85B2FA1C /* UTMSnapshotService.swift */,
|
||||
F7335781A3EBC95B7BE01C90 /* UTMQemuSnapshotBackend.swift */,
|
||||
DE6FCF9E7BD060AB81411ABC /* UTMSnapshotBackend.swift */,
|
||||
84A0A8822A47D52E0038F329 /* UTMQemuPort.swift */,
|
||||
CE03D05424D90BE000F76B84 /* UTMQemuSystem.h */,
|
||||
CE03D05024D90B4E00F76B84 /* UTMQemuSystem.m */,
|
||||
@@ -3916,6 +3926,8 @@
|
||||
848D99A8285DB5550055C215 /* VMConfigConstantPicker.swift in Sources */,
|
||||
8453DCB4278CE5410037A0DA /* UTMQemuImage.swift in Sources */,
|
||||
393A92DAE84A47029BA5F829 /* UTMSnapshotService.swift in Sources */,
|
||||
738DDF2A88F46375A0D72177 /* UTMQemuSnapshotBackend.swift in Sources */,
|
||||
EFAB3C24B8C39BC85EEFE028 /* UTMSnapshotBackend.swift in Sources */,
|
||||
AFC3FBE9C92083C52D800437 /* UTMSnapshotManifest.swift in Sources */,
|
||||
CE2D955924AD4F980059923A /* VMToolbarModifier.swift in Sources */,
|
||||
CE2D92DA24AD46670059923A /* VMCursor.m in Sources */,
|
||||
@@ -4164,6 +4176,8 @@
|
||||
CE2D956224AD4F990059923A /* VMSettingsView.swift in Sources */,
|
||||
8453DCB6278CE5410037A0DA /* UTMQemuImage.swift in Sources */,
|
||||
FA897DF5FDA34A4F8F6782AD /* UTMSnapshotService.swift in Sources */,
|
||||
63353664DA55B4F6AC8BD07B /* UTMQemuSnapshotBackend.swift in Sources */,
|
||||
6E14FA50A12736E32EA02200 /* UTMSnapshotBackend.swift in Sources */,
|
||||
E1B70310A596175A0E9F3025 /* UTMSnapshotManifest.swift in Sources */,
|
||||
CEDF83FA258AE24E0030E4AC /* UTMPasteboard.swift in Sources */,
|
||||
848F71EE277A2F47006A0240 /* UTMSerialPortDelegate.swift in Sources */,
|
||||
@@ -4309,6 +4323,8 @@
|
||||
848A98C5286F332D006F0550 /* UTMConfiguration.swift in Sources */,
|
||||
8453DCB5278CE5410037A0DA /* UTMQemuImage.swift in Sources */,
|
||||
41FCD5A63CB34D7F8E20F687 /* UTMSnapshotService.swift in Sources */,
|
||||
8F8F6CA6377BBED5737019BB /* UTMQemuSnapshotBackend.swift in Sources */,
|
||||
12618B76026270B5C1C8CD29 /* UTMSnapshotBackend.swift in Sources */,
|
||||
BCB19D49AC1352354193ACB6 /* UTMSnapshotManifest.swift in Sources */,
|
||||
84C60FBB268269D700B58C00 /* VMDisplayViewController.swift in Sources */,
|
||||
8443EFFB28456F3B00B2E6E2 /* UTMQemuConfigurationSharing.swift in Sources */,
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"location" : "https://github.com/utmapp/QEMUKit.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "589765abff27a8764d58b1a90999a204ac09881e"
|
||||
"revision" : "14f445a7333edc7113f9e37f6d2915b1c5ca00b8"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user