Skip to content

Commit e226583

Browse files
committed
Support stacked VM archive import and export
1 parent dfd52fa commit e226583

5 files changed

Lines changed: 345 additions & 41 deletions

File tree

Sources/tart/Commands/Import.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ struct Import: AsyncParsableCommand {
2121

2222
// Create a temporary VM directory to which we will load the export file
2323
let tmpVMDir = try VMDirectory.temporary()
24+
defer {
25+
try? tmpVMDir.removeFromDisk()
26+
}
2427

2528
// Lock the temporary VM directory to prevent it's garbage collection
2629
// while we're running
@@ -30,10 +33,8 @@ struct Import: AsyncParsableCommand {
3033
// Populate the temporary VM directory with the export file contents
3134
print("importing...")
3235
try tmpVMDir.importFromArchive(path: path)
33-
34-
if tmpVMDir.isStackedVM || tmpVMDir.isStackedCachedImage {
35-
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
36-
throw RuntimeError.ImportFailed("importing stacked VMs is not supported yet")
36+
guard tmpVMDir.initialized else {
37+
throw RuntimeError.ImportFailed("archive does not contain a runnable VM")
3738
}
3839

3940
try await withTaskCancellationHandler(operation: {
@@ -50,7 +51,7 @@ struct Import: AsyncParsableCommand {
5051

5152
try lock.unlock()
5253
}, onCancel: {
53-
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
54+
try? tmpVMDir.removeFromDisk()
5455
})
5556
}
5657
}

Sources/tart/DiskImageStack.swift

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,22 @@ struct DiskImageStack {
4545
let blockSize: UInt64
4646
let blockCount: UInt64
4747

48+
static var isSupported: Bool {
49+
#if canImport(DiskImageKit)
50+
if #available(macOS 27.0, *) {
51+
return true
52+
}
53+
#endif
54+
55+
return false
56+
}
57+
58+
static func requireSupport() throws {
59+
guard isSupported else {
60+
throw DiskImageStackError.unavailable
61+
}
62+
}
63+
4864
/// Reads a disk image's current block layout without resolving or validating a
4965
/// whole stack. This is used for the VM's private writable overlay, whose
5066
/// size may be newer than the pinned immutable parent manifest.
@@ -69,21 +85,7 @@ struct DiskImageStack {
6985
#if canImport(DiskImageKit)
7086
if #available(macOS 27.0, *) {
7187
let image = try DiskImage(opening: .open(url: url, mode: .readOnly))
72-
let matchesFormat = switch expectedFormat {
73-
case .raw:
74-
image.format == .raw
75-
case .asif:
76-
image.format == .asif
77-
}
78-
guard matchesFormat else {
79-
throw DiskImageStackError.invalidDiskImage(url, "base disk format does not match")
80-
}
81-
guard image.layerType == nil, image.parentUUID == nil else {
82-
throw DiskImageStackError.invalidDiskImage(url, "base disk must not be an overlay")
83-
}
84-
if expectedFormat == .asif && image.layerUUID == nil {
85-
throw DiskImageStackError.invalidDiskImage(url, "ASIF base disk is missing a UUID")
86-
}
88+
try validateBase(image, at: url, expectedFormat: expectedFormat)
8789

8890
return DiskImageBlockLayout(
8991
blockSize: UInt64(image.blockSize.rawValue),
@@ -214,7 +216,7 @@ struct DiskImageStack {
214216
}
215217

216218
let baseImage = try DiskImage(opening: .open(url: baseURL, mode: .readOnly))
217-
try validateBase(baseImage, at: baseURL, expectedFormat: baseFormat)
219+
try Self.validateBase(baseImage, at: baseURL, expectedFormat: baseFormat)
218220

219221
var image = baseImage
220222

@@ -239,7 +241,7 @@ struct DiskImageStack {
239241
}
240242

241243
@available(macOS 27.0, *)
242-
private func validateBase(
244+
private static func validateBase(
243245
_ image: DiskImage,
244246
at url: URL,
245247
expectedFormat: DiskImageFormat

Sources/tart/VMDirectory+Archive.swift

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Foundation
12
import System
23
import AppleArchive
34

@@ -10,8 +11,14 @@ fileprivate let permissions = FilePermissions(rawValue: 0o644)
1011
// [2]: https://developer.apple.com/documentation/compression/algorithm/lzfse
1112
extension VMDirectory {
1213
func exportToArchive(path: String) throws {
13-
guard !isStackedVM && !isStackedCachedImage else {
14-
throw RuntimeError.ExportFailed("exporting stacked VMs is not supported yet")
14+
let temporaryArchive = try stackedArchiveDirectoryIfNeeded()
15+
let archiveSourceURL = temporaryArchive?.vmDirectory.baseURL ?? baseURL
16+
17+
defer {
18+
if let temporaryArchive {
19+
try? temporaryArchive.lock.unlock()
20+
try? temporaryArchive.vmDirectory.removeFromDisk()
21+
}
1522
}
1623

1724
guard let fileStream = ArchiveByteStream.fileStream(
@@ -53,7 +60,7 @@ extension VMDirectory {
5360
return
5461
}
5562

56-
try encodeStream.writeDirectoryContents(archiveFrom: FilePath(baseURL.path), keySet: keySet)
63+
try encodeStream.writeDirectoryContents(archiveFrom: FilePath(archiveSourceURL.path), keySet: keySet)
5764
}
5865

5966
func importFromArchive(path: String) throws {
@@ -96,5 +103,145 @@ extension VMDirectory {
96103
}
97104

98105
_ = try ArchiveStream.process(readingFrom: decodeStream, writingTo: extractStream)
106+
107+
if isStackedVM {
108+
try restoreStackedArchive()
109+
}
110+
}
111+
112+
/// Builds a self-contained staging directory for a stacked archive, if this
113+
/// directory currently resolves to a stacked VM or cached image.
114+
private func stackedArchiveDirectoryIfNeeded() throws -> (vmDirectory: VMDirectory, lock: FileLock)? {
115+
guard isStackedVM || isStackedCachedImage else {
116+
return nil
117+
}
118+
try DiskImageStack.requireSupport()
119+
120+
let contentStore = try ContentStore()
121+
let archiveVMDir = try VMDirectory.temporary()
122+
let archiveVMDirLock = try FileLock(lockURL: archiveVMDir.baseURL)
123+
try archiveVMDirLock.lock()
124+
125+
do {
126+
let stagedSource: (isStackedVM: Bool, contentDigests: [String])? = try contentStore.withPruneLock {
127+
() -> (isStackedVM: Bool, contentDigests: [String])? in
128+
// OCI tags are mutable symlinks. Resolve one digest record while tag
129+
// replacement and cached-image deletion are blocked, then copy every
130+
// source-owned file before releasing the lock.
131+
let sourceVMDir = VMDirectory(baseURL: baseURL.resolvingSymlinksInPath())
132+
guard sourceVMDir.isStackedVM || sourceVMDir.isStackedCachedImage else {
133+
return nil
134+
}
135+
136+
let sourceIsStackedVM = sourceVMDir.isStackedVM
137+
let sourceVMLock: PIDLock?
138+
if sourceIsStackedVM {
139+
let lock = try sourceVMDir.lock()
140+
guard try lock.trylock() else {
141+
throw RuntimeError.ExportFailed("VM \"\(sourceVMDir.name)\" must be stopped before export")
142+
}
143+
sourceVMLock = lock
144+
145+
// Holding the PID lock proves that the VM is not running. A saved
146+
// state file is the remaining suspended state that must reject export.
147+
guard !FileManager.default.fileExists(atPath: sourceVMDir.stateURL.path) else {
148+
try? lock.unlock()
149+
throw RuntimeError.ExportFailed("VM \"\(sourceVMDir.name)\" must be stopped before export")
150+
}
151+
} else {
152+
sourceVMLock = nil
153+
}
154+
defer { try? sourceVMLock?.unlock() }
155+
156+
try FileManager.default.copyItem(at: sourceVMDir.configURL, to: archiveVMDir.configURL)
157+
try FileManager.default.copyItem(at: sourceVMDir.nvramURL, to: archiveVMDir.nvramURL)
158+
try FileManager.default.copyItem(at: sourceVMDir.manifestURL, to: archiveVMDir.manifestURL)
159+
if sourceIsStackedVM {
160+
try FileManager.default.copyItem(at: sourceVMDir.overlayURL, to: archiveVMDir.overlayURL)
161+
}
162+
163+
return (sourceIsStackedVM, try archiveVMDir.diskContentDigests())
164+
}
165+
166+
guard let stagedSource else {
167+
try archiveVMDirLock.unlock()
168+
try archiveVMDir.removeFromDisk()
169+
return nil
170+
}
171+
172+
if !stagedSource.isStackedVM {
173+
try archiveVMDir.diskImageStack().createWritableOverlay()
174+
}
175+
176+
// The staged manifest is now an in-progress reference, so immutable
177+
// content remains protected while these potentially large copies run
178+
// without holding the global prune lock.
179+
for contentDigest in stagedSource.contentDigests {
180+
guard let sourceURL = try contentStore.existingContentURL(for: contentDigest) else {
181+
throw RuntimeError.ExportFailed("VM is missing cached disk content \(contentDigest)")
182+
}
183+
184+
let destinationURL = try contentStore.contentURL(
185+
for: contentDigest,
186+
under: archiveContentStoreURL(in: archiveVMDir)
187+
)
188+
try FileManager.default.createDirectory(
189+
at: destinationURL.deletingLastPathComponent(),
190+
withIntermediateDirectories: true
191+
)
192+
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
193+
}
194+
195+
return (archiveVMDir, archiveVMDirLock)
196+
} catch {
197+
try? archiveVMDirLock.unlock()
198+
try? archiveVMDir.removeFromDisk()
199+
throw error
200+
}
201+
}
202+
203+
/// Restores immutable files from an archive into the shared content store,
204+
/// removes the archive-only payload, then validates the resulting stack.
205+
private func restoreStackedArchive() throws {
206+
try DiskImageStack.requireSupport()
207+
208+
let contentStore = try ContentStore()
209+
// The extracted manifest is already a reference; synchronize publication
210+
// with a concurrent prune before installing its immutable content.
211+
try contentStore.synchronizePublishedReferences()
212+
for contentDigest in try diskContentDigests() {
213+
if try contentStore.existingContentURL(for: contentDigest) != nil {
214+
continue
215+
}
216+
217+
let archivedContentURL = try contentStore.contentURL(
218+
for: contentDigest,
219+
under: archiveContentStoreURL(in: self)
220+
)
221+
guard FileManager.default.fileExists(atPath: archivedContentURL.path) else {
222+
throw RuntimeError.ImportFailed("archive is missing disk content \(contentDigest)")
223+
}
224+
225+
let temporaryURL = try contentStore.temporaryContentURL(for: contentDigest)
226+
do {
227+
try FileManager.default.copyItem(at: archivedContentURL, to: temporaryURL)
228+
_ = try contentStore.install(temporaryURL, contentDigest: contentDigest)
229+
} catch {
230+
try? FileManager.default.removeItem(at: temporaryURL)
231+
throw error
232+
}
233+
}
234+
235+
try FileManager.default.removeItem(at: archiveContentStoreURL(in: self))
236+
try? FileManager.default.removeItem(at: stateURL)
237+
238+
// Opening the attachment validates the reconstructed immutable stack and
239+
// imported writable overlay before the VM enters local storage.
240+
_ = try diskImageStack().makeAttachment()
99241
}
242+
243+
private func archiveContentStoreURL(in vmDir: VMDirectory) -> URL {
244+
vmDir.baseURL.appendingPathComponent("content", isDirectory: true)
245+
}
246+
100247
}

0 commit comments

Comments
 (0)