Skip to content

Commit c92a920

Browse files
committed
Finish remaining features for DiskImageKit
Finish implementing remaining tart commands to support stacked disk image. Add integration test and benchmarking.
1 parent e33868e commit c92a920

29 files changed

Lines changed: 1304 additions & 103 deletions

Sources/tart/Commands/Clone.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ struct Clone: AsyncParsableCommand {
3131
@Flag(help: .hidden)
3232
var deduplicate: Bool = false
3333

34-
@Flag(help: "create a stacked disk that uses the source image as an immutable base")
34+
@Flag(help: "create a stacked disk using a remote flat macOS OCI image as an immutable base")
3535
var base: Bool = false
3636

3737
@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
@@ -56,15 +56,30 @@ struct Clone: AsyncParsableCommand {
5656
guard remoteName != nil else {
5757
throw ValidationError("--base requires an OCI source")
5858
}
59+
try DiskImageStack.requireSupport()
5960
}
6061

6162
if let remoteName, try !ociStorage.hasUsableCachedRecordForClone(remoteName, requireManifest: base) {
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+
// A stacked clone cannot run without DiskImageKit. Inspect the small
67+
// manifest first so macOS 26 does not download all parent layers only
68+
// to fail while creating the local writable overlay.
69+
if !base {
70+
let (manifest, _) = try await registry.pullManifest(reference: remoteName.reference.value)
71+
if manifest.layers.contains(where: { $0.mediaType == asifOverlayMediaType }) {
72+
try DiskImageStack.requireSupport()
73+
}
74+
}
75+
6476
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
6577
}
6678

6779
let sourceVM = try VMStorageHelper.open(sourceName)
80+
if sourceVM.isStackedLocal || sourceVM.isStackedOCIRecord {
81+
try DiskImageStack.requireSupport()
82+
}
6883
let tmpVMDir = try VMDirectory.temporary()
6984

7085
// Lock the temporary VM directory to prevent it's garbage collection

Sources/tart/Commands/Import.swift

Lines changed: 5 additions & 4 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? FileManager.default.removeItem(at: tmpVMDir.baseURL)
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.isStackedLocal || tmpVMDir.isStackedOCIRecord {
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: {

Sources/tart/Commands/Prune.swift

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -81,27 +81,36 @@ struct Prune: AsyncParsableCommand {
8181
}
8282

8383
static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
84-
let prunables: [Prunable] = try prunableStorages
85-
.flatMap { try $0.prunables() }
86-
.sorted { try $0.accessDate() > $1.accessDate() }
87-
88-
var spaceBudgetBytes = spaceBudgetBytes
89-
var prunablesToDelete: [Prunable] = []
90-
91-
for prunable in prunables {
92-
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
84+
while true {
85+
let prunables: [Prunable] = try prunableStorages
86+
.flatMap { try $0.prunables() }
87+
.sorted { try $0.accessDate() > $1.accessDate() }
88+
89+
var remainingBudgetBytes = spaceBudgetBytes
90+
var prunablesToDelete: [Prunable] = []
91+
92+
for prunable in prunables {
93+
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
94+
95+
if prunableSizeBytes <= remainingBudgetBytes {
96+
// Don't mark for deletion as
97+
// there's a budget available
98+
remainingBudgetBytes -= prunableSizeBytes
99+
} else {
100+
// Mark for deletion
101+
prunablesToDelete.append(prunable)
102+
}
103+
}
93104

94-
if prunableSizeBytes <= spaceBudgetBytes {
95-
// Don't mark for deletion as
96-
// there's a budget available
97-
spaceBudgetBytes -= prunableSizeBytes
98-
} else {
99-
// Mark for deletion
100-
prunablesToDelete.append(prunable)
105+
guard !prunablesToDelete.isEmpty else {
106+
return
101107
}
102-
}
103108

104-
try prunablesToDelete.forEach { try $0.delete() }
109+
try prunablesToDelete.forEach { try $0.delete() }
110+
// Deleting one stacked OCI record can change which remaining record
111+
// owns shared immutable content. Rebuild the candidates until the
112+
// retained set actually fits the requested budget.
113+
}
105114
}
106115

107116
static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {

Sources/tart/Commands/Run.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1018,7 +1018,9 @@ struct AdditionalDisk {
10181018
let temporaryVMDir = try VMDirectory.temporary()
10191019
try FileManager.default.copyItem(at: vmDir.configURL, to: temporaryVMDir.configURL)
10201020
try FileManager.default.copyItem(at: vmDir.nvramURL, to: temporaryVMDir.nvramURL)
1021-
try FileManager.default.copyItem(at: vmDir.manifestURL, to: temporaryVMDir.manifestURL)
1021+
try ContentStore().withPruneLock {
1022+
try FileManager.default.copyItem(at: vmDir.manifestURL, to: temporaryVMDir.manifestURL)
1023+
}
10221024
let stack = try temporaryVMDir.diskImageStack()
10231025
try stack.createWritableOverlay()
10241026
let attachment = try stack.makeAttachment(

Sources/tart/Commands/Set.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ struct Set: AsyncParsableCommand {
4141
let vmDir = try VMStorageLocal().open(name)
4242
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
4343

44+
if disk != nil && vmDir.isStackedLocal {
45+
throw ValidationError("--disk is not supported for images with a read-only base")
46+
}
47+
4448
if let cpu = cpu {
4549
try vmConfig.setCPU(cpuCount: Int(cpu))
4650
}

Sources/tart/ContentStore.swift

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,29 @@ struct ContentStore {
1616
private static let digestPrefix = "sha256:"
1717

1818
let baseURL: URL
19+
private let pruneLockURL: URL
1920

2021
init() throws {
2122
try self.init(baseURL: Config().tartCacheDir.appendingPathComponent("content", isDirectory: true))
2223
}
2324

2425
init(baseURL: URL) throws {
2526
self.baseURL = baseURL
27+
pruneLockURL = baseURL.appendingPathComponent(".gc.lock")
2628
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
29+
if !FileManager.default.fileExists(atPath: pruneLockURL.path) {
30+
_ = FileManager.default.createFile(atPath: pruneLockURL.path, contents: Data())
31+
}
32+
}
33+
34+
/// Serializes reference publication with the final reference check and
35+
/// deletion of immutable cache entries across Tart processes.
36+
func withPruneLock<T>(_ body: () throws -> T) throws -> T {
37+
let lock = try FileLock(lockURL: pruneLockURL)
38+
try lock.lock()
39+
defer { try? lock.unlock() }
40+
41+
return try body()
2742
}
2843

2944
func contentURL(for contentDigest: String) throws -> URL {
@@ -65,16 +80,18 @@ struct ContentStore {
6580
return lockURL
6681
}
6782

68-
/// Returns a digest-addressed entry without rereading it. Pull verifies
69-
/// content hashes before accepting a cache hit; clone only needs a cheap
70-
/// structural check, like Tart's existing disk.img path.
83+
/// Returns an immutable digest-addressed entry without rereading it. Files
84+
/// are verified when installed and when deciding whether a pull is a cache
85+
/// hit; normal clone/run/push paths trust the store like Tart's disk.img.
7186
func contentURLIfPresent(for contentDigest: String) throws -> URL? {
7287
let url = try contentURL(for: contentDigest)
7388

7489
guard FileManager.default.fileExists(atPath: url.path) else {
7590
return nil
7691
}
7792

93+
try url.updateAccessDate()
94+
7895
return url
7996
}
8097

@@ -92,6 +109,66 @@ struct ContentStore {
92109
return url
93110
}
94111

112+
/// Returns immutable content files that no retained OCI record or local VM
113+
/// references. Callers may prune these like other cache entries.
114+
func prunables(excluding referencedContentDigests: Swift.Set<String>) throws -> [URL] {
115+
let sha256URL = baseURL.appendingPathComponent("sha256", isDirectory: true)
116+
guard let enumerator = FileManager.default.enumerator(
117+
at: sha256URL,
118+
includingPropertiesForKeys: [.isRegularFileKey],
119+
options: [.skipsSubdirectoryDescendants]
120+
) else {
121+
return []
122+
}
123+
124+
return try enumerator.compactMap { element in
125+
guard let url = element as? URL,
126+
try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else {
127+
return nil
128+
}
129+
130+
let contentDigest = "sha256:\(url.lastPathComponent)"
131+
guard (try? validatedDigestHex(contentDigest)) != nil,
132+
!referencedContentDigests.contains(contentDigest) else {
133+
return nil
134+
}
135+
136+
return url
137+
}
138+
}
139+
140+
/// Reconstructs one immutable disk file from transport chunks unless a
141+
/// validated copy is already present under its whole-file digest.
142+
func pullContent(
143+
registry: Registry,
144+
chunks: [OCIManifestLayer],
145+
contentDigest: String,
146+
concurrency: UInt,
147+
progress: Progress
148+
) async throws -> URL {
149+
if let existingURL = try existingContentURL(for: contentDigest) {
150+
progress.completedUnitCount += chunks.reduce(0) { $0 + Int64($1.size) }
151+
return existingURL
152+
}
153+
154+
let temporaryURL = try temporaryContentURL(for: contentDigest)
155+
156+
do {
157+
try await DiskV2.pull(
158+
registry: registry,
159+
diskLayers: chunks,
160+
diskURL: temporaryURL,
161+
concurrency: concurrency,
162+
progress: progress
163+
)
164+
165+
return try install(temporaryURL, contentDigest: contentDigest)
166+
} catch {
167+
try? FileManager.default.removeItem(at: temporaryURL)
168+
throw error
169+
}
170+
}
171+
95172
/// Move a fully reconstructed temporary file into the cache after verifying
96173
/// its semantic identity. The caller should create the temporary file with
97174
/// temporaryContentURL(for:) or partialContentURL(for:) so rename stays on

Sources/tart/DiskImageStack.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,22 @@ struct DiskImageStack: DiskAttachmentSource {
4848
let blockSize: UInt64
4949
let blockCount: UInt64
5050

51+
static var isSupported: Bool {
52+
#if canImport(DiskImageKit)
53+
if #available(macOS 27.0, *) {
54+
return true
55+
}
56+
#endif
57+
58+
return false
59+
}
60+
61+
static func requireSupport() throws {
62+
guard isSupported else {
63+
throw DiskImageStackError.unavailable
64+
}
65+
}
66+
5167
/// Reads a disk image's current geometry without resolving or validating a
5268
/// whole stack. This is used for the VM's private writable overlay, whose
5369
/// size may be newer than the pinned immutable parent manifest.

Sources/tart/OCI/Digest.swift

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ enum DigestError: Error {
77
}
88

99
class Digest {
10+
private static let fileBufferSize = 4 * 1024 * 1024
11+
1012
var hash: SHA256 = SHA256()
1113

1214
func update(_ data: Data) {
@@ -22,7 +24,23 @@ class Digest {
2224
}
2325

2426
static func hash(_ url: URL) throws -> String {
25-
hash(try Data(contentsOf: url))
27+
let file = try FileHandle(forReadingFrom: url)
28+
defer { try? file.close() }
29+
30+
let digest = Digest()
31+
while try autoreleasepool(invoking: {
32+
guard let data = try file.read(upToCount: fileBufferSize), !data.isEmpty else {
33+
return false
34+
}
35+
36+
digest.update(data)
37+
return true
38+
}) {
39+
// Keep the pool scoped to one read so large disk hashing does not
40+
// retain Foundation's temporary buffers until the command exits.
41+
}
42+
43+
return digest.finalize()
2644
}
2745

2846
static func hash(_ url: URL, offset: UInt64, size: UInt64) throws -> String {
@@ -36,20 +54,31 @@ class Digest {
3654
throw DigestError.InvalidOffset
3755
}
3856

39-
if (offset + size) > fileSize {
57+
if size > fileSize - offset {
4058
throw DigestError.InvalidSize
4159
}
4260

43-
// Read a chunk of size ``size`` at offset ``offset``
44-
// and calculate it's digest
61+
// Read the requested range incrementally and calculate its digest.
4562
let fh = try FileHandle(forReadingFrom: url)
46-
defer { try! fh.close() }
63+
defer { try? fh.close() }
4764

4865
try fh.seek(toOffset: offset)
4966

50-
let data = try fh.read(upToCount: Int(size))!
67+
let digest = Digest()
68+
var remaining = size
69+
while remaining > 0 {
70+
try autoreleasepool {
71+
let count = Int(min(UInt64(fileBufferSize), remaining))
72+
guard let data = try fh.read(upToCount: count), !data.isEmpty else {
73+
throw DigestError.InvalidSize
74+
}
75+
76+
digest.update(data)
77+
remaining -= UInt64(data.count)
78+
}
79+
}
5180

52-
return hash(data)
81+
return digest.finalize()
5382
}
5483
}
5584

Sources/tart/OCI/Manifest.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-cont
2525
let diskFileContentDigestAnnotation = "org.cirruslabs.tart.disk-file-content-digest"
2626
let diskFileChunkCountAnnotation = "org.cirruslabs.tart.disk-file-chunk-count"
2727

28+
// Keep stacked manifests comfortably within the limit accepted by common OCI
29+
// registries. Flat manifests predate this feature and keep their behavior.
30+
let maxStackedManifestSizeBytes = 4 * 1024 * 1024
31+
2832
/// The OCI-layer descriptors whose Tart disk chunks reconstruct one complete
2933
/// base disk or ASIF overlay.
3034
struct TartDiskFileGroup: Equatable {
@@ -186,6 +190,22 @@ struct OCIManifest: Codable, Equatable {
186190
func diskBlockCount() -> UInt64? {
187191
annotations?[diskBlockCountAnnotation].flatMap(UInt64.init)
188192
}
193+
194+
/// Reject an impractically deep stack before submitting its manifest to a
195+
/// registry. The limit applies only to the new representation so existing
196+
/// flat images remain backwards-compatible.
197+
func validateStackedSizeForPush(maxBytes: Int = maxStackedManifestSizeBytes) throws {
198+
guard case .stacked = try tartDiskRepresentation() else {
199+
return
200+
}
201+
202+
let size = try toJSON().count
203+
guard size <= maxBytes else {
204+
throw OCIManifestValidationError.invalidLayout(
205+
"stacked manifest is \(size) bytes, exceeding the \(maxBytes)-byte limit"
206+
)
207+
}
208+
}
189209
}
190210

191211
struct OCIConfig: Codable {

0 commit comments

Comments
 (0)