Skip to content

Commit 50032eb

Browse files
committed
Complete stacked disk command support
1 parent e226583 commit 50032eb

4 files changed

Lines changed: 80 additions & 25 deletions

File tree

Sources/tart/Commands/Clone.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,28 @@ struct Clone: AsyncParsableCommand {
5656
guard remoteName != nil else {
5757
throw ValidationError("--stacked requires a remote image")
5858
}
59+
try DiskImageStack.requireSupport()
5960
}
6061

6162
if let remoteName, try !ociStorage.hasUsableCachedImageForClone(remoteName, requireManifest: stacked) {
6263
// Pull the VM in case it's OCI-based and doesn't exist locally yet
6364
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
65+
66+
// Fail before pulling disk content when this host cannot create a writable stacked disk.
67+
if !stacked {
68+
let (manifest, _) = try await registry.pullManifest(reference: remoteName.reference.value)
69+
if manifest.layers.contains(where: { $0.mediaType == asifOverlayMediaType }) {
70+
try DiskImageStack.requireSupport()
71+
}
72+
}
73+
6474
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
6575
}
6676

6777
let sourceVM = try VMStorageHelper.open(sourceName)
78+
if sourceVM.isStackedVM || sourceVM.isStackedCachedImage {
79+
try DiskImageStack.requireSupport()
80+
}
6881
let tmpVMDir = try VMDirectory.temporary()
6982

7083
// Lock the temporary VM directory to prevent it's garbage collection
@@ -126,7 +139,7 @@ struct Clone: AsyncParsableCommand {
126139
}
127140
}
128141
}, onCancel: {
129-
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
142+
try? tmpVMDir.removeFromDisk()
130143
})
131144
}
132145
}

