Skip to content

Commit 4ce8a11

Browse files
authored
Add OCI transport and base clone with DiskImageKit (#1304)
* Add OCI transport and base clone with DiskImageKit * Address stacked OCI pull review feedback * Stream file digest hashing * Lock frozen overlays during push
1 parent f87b57b commit 4ce8a11

27 files changed

Lines changed: 2053 additions & 212 deletions

Sources/tart/Commands/Clone.swift

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ 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")
35+
var stacked: Bool = false
36+
3437
@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
3538
var pruneLimit: UInt = 100
3639

@@ -47,8 +50,15 @@ struct Clone: AsyncParsableCommand {
4750
func run() async throws {
4851
let ociStorage = try VMStorageOCI()
4952
let localStorage = try VMStorageLocal()
53+
let remoteName = try? RemoteName(sourceName)
5054

51-
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
55+
if stacked {
56+
guard remoteName != nil else {
57+
throw ValidationError("--stacked requires a remote image")
58+
}
59+
}
60+
61+
if let remoteName, try !ociStorage.hasUsableCachedImageForClone(remoteName, requireManifest: stacked) {
5262
// Pull the VM in case it's OCI-based and doesn't exist locally yet
5363
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
5464
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
@@ -66,9 +76,28 @@ struct Clone: AsyncParsableCommand {
6676
let lock = try FileLock(lockURL: Config().tartHomeDir)
6777
try lock.lock()
6878

79+
let sourceState = try sourceVM.state()
6980
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
70-
&& sourceVM.state() != .Suspended
71-
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
81+
&& sourceState != .Suspended
82+
83+
if stacked {
84+
guard sourceVM.isStandalone else {
85+
throw ValidationError("--stacked cannot use an image that already has a stacked disk")
86+
}
87+
guard try VMConfig(fromURL: sourceVM.configURL).os == .darwin else {
88+
throw ValidationError("--stacked currently supports only macOS images")
89+
}
90+
try sourceVM.cloneAsStackedBase(to: tmpVMDir, generateMAC: generateMAC)
91+
} else if sourceVM.isStackedCachedImage {
92+
try sourceVM.cloneStacked(to: tmpVMDir, copyWritableOverlay: false, generateMAC: generateMAC)
93+
} else if sourceVM.isStackedVM {
94+
guard sourceState == .Stopped else {
95+
throw RuntimeError.VMConfigurationError("VM \"\(sourceName)\" must be stopped before cloning")
96+
}
97+
try sourceVM.cloneStacked(to: tmpVMDir, copyWritableOverlay: true, generateMAC: generateMAC)
98+
} else {
99+
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
100+
}
72101

73102
try localStorage.move(newName, from: tmpVMDir)
74103

@@ -78,11 +107,23 @@ struct Clone: AsyncParsableCommand {
78107
// is not actually claiming new space until the VM is started and it writes something to disk.
79108
//
80109
// So, once we clone the VM let's try to claim the rest of space for the VM to run without errors.
81-
let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
82-
// Avoid reclaiming an excessive amount of disk space.
83-
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
84-
if reclaimBytes > 0 {
85-
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM)
110+
if sourceVM.isStandalone {
111+
let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
112+
// Avoid reclaiming an excessive amount of disk space.
113+
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
114+
if reclaimBytes > 0 {
115+
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM)
116+
}
117+
} else if sourceVM.isStackedVM || sourceVM.isStackedCachedImage {
118+
let clonedVM = try localStorage.open(newName)
119+
// A stacked clone owns only its writable overlay locally, but that
120+
// overlay may grow to the full guest-visible disk block layout at
121+
// runtime. Reclaim against the clone so it is not pruned itself.
122+
let unallocatedBytes = try clonedVM.diskSizeBytes() - clonedVM.allocatedSizeBytes()
123+
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
124+
if reclaimBytes > 0 {
125+
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), clonedVM)
126+
}
86127
}
87128
}, onCancel: {
88129
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)

Sources/tart/Commands/Get.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ struct Get: AsyncParsableCommand {
3131
OS: vmConfig.os,
3232
CPU: vmConfig.cpuCount,
3333
Memory: memorySizeInMb,
34-
Disk: HumanReadableByteCount(try vmDir.sizeBytes()) { $0 / 1000 / 1000 / 1000 },
34+
Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
3535
DiskFormat: vmConfig.diskFormat.rawValue,
3636
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) {
3737
String(format: "%.3f", Float($0) / 1000 / 1000 / 1000)
@@ -40,7 +40,6 @@ struct Get: AsyncParsableCommand {
4040
Running: try vmDir.running(),
4141
State: try vmDir.state().rawValue
4242
)
43-
4443
print(format.renderSingle(info))
4544
}
4645
}

Sources/tart/Commands/Import.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ struct Import: AsyncParsableCommand {
3131
print("importing...")
3232
try tmpVMDir.importFromArchive(path: path)
3333

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")
37+
}
38+
3439
try await withTaskCancellationHandler(operation: {
3540
// Acquire a global lock
3641
let lock = try FileLock(lockURL: Config().tartHomeDir)

Sources/tart/Commands/List.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ struct List: AsyncParsableCommand {
4242
try VMInfo(
4343
Source: "local",
4444
Name: name,
45-
Disk: HumanReadableByteCount(try vmDir.sizeBytes()) { $0 / 1000 / 1000 / 1000 },
45+
Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
4646
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
4747
Accessed: formatAccessDate(try vmDir.accessDate()),
4848
Running: vmDir.running(),
@@ -56,7 +56,7 @@ struct List: AsyncParsableCommand {
5656
try VMInfo(
5757
Source: "OCI",
5858
Name: name,
59-
Disk: HumanReadableByteCount(try vmDir.sizeBytes()) { $0 / 1000 / 1000 / 1000 },
59+
Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
6060
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
6161
Accessed: formatAccessDate(try vmDir.accessDate()),
6262
Running: vmDir.running(),

Sources/tart/Commands/Push.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ struct Push: AsyncParsableCommand {
6969
let references = remoteNamesForRegistry.map{ $0.reference.value }
7070

7171
let pushedRemoteName: RemoteName
72-
// If we're pushing a local OCI VM, check if points to an already existing registry manifest
72+
// If we're pushing a cached remote image, check if it points to an existing registry manifest
7373
// and if so, only upload manifests (without config, disk and NVRAM) to the user-specified references
7474
if let remoteName = try? RemoteName(localName) {
7575
pushedRemoteName = try await lightweightPushToRegistry(
@@ -78,17 +78,18 @@ struct Push: AsyncParsableCommand {
7878
references: references
7979
)
8080
} else {
81-
pushedRemoteName = try await localVMDir.pushToRegistry(
81+
let pushedImage = try await localVMDir.pushToRegistry(
8282
registry: registry,
8383
references: references,
8484
chunkSizeMb: chunkSize,
8585
concurrency: concurrency,
8686
labels: parseLabels()
8787
)
88+
pushedRemoteName = pushedImage.name
89+
8890
// Populate the local cache (if requested)
8991
if populateCache {
90-
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
91-
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
92+
try ociStorage.populate(pushedImage.name, from: localVMDir, manifest: pushedImage.manifest)
9293
}
9394
}
9495

@@ -102,7 +103,7 @@ struct Push: AsyncParsableCommand {
102103
}
103104

104105
func lightweightPushToRegistry(registry: Registry, remoteName: RemoteName, references: [String]) async throws -> RemoteName {
105-
// Is the local OCI VM already present in the registry?
106+
// Is the cached remote image already present in the registry?
106107
let digest = try VMStorageOCI().digest(remoteName)
107108

108109
let (remoteManifest, _) = try await registry.pullManifest(reference: digest)

Sources/tart/Commands/Run.swift

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -455,10 +455,15 @@ struct Run: AsyncParsableCommand {
455455
let provisioning = try provisioningOpts.map { try GuestProvisioningOptions($0) }
456456
#endif
457457

458+
// Keep these values alive while the VM runs. Some additional disks own a
459+
// lock that protects their temporary backing files from Config.gc().
460+
let additionalDisks = try additionalDisks()
461+
defer { withExtendedLifetime(additionalDisks) {} }
462+
458463
vm = try VM(
459464
vmDir: vmDir,
460465
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
461-
additionalStorageDevices: try additionalDiskAttachments(),
466+
additionalStorageDevices: additionalDisks.map(\.configuration),
462467
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
463468
serialPorts: serialPorts,
464469
suspendable: suspendable,
@@ -727,9 +732,9 @@ struct Run: AsyncParsableCommand {
727732
}
728733
}
729734

730-
func additionalDiskAttachments() throws -> [VZStorageDeviceConfiguration] {
735+
func additionalDisks() throws -> [AdditionalDisk] {
731736
try disk.map {
732-
try AdditionalDisk(parseFrom: $0).configuration
737+
try AdditionalDisk(parseFrom: $0)
733738
}
734739
}
735740

@@ -952,14 +957,32 @@ struct VMView: NSViewRepresentable {
952957

953958
struct AdditionalDisk {
954959
let configuration: VZStorageDeviceConfiguration
960+
// Retained for as long as the additional disk is attached, so Config.gc()
961+
// cannot remove a temporary backing file or stacked-disk directory.
962+
private let temporaryDiskLock: FileLock?
955963

956964
init(parseFrom: String) throws {
957965
let (diskPath, readOnly, syncModeRaw, cachingModeRaw) = Self.parseOptions(parseFrom)
958966

959-
self.configuration = try Self.craft(diskPath, readOnly: readOnly, syncModeRaw: syncModeRaw, cachingModeRaw: cachingModeRaw)
967+
self = try Self.craft(
968+
diskPath,
969+
readOnly: readOnly,
970+
syncModeRaw: syncModeRaw,
971+
cachingModeRaw: cachingModeRaw
972+
)
973+
}
974+
975+
private init(configuration: VZStorageDeviceConfiguration, temporaryDiskLock: FileLock? = nil) {
976+
self.configuration = configuration
977+
self.temporaryDiskLock = temporaryDiskLock
960978
}
961979

962-
static func craft(_ diskPath: String, readOnly diskReadOnly: Bool, syncModeRaw: String, cachingModeRaw: String) throws -> VZStorageDeviceConfiguration {
980+
private static func craft(
981+
_ diskPath: String,
982+
readOnly diskReadOnly: Bool,
983+
syncModeRaw: String,
984+
cachingModeRaw: String
985+
) throws -> AdditionalDisk {
963986
let diskURL = URL(string: diskPath)
964987

965988
if (["nbd", "nbds", "nbd+unix", "nbds+unix"].contains(diskURL?.scheme)) {
@@ -974,7 +997,7 @@ struct AdditionalDisk {
974997
synchronizationMode: try VZDiskSynchronizationMode(syncModeRaw)
975998
)
976999

977-
return VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment)
1000+
return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment))
9781001
}
9791002

9801003
// Expand the tilde (~) since at this point we're dealing with a local path,
@@ -1005,13 +1028,33 @@ struct AdditionalDisk {
10051028
let blockAttachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: FileHandle(fileDescriptor: fd, closeOnDealloc: true),
10061029
readOnly: diskReadOnly, synchronizationMode: try VZDiskSynchronizationMode(syncModeRaw))
10071030

1008-
return VZVirtioBlockDeviceConfiguration(attachment: blockAttachment)
1031+
return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: blockAttachment))
10091032
}
10101033

10111034
// Support remote VM names in --disk command-line argument
10121035
if let remoteName = try? RemoteName(diskPath) {
10131036
let vmDir = try VMStorageOCI().open(remoteName)
10141037

1038+
if vmDir.isStackedCachedImage {
1039+
// A cached stacked image has no writable top overlay. Create one in a
1040+
// disposable directory for this additional-disk attachment.
1041+
let temporaryVMDir = try VMDirectory.temporary()
1042+
try FileManager.default.copyItem(at: vmDir.configURL, to: temporaryVMDir.configURL)
1043+
try FileManager.default.copyItem(at: vmDir.nvramURL, to: temporaryVMDir.nvramURL)
1044+
try FileManager.default.copyItem(at: vmDir.manifestURL, to: temporaryVMDir.manifestURL)
1045+
let lock = try FileLock(lockURL: temporaryVMDir.baseURL)
1046+
try lock.lock()
1047+
let stack = try temporaryVMDir.diskImageStack()
1048+
try stack.createWritableOverlay()
1049+
let attachment = try stack.makeAttachment(
1050+
readOnly: diskReadOnly,
1051+
cachingMode: try VZDiskImageCachingMode(cachingModeRaw) ?? .automatic,
1052+
synchronizationMode: try VZDiskImageSynchronizationMode(syncModeRaw)
1053+
)
1054+
1055+
return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: attachment), temporaryDiskLock: lock)
1056+
}
1057+
10151058
// Unfortunately, VZDiskImageStorageDeviceAttachment does not support
10161059
// FileHandle, so we can't easily clone the disk, open it and unlink(2)
10171060
// to simplify the garbage collection, so use an intermediate directory.
@@ -1024,7 +1067,7 @@ struct AdditionalDisk {
10241067

10251068
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(url: clonedDiskURL, readOnly: diskReadOnly)
10261069

1027-
return VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment)
1070+
return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment), temporaryDiskLock: lock)
10281071
}
10291072

10301073
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
@@ -1040,7 +1083,7 @@ struct AdditionalDisk {
10401083
synchronizationMode: try VZDiskImageSynchronizationMode(syncModeRaw)
10411084
)
10421085

1043-
return VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment)
1086+
return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment))
10441087
}
10451088

10461089
static func parseOptions(_ parseFrom: String) -> (String, Bool, String, String) {

Sources/tart/Commands/Set.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ struct Set: AsyncParsableCommand {
3939

4040
func run() async throws {
4141
let vmDir = try VMStorageLocal().open(name)
42+
43+
// Replacing disk.img would leave a stacked VM with both disk.img and
44+
// overlay.asif, which is not a supported local layout. Reject before
45+
// saving any other requested configuration changes.
46+
if disk != nil, vmDir.isStackedVM {
47+
throw ValidationError("--disk is not supported for VMs with a stacked disk")
48+
}
49+
4250
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
4351

4452
if let cpu = cpu {

Sources/tart/ContentStore.swift

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,48 @@ struct ContentStore {
3939
return targetURL.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).tmp")
4040
}
4141

42-
/// Returns a validated cache hit. Corrupt files are treated as misses so a
43-
/// later pull can safely rebuild them.
44-
func existingContentURL(for contentDigest: String) throws -> URL? {
42+
/// Returns a stable staging path so an interrupted registry pull can resume
43+
/// reconstructing this content entry on a later attempt.
44+
func resumableContentURL(for contentDigest: String) throws -> URL {
45+
let targetURL = try contentURL(for: contentDigest)
46+
47+
return targetURL.deletingLastPathComponent().appendingPathComponent(".\(targetURL.lastPathComponent).partial")
48+
}
49+
50+
/// Returns a stable lock file for serializing reconstruction of one content
51+
/// entry. The file is intentionally retained; flock state lives on the file
52+
/// descriptor and disappears when the owning process exits.
53+
func lockURL(for contentDigest: String) throws -> URL {
54+
let targetURL = try contentURL(for: contentDigest)
55+
56+
let lockURL = targetURL.deletingLastPathComponent().appendingPathComponent(".\(targetURL.lastPathComponent).lock")
57+
if !FileManager.default.fileExists(atPath: lockURL.path) {
58+
_ = FileManager.default.createFile(atPath: lockURL.path, contents: nil)
59+
}
60+
61+
return lockURL
62+
}
63+
64+
/// Returns a digest-addressed entry without rereading it. Pull verifies
65+
/// content hashes before accepting a cache hit; clone only needs a cheap
66+
/// structural check, like Tart's existing disk.img path.
67+
func contentURLIfPresent(for contentDigest: String) throws -> URL? {
4568
let url = try contentURL(for: contentDigest)
4669

4770
guard FileManager.default.fileExists(atPath: url.path) else {
4871
return nil
4972
}
5073

74+
return url
75+
}
76+
77+
/// Returns a validated cache hit. Corrupt files are treated as misses so a
78+
/// later pull can safely rebuild them.
79+
func existingContentURL(for contentDigest: String) throws -> URL? {
80+
guard let url = try contentURLIfPresent(for: contentDigest) else {
81+
return nil
82+
}
83+
5184
guard try Digest.hash(url) == contentDigest else {
5285
return nil
5386
}
@@ -57,22 +90,29 @@ struct ContentStore {
5790

5891
/// Move a fully reconstructed temporary file into the cache after verifying
5992
/// its semantic identity. The caller should create the temporary file with
60-
/// `temporaryContentURL(for:)` so rename stays on the same filesystem.
93+
/// temporaryContentURL(for:) or resumableContentURL(for:) so rename stays on
94+
/// the same filesystem.
6195
func install(_ temporaryURL: URL, contentDigest: String) throws -> URL {
6296
let actualDigest = try Digest.hash(temporaryURL)
6397
guard actualDigest == contentDigest else {
6498
throw ContentStoreError.contentDigestMismatch(expected: contentDigest, actual: actualDigest)
6599
}
66100

67101
let targetURL = try contentURL(for: contentDigest)
102+
let lock = try FileLock(lockURL: baseURL)
103+
try lock.lock()
104+
defer { try? lock.unlock() }
68105

69106
if let existingURL = try existingContentURL(for: contentDigest) {
70107
try? FileManager.default.removeItem(at: temporaryURL)
71108
return existingURL
72109
}
73110

74-
try? FileManager.default.removeItem(at: targetURL)
75-
try FileManager.default.moveItem(at: temporaryURL, to: targetURL)
111+
if FileManager.default.fileExists(atPath: targetURL.path) {
112+
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: temporaryURL)
113+
} else {
114+
try FileManager.default.moveItem(at: temporaryURL, to: targetURL)
115+
}
76116

77117
return targetURL
78118
}

0 commit comments

Comments
 (0)