Skip to content

Commit be44cc8

Browse files
committed
Add OCI transport and base clone with DiskImageKit
1 parent f87b57b commit be44cc8

26 files changed

Lines changed: 1869 additions & 206 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 base: 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 base {
56+
guard remoteName != nil else {
57+
throw ValidationError("--base requires a remote image")
58+
}
59+
}
60+
61+
if let remoteName, try !ociStorage.hasUsableCachedImageForClone(remoteName, requireManifest: base) {
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 base {
84+
guard sourceVM.isStandalone else {
85+
throw ValidationError("--base cannot use an image that already has a stacked disk")
86+
}
87+
guard try VMConfig(fromURL: sourceVM.configURL).os == .darwin else {
88+
throw ValidationError("--base 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: 47 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,27 @@ 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+
(configuration, temporaryDiskLock) = try Self.craft(
968+
diskPath,
969+
readOnly: readOnly,
970+
syncModeRaw: syncModeRaw,
971+
cachingModeRaw: cachingModeRaw
972+
)
960973
}
961974

962-
static func craft(_ diskPath: String, readOnly diskReadOnly: Bool, syncModeRaw: String, cachingModeRaw: String) throws -> VZStorageDeviceConfiguration {
975+
private static func craft(
976+
_ diskPath: String,
977+
readOnly diskReadOnly: Bool,
978+
syncModeRaw: String,
979+
cachingModeRaw: String
980+
) throws -> (VZStorageDeviceConfiguration, FileLock?) {
963981
let diskURL = URL(string: diskPath)
964982

965983
if (["nbd", "nbds", "nbd+unix", "nbds+unix"].contains(diskURL?.scheme)) {
@@ -974,7 +992,7 @@ struct AdditionalDisk {
974992
synchronizationMode: try VZDiskSynchronizationMode(syncModeRaw)
975993
)
976994

977-
return VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment)
995+
return (VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment), nil)
978996
}
979997

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

1008-
return VZVirtioBlockDeviceConfiguration(attachment: blockAttachment)
1026+
return (VZVirtioBlockDeviceConfiguration(attachment: blockAttachment), nil)
10091027
}
10101028

10111029
// Support remote VM names in --disk command-line argument
10121030
if let remoteName = try? RemoteName(diskPath) {
10131031
let vmDir = try VMStorageOCI().open(remoteName)
10141032

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

10251063
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(url: clonedDiskURL, readOnly: diskReadOnly)
10261064

1027-
return VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment)
1065+
return (VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment), lock)
10281066
}
10291067

10301068
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
@@ -1040,7 +1078,7 @@ struct AdditionalDisk {
10401078
synchronizationMode: try VZDiskImageSynchronizationMode(syncModeRaw)
10411079
)
10421080

1043-
return VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment)
1081+
return (VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment), nil)
10441082
}
10451083

10461084
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 {

0 commit comments

Comments
 (0)