Sources/tart/VMDirectory+OCI.swift

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ extension VMDirectory {
114114

115115
/// Reconstructs one complete immutable base disk or published ASIF overlay
116116
/// from its Tart disk chunks, unless the shared content store already has a
117-
/// verified copy.
117+
/// size-matching copy.
118118
private func pullDiskFile(
119119
registry: Registry,
120120
group: TartDiskFileGroup,
@@ -133,7 +133,10 @@ extension VMDirectory {
133133
try lock.lock()
134134
defer { try? lock.unlock() }
135135

136-
if let existingURL = try contentStore.existingContentURL(for: contentDigest) {
136+
if let existingURL = try contentStore.contentURLIfPresent(for: contentDigest),
137+
let actualSize = UInt64(exactly: try existingURL.sizeBytes()),
138+
let expectedSize = group.uncompressedSize(),
139+
actualSize == expectedSize {
137140
progress.completedUnitCount += group.chunks.reduce(0) { $0 + Int64($1.size) }
138141
return existingURL
139142
}
@@ -189,7 +192,6 @@ extension VMDirectory {
189192
var annotations = diskAnnotations
190193
annotations[uploadTimeAnnotation] = Date().toISO()
191194
manifest.annotations = annotations
192-
193195
// Manifest
194196
for reference in references {
195197
defaultLogger.appendNewLine("pushing manifest for \(reference)...")
@@ -228,6 +230,15 @@ extension VMDirectory {
228230
}
229231

230232
let localManifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL))
233+
// pushToRegistry() reads config.json before reaching this point. Closing
234+
// that read descriptor can release the caller's fcntl PID lock, so take a
235+
// fresh lock before hashing, uploading, and inspecting the writable overlay.
236+
let stackedDiskLock = try lock()
237+
guard try stackedDiskLock.trylock() else {
238+
throw RuntimeError.VMIsRunning(name)
239+
}
240+
defer { try? stackedDiskLock.unlock() }
241+
231242
let inheritedGroups: [TartDiskFileGroup]
232243
switch try localManifest.tartDiskRepresentation() {
233244
case .flat(let base) where base.contentDigest != nil:
@@ -250,26 +261,13 @@ extension VMDirectory {
250261
))
251262
}
252263

253-
// Keep the snapshot out of startup GC while this potentially long push
254-
// hashes, uploads, and inspects it.
255-
let frozenOverlayDirectory = try VMDirectory.temporary()
256-
let frozenOverlayLock = try FileLock(lockURL: frozenOverlayDirectory.baseURL)
257-
try frozenOverlayLock.lock()
258-
defer {
259-
try? frozenOverlayLock.unlock()
260-
try? FileManager.default.removeItem(at: frozenOverlayDirectory.baseURL)
261-
}
262-
263-
let frozenOverlayURL = frozenOverlayDirectory.baseURL.appendingPathComponent("overlay.asif")
264-
try FileManager.default.copyItem(at: overlayURL, to: frozenOverlayURL)
265-
266-
let overlaySize = try FileManager.default.attributesOfItem(atPath: frozenOverlayURL.path)[.size] as! Int64
264+
let overlaySize = try FileManager.default.attributesOfItem(atPath: overlayURL.path)[.size] as! Int64
267265
defaultLogger.appendNewLine("pushing overlay...")
268266
let progress = Progress(totalUnitCount: overlaySize)
269267
ProgressObserver(progress).log(defaultLogger)
270-
let contentDigest = try Digest.hash(frozenOverlayURL)
268+
let contentDigest = try Digest.hash(overlayURL)
271269
let chunks = try await DiskV2.push(
272-
diskURL: frozenOverlayURL,
270+
diskURL: overlayURL,
273271
mediaType: asifOverlayMediaType,
274272
registry: registry,
275273
chunkSizeMb: chunkSizeMb,
@@ -278,7 +276,7 @@ extension VMDirectory {
278276
)
279277
layers.append(contentsOf: annotatedChunks(chunks, kind: .asifOverlay, contentDigest: contentDigest))
280278

281-
let blockLayout = try DiskImageStack.diskImageBlockLayout(at: frozenOverlayURL)
279+
let blockLayout = try DiskImageStack.diskImageBlockLayout(at: overlayURL)
282280
let diskSize = blockLayout.blockSize.multipliedReportingOverflow(by: blockLayout.blockCount)
283281
guard !diskSize.overflow else {
284282
throw DiskImageStackError.invalidBlockLayout("stacked disk block layout overflows UInt64")
@@ -316,6 +314,8 @@ extension VMDirectory {
316314
return group.chunks
317315
}
318316

317+
// Rebuilding transport blobs republishes this file under the pinned
318+
// whole-file digest, so validate the cached bytes at this boundary.
319319
guard let contentURL = try contentStore.existingContentURL(for: contentDigest) else {
320320
throw RuntimeError.VMMissingFiles("stacked VM is missing cached disk content \(contentDigest)")
321321
}

Sources/tart/VMDirectory.swift

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,17 +236,29 @@ struct VMDirectory: Prunable {
236236
contentStore: ContentStore? = nil
237237
) throws {
238238
if isStackedVM {
239-
guard try state() == .Stopped else {
239+
// Resolve the stack before taking the config.json PID lock. Reading
240+
// config.json after acquiring an fcntl lock would release that lock
241+
// when the read file descriptor is closed.
242+
let stack = try diskImageStack(contentStore: contentStore)
243+
let lock = try lock()
244+
guard try lock.trylock() else {
245+
throw RuntimeError.VMConfigurationError("VM \"\(name)\" must be stopped before resizing its disk")
246+
}
247+
defer { try? lock.unlock() }
248+
249+
// Holding the PID lock proves that the VM is not running. A saved state
250+
// file is the remaining suspended state that must also reject resize.
251+
guard !FileManager.default.fileExists(atPath: stateURL.path) else {
240252
throw RuntimeError.VMConfigurationError("VM \"\(name)\" must be stopped before resizing its disk")
241253
}
242254

243-
let stack = try diskImageStack(contentStore: contentStore)
244255
let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000
245256
guard desiredSizeBytes.isMultiple(of: stack.blockSize) else {
246257
throw RuntimeError.InvalidDiskSize("new disk size must align to the stacked disk block size")
247258
}
248259

249-
try stack.growWritableOverlay(toBlockCount: desiredSizeBytes / stack.blockSize)
260+
let desiredBlockCount = desiredSizeBytes / stack.blockSize
261+
try stack.growWritableOverlay(toBlockCount: desiredBlockCount)
250262
return
251263
}
252264

Tests/TartTests/VMDirectoryDiskImageStackTests.swift

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,37 @@ import XCTest
7979
XCTAssertTrue(FileManager.default.fileExists(atPath: fresh.overlayURL.path))
8080
}
8181

82-
func testResizeDiskGrowsWritableOverlay() throws {
82+
func testStackedRemoteAdditionalDiskRetainsTemporaryVM() throws {
83+
try withTemporaryTartHome {
84+
let source = try flatSource()
85+
let stacked = try temporaryVMDirectory()
86+
try source.cloneAsStackedBase(to: stacked, generateMAC: false)
87+
88+
let storage = try VMStorageOCI()
89+
let name = try RemoteName("example.com/org/image:latest")
90+
let cachedImage = try storage.create(name)
91+
try FileManager.default.copyItem(at: stacked.configURL, to: cachedImage.configURL)
92+
try FileManager.default.copyItem(at: stacked.nvramURL, to: cachedImage.nvramURL)
93+
try FileManager.default.copyItem(at: stacked.manifestURL, to: cachedImage.manifestURL)
94+
95+
do {
96+
let additionalDisk = try AdditionalDisk(parseFrom: name.description)
97+
let entries = try temporaryEntries()
98+
XCTAssertEqual(entries.count, 1)
99+
XCTAssertTrue(VMDirectory(baseURL: entries[0]).isStackedVM)
100+
101+
try Config().gc()
102+
XCTAssertEqual(try temporaryEntries(), entries)
103+
104+
withExtendedLifetime(additionalDisk) {}
105+
}
106+
107+
try Config().gc()
108+
XCTAssertTrue(try temporaryEntries().isEmpty)
109+
}
110+
}
111+
112+
func testResizeDiskGrowsWritableOverlayAndPreservesParentGeometry() throws {
83113
let contentStore = try temporaryContentStore()
84114
let source = try flatSource()
85115
let stacked = try temporaryVMDirectory()

0 commit comments

Comments
 (0)