diff --git a/Sources/tart/Commands/Clone.swift b/Sources/tart/Commands/Clone.swift index b6497e2f..3bc3408b 100644 --- a/Sources/tart/Commands/Clone.swift +++ b/Sources/tart/Commands/Clone.swift @@ -31,6 +31,9 @@ struct Clone: AsyncParsableCommand { @Flag(help: .hidden) var deduplicate: Bool = false + @Flag(help: "create a stacked disk that uses the source image as an immutable base") + var base: Bool = false + @Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n")) var pruneLimit: UInt = 100 @@ -47,8 +50,15 @@ struct Clone: AsyncParsableCommand { func run() async throws { let ociStorage = try VMStorageOCI() let localStorage = try VMStorageLocal() + let remoteName = try? RemoteName(sourceName) - if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) { + if base { + guard remoteName != nil else { + throw ValidationError("--base requires an OCI source") + } + } + + if let remoteName, try !ociStorage.hasUsableCachedRecordForClone(remoteName, requireManifest: base) { // Pull the VM in case it's OCI-based and doesn't exist locally yet let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure) try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate) @@ -66,9 +76,28 @@ struct Clone: AsyncParsableCommand { let lock = try FileLock(lockURL: Config().tartHomeDir) try lock.lock() + let sourceState = try sourceVM.state() let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress()) - && sourceVM.state() != .Suspended - try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC) + && sourceState != .Suspended + + if base { + guard sourceVM.isStandalone else { + throw ValidationError("--base currently supports only flat OCI images") + } + guard try VMConfig(fromURL: sourceVM.configURL).os == .darwin else { + throw ValidationError("--base currently supports only macOS OCI images") + } + try sourceVM.cloneAsStackedBase(to: tmpVMDir, generateMAC: generateMAC) + } else if sourceVM.isStackedOCIRecord { + try sourceVM.cloneStacked(to: tmpVMDir, copyWritableOverlay: false, generateMAC: generateMAC) + } else if sourceVM.isStackedLocal { + guard sourceState == .Stopped else { + throw RuntimeError.VMConfigurationError("VM \"\(sourceName)\" must be stopped before cloning") + } + try sourceVM.cloneStacked(to: tmpVMDir, copyWritableOverlay: true, generateMAC: generateMAC) + } else { + try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC) + } try localStorage.move(newName, from: tmpVMDir) @@ -78,11 +107,23 @@ struct Clone: AsyncParsableCommand { // is not actually claiming new space until the VM is started and it writes something to disk. // // So, once we clone the VM let's try to claim the rest of space for the VM to run without errors. - let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes() - // Avoid reclaiming an excessive amount of disk space. - let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024) - if reclaimBytes > 0 { - try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM) + if sourceVM.isStandalone { + let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes() + // Avoid reclaiming an excessive amount of disk space. + let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024) + if reclaimBytes > 0 { + try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM) + } + } else if sourceVM.isStackedLocal || sourceVM.isStackedOCIRecord { + let clonedVM = try localStorage.open(newName) + // A stacked clone owns only its writable overlay locally, but that + // overlay may grow to the full guest-visible disk geometry at + // runtime. Reclaim against the clone so it is not pruned itself. + let unallocatedBytes = try clonedVM.diskSizeBytes() - clonedVM.allocatedSizeBytes() + let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024) + if reclaimBytes > 0 { + try Prune.reclaimIfNeeded(UInt64(reclaimBytes), clonedVM) + } } }, onCancel: { try? FileManager.default.removeItem(at: tmpVMDir.baseURL) diff --git a/Sources/tart/Commands/Get.swift b/Sources/tart/Commands/Get.swift index 7ffada69..6973377b 100644 --- a/Sources/tart/Commands/Get.swift +++ b/Sources/tart/Commands/Get.swift @@ -27,7 +27,7 @@ struct Get: AsyncParsableCommand { let vmConfig = try VMConfig(fromURL: vmDir.configURL) let memorySizeInMb = vmConfig.memorySize / 1024 / 1024 - let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue) + let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.diskSizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue) print(format.renderSingle(info)) } } diff --git a/Sources/tart/Commands/Import.swift b/Sources/tart/Commands/Import.swift index edb02538..cf1beb4f 100644 --- a/Sources/tart/Commands/Import.swift +++ b/Sources/tart/Commands/Import.swift @@ -31,6 +31,11 @@ struct Import: AsyncParsableCommand { print("importing...") try tmpVMDir.importFromArchive(path: path) + if tmpVMDir.isStackedLocal || tmpVMDir.isStackedOCIRecord { + try? FileManager.default.removeItem(at: tmpVMDir.baseURL) + throw RuntimeError.ImportFailed("importing stacked VMs is not supported yet") + } + try await withTaskCancellationHandler(operation: { // Acquire a global lock let lock = try FileLock(lockURL: Config().tartHomeDir) diff --git a/Sources/tart/Commands/List.swift b/Sources/tart/Commands/List.swift index 6261157f..330657ce 100644 --- a/Sources/tart/Commands/List.swift +++ b/Sources/tart/Commands/List.swift @@ -42,7 +42,7 @@ struct List: AsyncParsableCommand { try VMInfo( Source: "local", Name: name, - Disk: vmDir.sizeGB(), + Disk: vmDir.diskSizeGB(), Size: vmDir.allocatedSizeGB(), Accessed: formatAccessDate(try vmDir.accessDate()), Running: vmDir.running(), @@ -56,7 +56,7 @@ struct List: AsyncParsableCommand { try VMInfo( Source: "OCI", Name: name, - Disk: vmDir.sizeGB(), + Disk: vmDir.diskSizeGB(), Size: vmDir.allocatedSizeGB(), Accessed: formatAccessDate(try vmDir.accessDate()), Running: vmDir.running(), diff --git a/Sources/tart/Commands/Push.swift b/Sources/tart/Commands/Push.swift index ce67bdd9..760c2463 100644 --- a/Sources/tart/Commands/Push.swift +++ b/Sources/tart/Commands/Push.swift @@ -78,17 +78,18 @@ struct Push: AsyncParsableCommand { references: references ) } else { - pushedRemoteName = try await localVMDir.pushToRegistry( + let pushedImage = try await localVMDir.pushToRegistry( registry: registry, references: references, chunkSizeMb: chunkSize, concurrency: concurrency, labels: parseLabels() ) + pushedRemoteName = pushedImage.name + // Populate the local cache (if requested) if populateCache { - let expectedPushedVMDir = try ociStorage.create(pushedRemoteName) - try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false) + try ociStorage.populate(pushedImage.name, from: localVMDir, manifest: pushedImage.manifest) } } diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index ad22bf18..b3d63fb3 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -1012,6 +1012,24 @@ struct AdditionalDisk { if let remoteName = try? RemoteName(diskPath) { let vmDir = try VMStorageOCI().open(remoteName) + if vmDir.isStackedOCIRecord { + // A stacked OCI record has no writable top overlay. Create one in a + // disposable directory for this additional-disk attachment. + let temporaryVMDir = try VMDirectory.temporary() + try FileManager.default.copyItem(at: vmDir.configURL, to: temporaryVMDir.configURL) + try FileManager.default.copyItem(at: vmDir.nvramURL, to: temporaryVMDir.nvramURL) + try FileManager.default.copyItem(at: vmDir.manifestURL, to: temporaryVMDir.manifestURL) + let stack = try temporaryVMDir.diskImageStack() + try stack.createWritableOverlay() + let attachment = try stack.makeAttachment( + readOnly: diskReadOnly, + cachingMode: try VZDiskImageCachingMode(cachingModeRaw) ?? .automatic, + synchronizationMode: try VZDiskImageSynchronizationMode(syncModeRaw) + ) + + return VZVirtioBlockDeviceConfiguration(attachment: attachment) + } + // Unfortunately, VZDiskImageStorageDeviceAttachment does not support // FileHandle, so we can't easily clone the disk, open it and unlink(2) // to simplify the garbage collection, so use an intermediate directory. diff --git a/Sources/tart/ContentStore.swift b/Sources/tart/ContentStore.swift index 2eaee508..6da6f424 100644 --- a/Sources/tart/ContentStore.swift +++ b/Sources/tart/ContentStore.swift @@ -1,8 +1,10 @@ import Foundation +import System enum ContentStoreError: Error, Equatable { case invalidContentDigest(String) case contentDigestMismatch(expected: String, actual: String) + case operationFailed(String) } /// Opaque content-addressed storage for immutable reconstructed files. @@ -39,15 +41,26 @@ struct ContentStore { return targetURL.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).tmp") } - /// Returns a validated cache hit. Corrupt files are treated as misses so a - /// later pull can safely rebuild them. - func existingContentURL(for contentDigest: String) throws -> URL? { + /// Returns a digest-addressed entry without rereading it. Pull verifies + /// content hashes before accepting a cache hit; clone only needs a cheap + /// structural check, like Tart's existing disk.img path. + func contentURLIfPresent(for contentDigest: String) throws -> URL? { let url = try contentURL(for: contentDigest) guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return url + } + + /// Returns a validated cache hit. Corrupt files are treated as misses so a + /// later pull can safely rebuild them. + func existingContentURL(for contentDigest: String) throws -> URL? { + guard let url = try contentURLIfPresent(for: contentDigest) else { + return nil + } + guard try Digest.hash(url) == contentDigest else { return nil } @@ -67,15 +80,54 @@ struct ContentStore { let targetURL = try contentURL(for: contentDigest) try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(), withIntermediateDirectories: true) - if let existingURL = try existingContentURL(for: contentDigest) { - try? FileManager.default.removeItem(at: temporaryURL) - return existingURL + while true { + if try moveItemWithoutReplacing(at: temporaryURL, to: targetURL) { + return targetURL + } + + if let existingURL = try existingContentURL(for: contentDigest) { + try? FileManager.default.removeItem(at: temporaryURL) + return existingURL + } + + // The destination exists but is corrupt. Swapping keeps the digest path + // continuously populated: if another repair wins first, both sides of + // this exchange are already digest-valid and the result remains valid. + if try exchangeItem(at: temporaryURL, with: targetURL) { + try? FileManager.default.removeItem(at: temporaryURL) + return targetURL + } + } + } + + /// Atomically publishes a content entry without replacing an existing one. + /// Returns false when another installer already created the destination. + private func moveItemWithoutReplacing(at sourceURL: URL, to destinationURL: URL) throws -> Bool { + if renamex_np(sourceURL.path, destinationURL.path, UInt32(RENAME_EXCL)) == 0 { + return true + } + + if errno == EEXIST { + return false } - try? FileManager.default.removeItem(at: targetURL) - try FileManager.default.moveItem(at: temporaryURL, to: targetURL) + let details = Errno(rawValue: CInt(errno)) + throw ContentStoreError.operationFailed("failed to install content entry \(destinationURL.path): \(details)") + } + + /// Atomically exchanges a verified temporary file with a corrupt content + /// entry. Returns false when the destination disappeared before the swap. + private func exchangeItem(at sourceURL: URL, with destinationURL: URL) throws -> Bool { + if renamex_np(sourceURL.path, destinationURL.path, UInt32(RENAME_SWAP)) == 0 { + return true + } + + if errno == ENOENT { + return false + } - return targetURL + let details = Errno(rawValue: CInt(errno)) + throw ContentStoreError.operationFailed("failed to repair content entry \(destinationURL.path): \(details)") } private func validatedDigestHex(_ contentDigest: String) throws -> String { diff --git a/Sources/tart/DiskAttachmentSource.swift b/Sources/tart/DiskAttachmentSource.swift new file mode 100644 index 00000000..b3fc9512 --- /dev/null +++ b/Sources/tart/DiskAttachmentSource.swift @@ -0,0 +1,28 @@ +import Foundation +import Virtualization + +/// A disk-image-backed source that can become a Virtualization.Framework storage attachment. +protocol DiskAttachmentSource { + func makeAttachment( + readOnly: Bool, + cachingMode: VZDiskImageCachingMode, + synchronizationMode: VZDiskImageSynchronizationMode + ) throws -> VZStorageDeviceAttachment +} + +struct DiskImageAttachment: DiskAttachmentSource { + let url: URL + + func makeAttachment( + readOnly: Bool, + cachingMode: VZDiskImageCachingMode, + synchronizationMode: VZDiskImageSynchronizationMode + ) throws -> VZStorageDeviceAttachment { + try VZDiskImageStorageDeviceAttachment( + url: url, + readOnly: readOnly, + cachingMode: cachingMode, + synchronizationMode: synchronizationMode + ) + } +} diff --git a/Sources/tart/DiskImageStack.swift b/Sources/tart/DiskImageStack.swift index 318c3aac..be77c5b3 100644 --- a/Sources/tart/DiskImageStack.swift +++ b/Sources/tart/DiskImageStack.swift @@ -37,7 +37,7 @@ enum DiskImageStackError: Error, Equatable, CustomStringConvertible { } } -struct DiskImageStack { +struct DiskImageStack: DiskAttachmentSource { /// DiskImageKit-ready paths and geometry after Tart disk chunks have been /// reconstructed into complete immutable files. The writable overlay stays /// private to one VM. @@ -48,6 +48,50 @@ struct DiskImageStack { let blockSize: UInt64 let blockCount: UInt64 + /// Reads a disk image's current geometry without resolving or validating a + /// whole stack. This is used for the VM's private writable overlay, whose + /// size may be newer than the pinned immutable parent manifest. + static func diskImageGeometry(at url: URL) throws -> (blockSize: UInt64, blockCount: UInt64) { + #if canImport(DiskImageKit) + if #available(macOS 27.0, *) { + let image = try DiskImage(opening: .open(url: url, mode: .readOnly)) + return (UInt64(image.blockSize.rawValue), UInt64(image.blockCount)) + } + #endif + + throw DiskImageStackError.unavailable + } + + static func baseGeometry( + at url: URL, + expectedFormat: DiskImageFormat + ) throws -> (blockSize: UInt64, blockCount: UInt64) { + #if canImport(DiskImageKit) + if #available(macOS 27.0, *) { + let image = try DiskImage(opening: .open(url: url, mode: .readOnly)) + let matchesFormat = switch expectedFormat { + case .raw: + image.format == .raw + case .asif: + image.format == .asif + } + guard matchesFormat else { + throw DiskImageStackError.invalidDiskImage(url, "base disk format does not match") + } + guard image.layerType == nil, image.parentUUID == nil else { + throw DiskImageStackError.invalidDiskImage(url, "base disk must not be an overlay") + } + if expectedFormat == .asif && image.layerUUID == nil { + throw DiskImageStackError.invalidDiskImage(url, "ASIF base disk is missing a UUID") + } + + return (UInt64(image.blockSize.rawValue), UInt64(image.blockCount)) + } + #endif + + throw DiskImageStackError.unavailable + } + func createWritableOverlay() throws { #if canImport(DiskImageKit) if #available(macOS 27.0, *) { @@ -68,12 +112,14 @@ struct DiskImageStack { } func makeAttachment( + readOnly: Bool = false, cachingMode: VZDiskImageCachingMode = .automatic, synchronizationMode: VZDiskImageSynchronizationMode = .full - ) throws -> VZDiskImageStorageDeviceAttachment { + ) throws -> VZStorageDeviceAttachment { #if canImport(DiskImageKit) if #available(macOS 27.0, *) { return try attachmentWithDiskImageKit( + readOnly: readOnly, cachingMode: cachingMode, synchronizationMode: synchronizationMode ) @@ -108,6 +154,7 @@ struct DiskImageStack { @available(macOS 27.0, *) private func attachmentWithDiskImageKit( + readOnly: Bool, cachingMode: VZDiskImageCachingMode, synchronizationMode: VZDiskImageSynchronizationMode ) throws -> VZDiskImageStorageDeviceAttachment { @@ -121,7 +168,7 @@ struct DiskImageStack { previousUUID: parent.topUUID, previousBlockCount: parent.topBlockCount, blockSize: diskImageBlockSize(blockSize), - mode: .readWrite + mode: readOnly ? .readOnly : .readWrite ) let stackedImage = try parent.image.appending(writableOverlay.image) try validateAppendedOverlay(stackedImage, at: writableOverlayURL) diff --git a/Sources/tart/OCI/Layerizer/Disk.swift b/Sources/tart/OCI/Layerizer/Disk.swift index 051f5439..c03b852c 100644 --- a/Sources/tart/OCI/Layerizer/Disk.swift +++ b/Sources/tart/OCI/Layerizer/Disk.swift @@ -1,6 +1,6 @@ import Foundation protocol Disk { - static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] + static func push(diskURL: URL, mediaType: String, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws } diff --git a/Sources/tart/OCI/Layerizer/DiskV2.swift b/Sources/tart/OCI/Layerizer/DiskV2.swift index 2239985d..b5992409 100644 --- a/Sources/tart/OCI/Layerizer/DiskV2.swift +++ b/Sources/tart/OCI/Layerizer/DiskV2.swift @@ -22,7 +22,14 @@ class DiskV2: Disk { private static let holeGranularityBytes = 4 * 1024 * 1024 private static let zeroChunk = Data(count: holeGranularityBytes) - static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] { + static func push( + diskURL: URL, + mediaType: String, + registry: Registry, + chunkSizeMb: Int, + concurrency: UInt, + progress: Progress + ) async throws -> [OCIManifestLayer] { var pushedLayers: [(index: Int, pushedLayer: OCIManifestLayer)] = [] // Open the disk file @@ -63,7 +70,7 @@ class DiskV2: Disk { progress.completedUnitCount += Int64(data.count) return (index, OCIManifestLayer( - mediaType: diskV2MediaType, + mediaType: mediaType, size: compressedData.count, digest: compressedDataDigest, uncompressedSize: UInt64(data.count), diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index 77ef4576..49b094d5 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -64,7 +64,12 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Initialize the virtual machine and its configuration self.network = network - configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, + let disk: any DiskAttachmentSource = if vmDir.isStackedLocal { + try vmDir.diskImageStack() + } else { + DiskImageAttachment(url: vmDir.diskURL) + } + configuration = try Self.craftConfiguration(disk: disk, nvramURL: vmDir.nvramURL, vmConfig: config, network: network, additionalStorageDevices: additionalStorageDevices, directorySharingDevices: directorySharingDevices, @@ -196,7 +201,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Initialize the virtual machine and its configuration self.network = network - configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, + configuration = try Self.craftConfiguration(disk: DiskImageAttachment(url: vmDir.diskURL), + nvramURL: vmDir.nvramURL, vmConfig: config, network: network, additionalStorageDevices: additionalStorageDevices, directorySharingDevices: directorySharingDevices, @@ -312,7 +318,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { } static func craftConfiguration( - diskURL: URL, + disk: any DiskAttachmentSource, nvramURL: URL, vmConfig: VMConfig, network: Network = NetworkShared(), @@ -404,12 +410,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { } // Storage - let attachment = try VZDiskImageStorageDeviceAttachment( - url: diskURL, + // When not specified, use "cached" caching mode for Linux VMs to prevent file-system corruption[1] + // + // [1]: https://github.com/cirruslabs/tart/pull/675 + let attachment = try disk.makeAttachment( readOnly: false, - // When not specified, use "cached" caching mode for Linux VMs to prevent file-system corruption[1] - // - // [1]: https://github.com/cirruslabs/tart/pull/675 cachingMode: caching ?? (vmConfig.os == .linux ? .cached : .automatic), synchronizationMode: sync ) diff --git a/Sources/tart/VMDirectory+Archive.swift b/Sources/tart/VMDirectory+Archive.swift index dc62aac8..9e4600b2 100644 --- a/Sources/tart/VMDirectory+Archive.swift +++ b/Sources/tart/VMDirectory+Archive.swift @@ -10,6 +10,10 @@ fileprivate let permissions = FilePermissions(rawValue: 0o644) // [2]: https://developer.apple.com/documentation/compression/algorithm/lzfse extension VMDirectory { func exportToArchive(path: String) throws { + guard !isStackedLocal && !isStackedOCIRecord else { + throw RuntimeError.ExportFailed("exporting stacked VMs is not supported yet") + } + guard let fileStream = ArchiveByteStream.fileStream( path: FilePath(path), mode: .writeOnly, diff --git a/Sources/tart/VMDirectory+DiskImageStack.swift b/Sources/tart/VMDirectory+DiskImageStack.swift new file mode 100644 index 00000000..55682d1b --- /dev/null +++ b/Sources/tart/VMDirectory+DiskImageStack.swift @@ -0,0 +1,118 @@ +import Foundation + +extension VMDirectory { + func diskImageStack(contentStore providedStore: ContentStore? = nil) throws -> DiskImageStack { + let manifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL)) + let base: TartDiskFileGroup + let overlays: [TartDiskFileGroup] + + switch try manifest.tartDiskRepresentation() { + case .flat(let pinnedBase) where pinnedBase.contentDigest != nil: + base = pinnedBase + overlays = [] + case .stacked(let stackedBase, let stackedOverlays): + base = stackedBase + overlays = stackedOverlays + default: + throw RuntimeError.VMConfigurationError("VM is missing its disk image metadata") + } + guard let blockSize = manifest.diskBlockSize(), + let blockCount = manifest.diskBlockCount() else { + throw DiskImageStackError.invalidGeometry("disk image metadata is missing block geometry") + } + + let contentStore = try providedStore ?? ContentStore() + let baseFile = try diskImageFile(for: base, contentStore: contentStore) + let overlayFiles = try overlays.map { try diskImageFile(for: $0, contentStore: contentStore) } + let config = try VMConfig(fromURL: configURL) + + return DiskImageStack( + base: baseFile, + baseFormat: config.diskFormat, + overlays: overlayFiles, + writableOverlayURL: overlayURL, + blockSize: blockSize, + blockCount: blockCount + ) + } + + func cloneStacked( + to destination: VMDirectory, + copyWritableOverlay: Bool, + generateMAC: Bool, + contentStore: ContentStore? = nil + ) throws { + try FileManager.default.copyItem(at: configURL, to: destination.configURL) + try FileManager.default.copyItem(at: nvramURL, to: destination.nvramURL) + try FileManager.default.copyItem(at: manifestURL, to: destination.manifestURL) + + if copyWritableOverlay { + try FileManager.default.copyItem(at: overlayURL, to: destination.overlayURL) + } else { + try destination.diskImageStack(contentStore: contentStore).createWritableOverlay() + } + + if generateMAC { + try destination.regenerateMACAddress() + } + } + + func cloneAsStackedBase( + to destination: VMDirectory, + generateMAC: Bool, + contentStore providedStore: ContentStore? = nil + ) throws { + let config = try VMConfig(fromURL: configURL) + let geometry = try DiskImageStack.baseGeometry(at: diskURL, expectedFormat: config.diskFormat) + let contentDigest = try Digest.hash(diskURL) + let contentStore = try providedStore ?? ContentStore() + + if try contentStore.existingContentURL(for: contentDigest) == nil { + let temporaryURL = try contentStore.temporaryContentURL(for: contentDigest) + do { + try FileManager.default.copyItem(at: diskURL, to: temporaryURL) + _ = try contentStore.install(temporaryURL, contentDigest: contentDigest) + } catch { + try? FileManager.default.removeItem(at: temporaryURL) + throw error + } + } + + var manifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL)) + guard case .flat = try manifest.tartDiskRepresentation() else { + throw RuntimeError.VMConfigurationError("--base currently supports only flat OCI images") + } + + guard let firstDiskIndex = manifest.layers.firstIndex(where: { $0.mediaType == diskV2MediaType }) else { + throw OCIManifestValidationError.invalidLayout("manifest must contain at least one disk chunk") + } + + var baseAnnotations = manifest.layers[firstDiskIndex].annotations ?? [:] + baseAnnotations[diskFileContentDigestAnnotation] = contentDigest + manifest.layers[firstDiskIndex].annotations = baseAnnotations + var annotations = manifest.annotations ?? [:] + annotations[diskBlockSizeAnnotation] = String(geometry.blockSize) + annotations[diskBlockCountAnnotation] = String(geometry.blockCount) + manifest.annotations = annotations + + try FileManager.default.copyItem(at: configURL, to: destination.configURL) + try FileManager.default.copyItem(at: nvramURL, to: destination.nvramURL) + try manifest.toJSON().write(to: destination.manifestURL) + try destination.diskImageStack(contentStore: contentStore).createWritableOverlay() + + if generateMAC { + try destination.regenerateMACAddress() + } + } + + private func diskImageFile(for group: TartDiskFileGroup, contentStore: ContentStore) throws -> DiskImageFile { + guard let contentDigest = group.contentDigest else { + throw OCIManifestValidationError.invalidDiskMetadata("stacked disk files need a whole-file content digest") + } + guard let url = try contentStore.existingContentURL(for: contentDigest) else { + throw RuntimeError.VMMissingFiles("VM is missing cached disk content \(contentDigest)") + } + + return DiskImageFile(url: url, contentDigest: contentDigest) + } +} diff --git a/Sources/tart/VMDirectory+OCI.swift b/Sources/tart/VMDirectory+OCI.swift index 96104165..a8559a9d 100644 --- a/Sources/tart/VMDirectory+OCI.swift +++ b/Sources/tart/VMDirectory+OCI.swift @@ -6,7 +6,6 @@ let legacyDiskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1" enum OCIError: Error { case ShouldBeExactlyOneLayer - case ShouldBeAtLeastOneLayer case FailedToCreateVmFile case LayerIsMissingUncompressedSizeAnnotation case LayerIsMissingUncompressedDigestAnnotation @@ -14,7 +13,7 @@ enum OCIError: Error { extension VMDirectory { func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws { - // Pull VM's config file layer and re-serialize it into a config file + // Pull VM's config file layer and store it as the local config file. let configLayers = manifest.layers.filter { $0.mediaType == configMediaType } @@ -30,17 +29,22 @@ extension VMDirectory { } try configFile.close() - // Pull VM's disk layers and decompress them into a disk file + // Pull VM's disk chunks and decompress them into complete disk files. if manifest.layers.contains(where: { $0.mediaType == legacyDiskV1MediaType }) { throw RuntimeError.Generic("Pulling OCI images with legacy disk media type \(legacyDiskV1MediaType) is no longer supported, please re-push the image using a current Tart version") } - let layers = manifest.layers.filter { $0.mediaType == diskV2MediaType } - if layers.isEmpty { - throw OCIError.ShouldBeAtLeastOneLayer + let diskRepresentation = try manifest.tartDiskRepresentation() + let diskChunks: [OCIManifestLayer] + + switch diskRepresentation { + case .flat(let base): + diskChunks = base.chunks + case .stacked(let base, let overlays): + diskChunks = base.chunks + overlays.flatMap(\.chunks) } - let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +) + let diskCompressedSize = diskChunks.map { Int64($0.size) }.reduce(0, +) OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute( key: "compressed_disk_size_bytes", value: .int(Int(diskCompressedSize)) @@ -53,19 +57,42 @@ extension VMDirectory { ProgressObserver(progress).log(defaultLogger) do { - try await DiskV2.pull(registry: registry, diskLayers: layers, diskURL: diskURL, - concurrency: concurrency, progress: progress, - localLayerCache: localLayerCache, - deduplicate: deduplicate) + switch diskRepresentation { + case .flat(let base): + try await DiskV2.pull(registry: registry, diskLayers: base.chunks, diskURL: diskURL, + concurrency: concurrency, progress: progress, + localLayerCache: localLayerCache, + deduplicate: deduplicate) + + if deduplicate, let llc = localLayerCache { + // set custom attribute to remember deduplicated bytes + diskURL.setDeduplicatedBytes(llc.deduplicatedBytes) + } + case .stacked(let base, let overlays): + // The deterministic resumable directory may contain a partial + // disk.img from an interrupted pull while this tag was flat. A + // stacked OCI record must not retain that file or it is mistaken for + // a standalone VM after the pull is moved into cache. + if FileManager.default.fileExists(atPath: diskURL.path) { + try FileManager.default.removeItem(at: diskURL) + } + + let contentStore = try ContentStore() + + for group in [base] + overlays { + _ = try await pullDiskFile( + registry: registry, + group: group, + contentStore: contentStore, + concurrency: concurrency, + progress: progress + ) + } + } } catch let error where error is FilterError { throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)") } - if deduplicate, let llc = localLayerCache { - // set custom attribute to remember deduplicated bytes - diskURL.setDeduplicatedBytes(llc.deduplicatedBytes) - } - // Pull VM's NVRAM file layer and store it in an NVRAM file defaultLogger.appendNewLine("pulling NVRAM...") @@ -83,12 +110,46 @@ extension VMDirectory { try nvram.write(contentsOf: data) } try nvram.close() + } + + /// Reconstructs one complete immutable base disk or published ASIF overlay + /// from its Tart disk chunks, unless the shared content store already has a + /// verified copy. + private func pullDiskFile( + registry: Registry, + group: TartDiskFileGroup, + contentStore: ContentStore, + concurrency: UInt, + progress: Progress + ) async throws -> URL { + guard let contentDigest = group.contentDigest else { + throw OCIManifestValidationError.invalidDiskMetadata("stacked disk files need a whole-file content digest") + } + + if let existingURL = try contentStore.existingContentURL(for: contentDigest) { + progress.completedUnitCount += group.chunks.reduce(0) { $0 + Int64($1.size) } + return existingURL + } + + let temporaryURL = try contentStore.temporaryContentURL(for: contentDigest) + + do { + try await DiskV2.pull( + registry: registry, + diskLayers: group.chunks, + diskURL: temporaryURL, + concurrency: concurrency, + progress: progress + ) - // Serialize VM's manifest to enable better deduplication on subsequent "tart pull"'s - try manifest.toJSON().write(to: manifestURL) + return try contentStore.install(temporaryURL, contentDigest: contentDigest) + } catch { + try? FileManager.default.removeItem(at: temporaryURL) + throw error + } } - func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, concurrency: UInt, labels: [String: String] = [:]) async throws -> RemoteName { + func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, concurrency: UInt, labels: [String: String] = [:]) async throws -> (name: RemoteName, manifest: OCIManifest) { var layers = Array() // Read VM's config and push it as blob @@ -102,14 +163,12 @@ extension VMDirectory { let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb) layers.append(OCIManifestLayer(mediaType: configMediaType, size: configJSON.count, digest: configDigest)) - // Compress the disk file as multiple chunks and push them as disk layers - let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64 - - defaultLogger.appendNewLine("pushing disk... this will take a while...") - let progress = Progress(totalUnitCount: diskSize) - ProgressObserver(progress).log(defaultLogger) - - layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress)) + let (diskLayers, diskAnnotations) = try await diskDescriptorsForPush( + registry: registry, + chunkSizeMb: chunkSizeMb, + concurrency: concurrency + ) + layers.append(contentsOf: diskLayers) // Read VM's NVRAM and push it as blob defaultLogger.appendNewLine("pushing NVRAM...") @@ -122,12 +181,13 @@ extension VMDirectory { let ociConfigContainer = OCIConfig.ConfigContainer(Labels: labels) let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os, config: ociConfigContainer).toJSON() let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb) - let manifest = OCIManifest( + var manifest = OCIManifest( config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest), - layers: layers, - uncompressedDiskSize: UInt64(diskSize), - uploadDate: Date() + layers: layers ) + var annotations = diskAnnotations + annotations[uploadTimeAnnotation] = Date().toISO() + manifest.annotations = annotations // Manifest for reference in references { @@ -137,7 +197,152 @@ extension VMDirectory { } let pushedReference = Reference(digest: try manifest.digest()) - return RemoteName(host: registry.host!, namespace: registry.namespace, reference: pushedReference) + let name = RemoteName(host: registry.host!, namespace: registry.namespace, reference: pushedReference) + return (name, manifest) + } + + /// Builds the disk portion of the manifest. Registry transport is shared + /// for standalone and stacked VMs; only their local disk representation + /// determines which descriptors need to be uploaded or reused. + private func diskDescriptorsForPush( + registry: Registry, + chunkSizeMb: Int, + concurrency: UInt + ) async throws -> ([OCIManifestLayer], [String: String]) { + guard isStackedLocal else { + let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64 + defaultLogger.appendNewLine("pushing disk... this will take a while...") + let progress = Progress(totalUnitCount: diskSize) + ProgressObserver(progress).log(defaultLogger) + + let layers = try await DiskV2.push( + diskURL: diskURL, + mediaType: diskV2MediaType, + registry: registry, + chunkSizeMb: chunkSizeMb, + concurrency: concurrency, + progress: progress + ) + return (layers, [uncompressedDiskSizeAnnotation: String(diskSize)]) + } + + let localManifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL)) + let inheritedGroups: [TartDiskFileGroup] + switch try localManifest.tartDiskRepresentation() { + case .flat(let base) where base.contentDigest != nil: + inheritedGroups = [base] + case .stacked(let base, let overlays): + inheritedGroups = [base] + overlays + default: + throw RuntimeError.VMConfigurationError("stacked VM is missing a pinned disk stack") + } + + let contentStore = try ContentStore() + var layers: [OCIManifestLayer] = [] + for group in inheritedGroups { + layers.append(contentsOf: try await descriptorsForCachedDiskFile( + group, + contentStore: contentStore, + registry: registry, + chunkSizeMb: chunkSizeMb, + concurrency: concurrency + )) + } + + let frozenOverlayURL = try Config().tartTmpDir.appendingPathComponent("\(UUID().uuidString).asif") + try FileManager.default.copyItem(at: overlayURL, to: frozenOverlayURL) + defer { try? FileManager.default.removeItem(at: frozenOverlayURL) } + + let overlaySize = try FileManager.default.attributesOfItem(atPath: frozenOverlayURL.path)[.size] as! Int64 + defaultLogger.appendNewLine("pushing overlay...") + let progress = Progress(totalUnitCount: overlaySize) + ProgressObserver(progress).log(defaultLogger) + let contentDigest = try Digest.hash(frozenOverlayURL) + let chunks = try await DiskV2.push( + diskURL: frozenOverlayURL, + mediaType: asifOverlayMediaType, + registry: registry, + chunkSizeMb: chunkSizeMb, + concurrency: concurrency, + progress: progress + ) + layers.append(contentsOf: annotatedChunks(chunks, kind: .asifOverlay, contentDigest: contentDigest)) + + let geometry = try DiskImageStack.diskImageGeometry(at: frozenOverlayURL) + let diskSize = geometry.blockSize.multipliedReportingOverflow(by: geometry.blockCount) + guard !diskSize.overflow else { + throw DiskImageStackError.invalidGeometry("stacked disk geometry overflows UInt64") + } + + var annotations = localManifest.annotations ?? [:] + annotations[diskBlockSizeAnnotation] = String(geometry.blockSize) + annotations[diskBlockCountAnnotation] = String(geometry.blockCount) + annotations[uncompressedDiskSizeAnnotation] = String(diskSize.partialValue) + + return (layers, annotations) + } + + /// Returns transport descriptors for an immutable disk file. If the + /// target registry lacks the original blobs, recreate them from the local + /// content store. + private func descriptorsForCachedDiskFile( + _ group: TartDiskFileGroup, + contentStore: ContentStore, + registry: Registry, + chunkSizeMb: Int, + concurrency: UInt + ) async throws -> [OCIManifestLayer] { + guard let contentDigest = group.contentDigest else { + throw RuntimeError.VMConfigurationError("stacked VM is missing a pinned disk file digest") + } + + var allChunksExist = true + for chunk in group.chunks { + if try await !registry.blobExists(chunk.digest) { + allChunksExist = false + break + } + } + if allChunksExist { + return group.chunks + } + + guard let contentURL = try contentStore.existingContentURL(for: contentDigest) else { + throw RuntimeError.VMMissingFiles("stacked VM is missing cached disk content \(contentDigest)") + } + let contentSize = try FileManager.default.attributesOfItem(atPath: contentURL.path)[.size] as! Int64 + let progress = Progress(totalUnitCount: contentSize) + let mediaType = group.kind == .base ? diskV2MediaType : asifOverlayMediaType + let chunks = try await DiskV2.push( + diskURL: contentURL, + mediaType: mediaType, + registry: registry, + chunkSizeMb: chunkSizeMb, + concurrency: concurrency, + progress: progress + ) + + return annotatedChunks(chunks, kind: group.kind, contentDigest: contentDigest) + } + + private func annotatedChunks( + _ chunks: [OCIManifestLayer], + kind: TartDiskFileGroup.Kind, + contentDigest: String + ) -> [OCIManifestLayer] { + guard !chunks.isEmpty else { + return chunks + } + + var chunks = chunks + var annotations = chunks[0].annotations ?? [:] + annotations[diskFileContentDigestAnnotation] = contentDigest + if kind == .asifOverlay { + annotations[diskFileChunkCountAnnotation] = String(chunks.count) + } + chunks[0].annotations = annotations + + return chunks } } diff --git a/Sources/tart/VMDirectory.swift b/Sources/tart/VMDirectory.swift index ab8b12ba..9ffcf9af 100644 --- a/Sources/tart/VMDirectory.swift +++ b/Sources/tart/VMDirectory.swift @@ -133,6 +133,12 @@ struct VMDirectory: Prunable { isStandalone || isStackedLocal } + /// Shapes that may live in OCI storage. A stacked pulled record has no + /// writable overlay and is intentionally not runnable as a local VM. + var isOCIRecord: Bool { + isStandalone || isStackedOCIRecord + } + func initialize(overwrite: Bool = false) throws { if !overwrite && initialized { throw RuntimeError.VMDirectoryAlreadyInitialized("VM directory is already initialized, preventing overwrite") @@ -163,6 +169,20 @@ struct VMDirectory: Prunable { } } + func validateOCIRecord(userFriendlyName: String) throws { + if !FileManager.default.fileExists(atPath: baseURL.path) { + throw RuntimeError.VMDoesNotExist(name: userFriendlyName) + } + + if !isOCIRecord { + throw RuntimeError.VMMissingFiles( + "OCI record is missing files for a supported layout: " + + "flat requires \(configURL.lastPathComponent), \(diskURL.lastPathComponent) and \(nvramURL.lastPathComponent); " + + "stacked requires \(configURL.lastPathComponent), \(manifestURL.lastPathComponent) and \(nvramURL.lastPathComponent)" + ) + } + } + func clone(to: VMDirectory, generateMAC: Bool) throws { try FileManager.default.copyItem(at: configURL, to: to.configURL) try FileManager.default.copyItem(at: nvramURL, to: to.nvramURL) @@ -189,7 +209,26 @@ struct VMDirectory: Prunable { try vmConfig.save(toURL: configURL) } - func resizeDisk(_ sizeGB: UInt16, format: DiskImageFormat = .raw) throws { + func resizeDisk( + _ sizeGB: UInt16, + format: DiskImageFormat = .raw, + contentStore: ContentStore? = nil + ) throws { + if isStackedLocal { + guard try state() == .Stopped else { + throw RuntimeError.VMConfigurationError("VM \"\(name)\" must be stopped before resizing its disk") + } + + let stack = try diskImageStack(contentStore: contentStore) + let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000 + guard desiredSizeBytes.isMultiple(of: stack.blockSize) else { + throw RuntimeError.InvalidDiskSize("new disk size must align to the stacked disk block size") + } + + try stack.growWritableOverlay(toBlockCount: desiredSizeBytes / stack.blockSize) + return + } + let diskExists = FileManager.default.fileExists(atPath: diskURL.path) if diskExists { @@ -323,7 +362,7 @@ struct VMDirectory: Prunable { } func allocatedSizeBytes() throws -> Int { - try configURL.allocatedSizeBytes() + diskURL.allocatedSizeBytes() + nvramURL.allocatedSizeBytes() + try configURL.allocatedSizeBytes() + localDiskStorageAllocatedSizeBytes() + nvramURL.allocatedSizeBytes() } func allocatedSizeGB() throws -> Int { @@ -331,7 +370,7 @@ struct VMDirectory: Prunable { } func deduplicatedSizeBytes() throws -> Int { - try configURL.deduplicatedSizeBytes() + diskURL.deduplicatedSizeBytes() + nvramURL.deduplicatedSizeBytes() + try configURL.deduplicatedSizeBytes() + localDiskStorageDeduplicatedSizeBytes() + nvramURL.deduplicatedSizeBytes() } func deduplicatedSizeGB() throws -> Int { @@ -339,7 +378,7 @@ struct VMDirectory: Prunable { } func sizeBytes() throws -> Int { - try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes() + try configURL.sizeBytes() + localDiskStorageSizeBytes() + nvramURL.sizeBytes() } func sizeGB() throws -> Int { @@ -347,6 +386,30 @@ struct VMDirectory: Prunable { } func diskSizeBytes() throws -> Int { + if isStackedLocal { + let geometry = try DiskImageStack.diskImageGeometry(at: overlayURL) + let product = geometry.blockSize.multipliedReportingOverflow(by: geometry.blockCount) + guard !product.overflow, let diskSizeBytes = Int(exactly: product.partialValue) else { + throw RuntimeError.VMConfigurationError("VM has invalid stacked disk geometry") + } + + return diskSizeBytes + } + + if isStackedOCIRecord { + let manifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL)) + guard let blockSize = manifest.diskBlockSize(), + let blockCount = manifest.diskBlockCount() else { + throw RuntimeError.VMConfigurationError("VM has invalid stacked disk geometry") + } + let product = blockSize.multipliedReportingOverflow(by: blockCount) + guard !product.overflow, let diskSizeBytes = Int(exactly: product.partialValue) else { + throw RuntimeError.VMConfigurationError("VM has invalid stacked disk geometry") + } + + return diskSizeBytes + } + let vmConfig = try VMConfig(fromURL: configURL) return switch vmConfig.diskFormat { @@ -368,4 +431,23 @@ struct VMDirectory: Prunable { func isExplicitlyPulled() -> Bool { FileManager.default.fileExists(atPath: explicitlyPulledMark.path) } + + private var localDiskStorageURL: URL { + isStackedLocal ? overlayURL : diskURL + } + + // Pulled stacked OCI records own no disk file in their VM directory. Their + // immutable disk content lives in the shared content store and must not be + // charged to every record that references it. + private func localDiskStorageAllocatedSizeBytes() throws -> Int { + isStackedOCIRecord ? 0 : try localDiskStorageURL.allocatedSizeBytes() + } + + private func localDiskStorageDeduplicatedSizeBytes() throws -> Int { + isStackedOCIRecord ? 0 : try localDiskStorageURL.deduplicatedSizeBytes() + } + + private func localDiskStorageSizeBytes() throws -> Int { + isStackedOCIRecord ? 0 : try localDiskStorageURL.sizeBytes() + } } diff --git a/Sources/tart/VMStorageOCI.swift b/Sources/tart/VMStorageOCI.swift index 4240b4e2..b9719d69 100644 --- a/Sources/tart/VMStorageOCI.swift +++ b/Sources/tart/VMStorageOCI.swift @@ -18,7 +18,98 @@ class VMStorageOCI: PrunableStorage { } func exists(_ name: RemoteName) -> Bool { - VMDirectory(baseURL: vmURL(name)).initialized + VMDirectory(baseURL: vmURL(name)).isOCIRecord + } + + /// Whether clone can use a cached record without pulling. Flat records keep + /// Tart's existing structural check. Stacked records cheaply require every + /// immutable file with its expected length; explicit pull remains the path + /// that hashes content and repairs same-sized corruption. + func hasUsableCachedRecordForClone(_ name: RemoteName, requireManifest: Bool = false) throws -> Bool { + guard exists(name) else { + return false + } + + let vmDir = VMDirectory(baseURL: vmURL(name)) + if requireManifest && !FileManager.default.fileExists(atPath: vmDir.manifestURL.path) { + return false + } + guard vmDir.isStackedOCIRecord else { + return true + } + + let manifest = try OCIManifest(fromJSON: Data(contentsOf: vmDir.manifestURL)) + guard case .stacked(let base, let overlays) = try manifest.tartDiskRepresentation() else { + return true + } + + let contentStore = try ContentStore() + for group in [base] + overlays { + guard let contentDigest = group.contentDigest, + let contentURL = try contentStore.contentURLIfPresent(for: contentDigest) else { + return false + } + + var expectedSize: UInt64 = 0 + for chunk in group.chunks { + guard let uncompressedSize = chunk.uncompressedSize() else { + return false + } + let addition = expectedSize.addingReportingOverflow(uncompressedSize) + guard !addition.overflow else { + return false + } + expectedSize = addition.partialValue + } + + guard let actualSize = UInt64(exactly: try contentURL.sizeBytes()), + actualSize == expectedSize else { + return false + } + } + + return true + } + + /// Whether an OCI record is complete enough for `pull` to return without + /// repairing it. Flat records keep Tart's existing structural cache-hit + /// behavior; stacked records additionally need every immutable disk file in + /// the shared content store. + func hasCompleteCachedRecord(_ name: RemoteName, manifest: OCIManifest) throws -> Bool { + guard exists(name) else { + return false + } + + guard let missingGroups = try missingStackedDiskFileGroups(for: manifest) else { + return true + } + + return missingGroups.isEmpty + } + + /// Bytes that this pull may need to materialize locally. For stacked images + /// this is the sum of only the missing complete disk files, not the final + /// guest-visible disk geometry. + func requiredDiskStorageBytes(for manifest: OCIManifest) throws -> UInt64? { + guard let missingGroups = try missingStackedDiskFileGroups(for: manifest) else { + return manifest.uncompressedDiskSize() + } + + var total: UInt64 = 0 + for group in missingGroups { + for chunk in group.chunks { + guard let uncompressedSize = chunk.uncompressedSize() else { + throw OCIManifestValidationError.invalidDiskMetadata("disk chunks need uncompressed size and content digest") + } + let addition = total.addingReportingOverflow(uncompressedSize) + guard !addition.overflow else { + throw RuntimeError.PullFailed("stacked disk storage size overflows UInt64") + } + total = addition.partialValue + } + } + + return total } func digest(_ name: RemoteName) throws -> String { @@ -34,7 +125,7 @@ class VMStorageOCI: PrunableStorage { func open(_ name: RemoteName, _ accessDate: Date = Date()) throws -> VMDirectory { let vmDir = VMDirectory(baseURL: vmURL(name)) - try vmDir.validate(userFriendlyName: name.description) + try vmDir.validateOCIRecord(userFriendlyName: name.description) try vmDir.baseURL.updateAccessDate(accessDate) @@ -44,11 +135,55 @@ class VMStorageOCI: PrunableStorage { func create(_ name: RemoteName, overwrite: Bool = false) throws -> VMDirectory { let vmDir = VMDirectory(baseURL: vmURL(name)) + if !overwrite && vmDir.isOCIRecord { + throw RuntimeError.VMDirectoryAlreadyInitialized("VM directory is already initialized, preventing overwrite") + } + try vmDir.initialize(overwrite: overwrite) return vmDir } + /// Materialize the digest-addressed OCI record for an image Tart just + /// pushed, without routing its own local data back through the registry. + func populate(_ name: RemoteName, from source: VMDirectory, manifest: OCIManifest) throws { + if try hasCompleteCachedRecord(name, manifest: manifest) { + return + } + + let vmDir = try create(name, overwrite: exists(name)) + + if source.isStackedLocal { + guard case .stacked(_, let overlays) = try manifest.tartDiskRepresentation(), + let contentDigest = overlays.last?.contentDigest else { + throw RuntimeError.VMConfigurationError("pushed image is missing its writable ASIF overlay") + } + + // The pushed top overlay becomes immutable in the OCI record. Keep a + // semantic copy so later clones do not need to fetch it back. + let contentStore = try ContentStore() + if try contentStore.existingContentURL(for: contentDigest) == nil { + let temporaryURL = try contentStore.temporaryContentURL(for: contentDigest) + do { + try FileManager.default.copyItem(at: source.overlayURL, to: temporaryURL) + _ = try contentStore.install(temporaryURL, contentDigest: contentDigest) + } catch { + try? FileManager.default.removeItem(at: temporaryURL) + throw error + } + } + + try FileManager.default.copyItem(at: source.configURL, to: vmDir.configURL) + try FileManager.default.copyItem(at: source.nvramURL, to: vmDir.nvramURL) + } else { + try source.clone(to: vmDir, generateMAC: false) + } + + // Keep the exact manifest Tart submitted so tag links and later pushes + // refer to the same digest-addressed OCI record. + try manifest.toJSON().write(to: vmDir.manifestURL) + } + func move(_ name: RemoteName, from: VMDirectory) throws{ let targetURL = vmURL(name) @@ -84,7 +219,7 @@ class VMStorageOCI: PrunableStorage { } let vmDir = VMDirectory(baseURL: foundURL.resolvingSymlinksInPath()) - if !vmDir.initialized { + if !vmDir.isOCIRecord { continue } @@ -113,7 +248,7 @@ class VMStorageOCI: PrunableStorage { for case let foundURL as URL in enumerator { let vmDir = VMDirectory(baseURL: foundURL) - if !vmDir.initialized { + if !vmDir.isOCIRecord { continue } @@ -141,7 +276,9 @@ class VMStorageOCI: PrunableStorage { } func prunables() throws -> [Prunable] { - try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir } + try list().filter { (_, vmDir, isSymlink) in + !isSymlink && vmDir.isStandalone + }.map { (_, vmDir, _) in vmDir } } func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws { @@ -157,7 +294,8 @@ class VMStorageOCI: PrunableStorage { let digestName = RemoteName(host: name.host, namespace: name.namespace, reference: Reference(digest: Digest.hash(manifestData))) - if exists(name) && exists(digestName) && linked(from: name, to: digestName) { + let hasCompleteDigestRecord = try hasCompleteCachedRecord(digestName, manifest: manifest) + if exists(name) && hasCompleteDigestRecord && linked(from: name, to: digestName) { // optimistically check if we need to do anything at all before locking defaultLogger.appendNewLine("\(digestName) image is already cached and linked!") return @@ -181,11 +319,13 @@ class VMStorageOCI: PrunableStorage { throw CancellationError() } - if !exists(digestName) { + if try !hasCompleteCachedRecord(digestName, manifest: manifest) { let span = OTel.shared.tracer.spanBuilder(spanName: "pull").setActive(true).startSpan() defer { span.end() } let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description) + let digestVMDir = VMDirectory(baseURL: vmURL(digestName)) + let preserveExplicitlyPulledMark = digestVMDir.isExplicitlyPulled() // Open an existing VM directory corresponding to this name, if any, // marking it as outdated to speed up the garbage collection process @@ -196,21 +336,35 @@ class VMStorageOCI: PrunableStorage { try tmpVMDirLock.lock() // Try to reclaim some cache space if we know the VM size in advance - if let uncompressedDiskSize = manifest.uncompressedDiskSize() { - OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute( - key: "oci.image-uncompressed-disk-size-bytes", - value: .int(Int(uncompressedDiskSize)) - ) + if let requiredDiskStorageBytes = try requiredDiskStorageBytes(for: manifest) { + if let telemetryValue = Int(exactly: requiredDiskStorageBytes) { + OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute( + key: "oci.image-required-disk-storage-bytes", + value: .int(telemetryValue) + ) + } let otherVMFilesSize: UInt64 = 128 * 1024 * 1024 + let requiredStorage = requiredDiskStorageBytes.addingReportingOverflow(otherVMFilesSize) + guard !requiredStorage.overflow else { + throw RuntimeError.PullFailed("required pull storage size overflows UInt64") + } - try Prune.reclaimIfNeeded(uncompressedDiskSize + otherVMFilesSize) + try Prune.reclaimIfNeeded(requiredStorage.partialValue) } try await withTaskCancellationHandler(operation: { try await retry(maxAttempts: 5) { - // Choose the best base image which has the most deduplication ratio - let localLayerCache = try await chooseLocalLayerCache(name, manifest, registry) + // Existing flat images can still reuse another complete local disk. + // Stacked images reconstruct their immutable files through the + // shared content store instead of materializing disk.img. + let localLayerCache: LocalLayerCache? + switch try manifest.tartDiskRepresentation() { + case .flat: + localLayerCache = try await chooseLocalLayerCache(name, manifest, registry) + case .stacked: + localLayerCache = nil + } if let llc = localLayerCache { let deduplicatedHuman = ByteCountFormatter.string(fromByteCount: Int64(llc.deduplicatedBytes), countStyle: .file) @@ -232,6 +386,14 @@ class VMStorageOCI: PrunableStorage { return .throw } + + // Preserve the exact manifest bytes received from the registry. Its + // digest identifies this OCI cache record and stacked VMs pin it. + try manifestData.write(to: tmpVMDir.manifestURL) + if preserveExplicitlyPulledMark { + tmpVMDir.markExplicitlyPulled() + } + try move(digestName, from: tmpVMDir) }, onCancel: { try? FileManager.default.removeItem(at: tmpVMDir.baseURL) @@ -253,6 +415,28 @@ class VMStorageOCI: PrunableStorage { _ = try VMStorageOCI().open(name) } + /// Returns `nil` for flat images and the missing immutable disk-file groups + /// for stacked images. `ContentStore.existingContentURL()` intentionally + /// validates the digest so corrupt entries are repaired by a normal pull. + private func missingStackedDiskFileGroups(for manifest: OCIManifest) throws -> [TartDiskFileGroup]? { + guard case .stacked(let base, let overlays) = try manifest.tartDiskRepresentation() else { + return nil + } + + let contentStore = try ContentStore() + var missingGroups: [TartDiskFileGroup] = [] + for group in [base] + overlays { + guard let contentDigest = group.contentDigest else { + throw OCIManifestValidationError.invalidDiskMetadata("stacked disk files need a whole-file content digest") + } + if try contentStore.existingContentURL(for: contentDigest) == nil { + missingGroups.append(group) + } + } + + return missingGroups + } + func linked(from: RemoteName, to: RemoteName) -> Bool { do { let resolvedFrom = try FileManager.default.destinationOfSymbolicLink(atPath: vmURL(from).path) @@ -280,10 +464,16 @@ class VMStorageOCI: PrunableStorage { } // Load OCI VM images and their manifests (if present) - var candidates: [(name: String, vmDir: VMDirectory, manifest: OCIManifest, deduplicatedBytes: UInt64)] = [] + var candidates: [( + name: String, + vmDir: VMDirectory, + manifest: OCIManifest, + manifestDigest: String, + deduplicatedBytes: UInt64 + )] = [] for (name, vmDir, isSymlink) in try list() { - if isSymlink { + if isSymlink || !vmDir.isStandalone { continue } @@ -295,7 +485,13 @@ class VMStorageOCI: PrunableStorage { continue } - candidates.append((name, vmDir, manifest, calculateDeduplicatedBytes(manifest))) + candidates.append(( + name, + vmDir, + manifest, + Digest.hash(manifestJSON), + calculateDeduplicatedBytes(manifest) + )) } // Previously we haven't stored the OCI VM image manifests, but still fetched the VM image manifest if @@ -305,10 +501,17 @@ class VMStorageOCI: PrunableStorage { // with the registry if we haven't already retrieved the manifest for that OCI VM image. if name.reference.type == .Tag, let vmDir = try? open(name), + vmDir.isStandalone, let digest = try? digest(name), - try !candidates.contains(where: {try $0.manifest.digest() == digest}), - let (manifest, _) = try? await registry.pullManifest(reference: digest) { - candidates.append((name.description, vmDir, manifest, calculateDeduplicatedBytes(manifest))) + !candidates.contains(where: { $0.manifestDigest == digest }), + let (manifest, manifestData) = try? await registry.pullManifest(reference: digest) { + candidates.append(( + name.description, + vmDir, + manifest, + Digest.hash(manifestData), + calculateDeduplicatedBytes(manifest) + )) } // Now, find the best match based on how many bytes we'll deduplicate diff --git a/Tests/TartTests/ContentStoreTests.swift b/Tests/TartTests/ContentStoreTests.swift index 3b142995..cab441b3 100644 --- a/Tests/TartTests/ContentStoreTests.swift +++ b/Tests/TartTests/ContentStoreTests.swift @@ -26,6 +26,43 @@ final class ContentStoreTests: XCTestCase { XCTAssertNil(try store.existingContentURL(for: expectedDigest)) } + func testInstallReplacesCorruptEntry() throws { + let store = try temporaryStore() + let data = Data("expected".utf8) + let digest = Digest.hash(data) + let contentURL = try store.contentURL(for: digest) + try FileManager.default.createDirectory(at: contentURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("corrupt".utf8).write(to: contentURL) + let temporaryURL = try store.temporaryContentURL(for: digest) + try data.write(to: temporaryURL) + + XCTAssertEqual(try store.install(temporaryURL, contentDigest: digest), contentURL) + XCTAssertEqual(try Digest.hash(contentURL), digest) + } + + func testInstallPreservesExistingValidEntry() throws { + let store = try temporaryStore() + let data = Data("expected".utf8) + let digest = Digest.hash(data) + let firstTemporaryURL = try store.temporaryContentURL(for: digest) + try data.write(to: firstTemporaryURL) + let installedURL = try store.install(firstTemporaryURL, contentDigest: digest) + let secondTemporaryURL = try store.temporaryContentURL(for: digest) + try data.write(to: secondTemporaryURL) + + XCTAssertEqual(try store.install(secondTemporaryURL, contentDigest: digest), installedURL) + XCTAssertFalse(FileManager.default.fileExists(atPath: secondTemporaryURL.path)) + XCTAssertEqual(try Digest.hash(installedURL), digest) + } + + func testConcurrentInstallsAcceptDigestValidWinner() throws { + try assertConcurrentInstalls(seedCorruptEntry: false) + } + + func testConcurrentInstallsRepairCorruptEntry() throws { + try assertConcurrentInstalls(seedCorruptEntry: true) + } + func testInstallRejectsWrongContentDigest() throws { let store = try temporaryStore() let expectedDigest = Digest.hash(Data("expected".utf8)) @@ -57,4 +94,51 @@ final class ContentStoreTests: XCTestCase { return try ContentStore(baseURL: url) } + + private func assertConcurrentInstalls(seedCorruptEntry: Bool) throws { + let store = try temporaryStore() + let data = Data("expected".utf8) + let digest = Digest.hash(data) + let contentURL = try store.contentURL(for: digest) + try FileManager.default.createDirectory(at: contentURL.deletingLastPathComponent(), withIntermediateDirectories: true) + if seedCorruptEntry { + try Data("corrupt".utf8).write(to: contentURL) + } + + let temporaryURLs = try (0..<16).map { _ in + let url = try store.temporaryContentURL(for: digest) + try data.write(to: url) + return url + } + let errors = ErrorCollector() + + DispatchQueue.concurrentPerform(iterations: temporaryURLs.count) { index in + do { + _ = try store.install(temporaryURLs[index], contentDigest: digest) + } catch { + errors.append(error) + } + } + + XCTAssertTrue(errors.values.isEmpty, "unexpected install errors: \(errors.values)") + XCTAssertEqual(try Digest.hash(contentURL), digest) + XCTAssertTrue(temporaryURLs.allSatisfy { !FileManager.default.fileExists(atPath: $0.path) }) + } + + private final class ErrorCollector: @unchecked Sendable { + private let lock = NSLock() + private var errors: [Error] = [] + + var values: [Error] { + lock.lock() + defer { lock.unlock() } + return errors + } + + func append(_ error: Error) { + lock.lock() + defer { lock.unlock() } + errors.append(error) + } + } } diff --git a/Tests/TartTests/DiskImageStackTests.swift b/Tests/TartTests/DiskImageStackTests.swift index 12198a2c..d7399556 100644 --- a/Tests/TartTests/DiskImageStackTests.swift +++ b/Tests/TartTests/DiskImageStackTests.swift @@ -45,6 +45,13 @@ import XCTest _ = try fixture.disk.makeAttachment() } + func testAttachesStackReadOnly() throws { + let fixture = try Fixture(baseFormat: .raw) + try fixture.disk.createWritableOverlay() + + _ = try fixture.disk.makeAttachment(readOnly: true) + } + func testRejectsMissingWritableOverlayWhenAttaching() throws { let fixture = try Fixture(baseFormat: .raw) diff --git a/Tests/TartTests/LayerizerTests.swift b/Tests/TartTests/LayerizerTests.swift index 4ce4b008..5e22ba97 100644 --- a/Tests/TartTests/LayerizerTests.swift +++ b/Tests/TartTests/LayerizerTests.swift @@ -36,7 +36,14 @@ final class LayerizerTests: XCTestCase { let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) print("pushing disk...") - let diskLayers = try await DiskV2.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, concurrency: 4, progress: Progress()) + let diskLayers = try await DiskV2.push( + diskURL: originalDiskFileURL, + mediaType: diskV2MediaType, + registry: registry, + chunkSizeMb: 0, + concurrency: 4, + progress: Progress() + ) print("pulling disk...") try await DiskV2.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress()) diff --git a/Tests/TartTests/VMDirectoryDiskImageStackTests.swift b/Tests/TartTests/VMDirectoryDiskImageStackTests.swift new file mode 100644 index 00000000..3ec5a78d --- /dev/null +++ b/Tests/TartTests/VMDirectoryDiskImageStackTests.swift @@ -0,0 +1,185 @@ +import Foundation +import XCTest +@testable import tart + +#if canImport(DiskImageKit) + import DiskImageKit + + @available(macOS 27.0, *) + final class VMDirectoryDiskImageStackTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + if #unavailable(macOS 27.0) { + throw XCTSkip("DiskImageKit tests require macOS 27 or newer") + } + } + + func testBaseGeometryReadsRawAndASIFImages() throws { + let directory = try temporaryDirectory() + let rawURL = directory.appendingPathComponent("base.raw") + let asifURL = directory.appendingPathComponent("base.asif") + _ = try DiskImage(creating: .raw(url: rawURL, blockCount: 8)) + _ = try DiskImage(creating: .asif(url: asifURL, blockCount: 16, blockSize: .bytes512)) + + XCTAssertEqual(try DiskImageStack.baseGeometry(at: rawURL, expectedFormat: .raw).blockCount, 8) + XCTAssertEqual(try DiskImageStack.baseGeometry(at: asifURL, expectedFormat: .asif).blockCount, 16) + } + + func testCloneAsStackedBasePinsFlatManifestAndCreatesOverlay() throws { + let contentStore = try temporaryContentStore() + let source = try flatSource() + let destination = try temporaryVMDirectory() + + try source.cloneAsStackedBase(to: destination, generateMAC: false, contentStore: contentStore) + + XCTAssertTrue(destination.isStackedLocal) + XCTAssertFalse(FileManager.default.fileExists(atPath: destination.diskURL.path)) + + let contentDigest = try Digest.hash(source.diskURL) + let manifest = try OCIManifest(fromJSON: Data(contentsOf: destination.manifestURL)) + guard case .flat(let base) = try manifest.tartDiskRepresentation() else { + return XCTFail("expected a pinned base-only manifest") + } + XCTAssertEqual(base.contentDigest, contentDigest) + XCTAssertEqual(manifest.diskBlockSize(), 512) + XCTAssertEqual(manifest.diskBlockCount(), 8) + + let stack = try destination.diskImageStack(contentStore: contentStore) + XCTAssertEqual(stack.base.contentDigest, contentDigest) + XCTAssertTrue(FileManager.default.fileExists(atPath: destination.overlayURL.path)) + } + + func testCloneAsStackedBaseSupportsASIFDisk() throws { + let contentStore = try temporaryContentStore() + let source = try flatSource(diskFormat: .asif) + let destination = try temporaryVMDirectory() + + try source.cloneAsStackedBase(to: destination, generateMAC: false, contentStore: contentStore) + + let stack = try destination.diskImageStack(contentStore: contentStore) + XCTAssertEqual(stack.baseFormat, .asif) + XCTAssertTrue(destination.isStackedLocal) + _ = try stack.makeAttachment() + } + + func testStackedCloneCanCopyOrCreateWritableOverlay() throws { + let contentStore = try temporaryContentStore() + let source = try flatSource() + let stacked = try temporaryVMDirectory() + try source.cloneAsStackedBase(to: stacked, generateMAC: false, contentStore: contentStore) + + let copied = try temporaryVMDirectory() + try stacked.cloneStacked(to: copied, copyWritableOverlay: true, generateMAC: false, contentStore: contentStore) + XCTAssertEqual(try Digest.hash(copied.overlayURL), try Digest.hash(stacked.overlayURL)) + + let fresh = try temporaryVMDirectory() + try stacked.cloneStacked(to: fresh, copyWritableOverlay: false, generateMAC: false, contentStore: contentStore) + XCTAssertTrue(fresh.isStackedLocal) + XCTAssertTrue(FileManager.default.fileExists(atPath: fresh.overlayURL.path)) + } + + func testResizeDiskGrowsWritableOverlay() throws { + let contentStore = try temporaryContentStore() + let source = try flatSource() + let stacked = try temporaryVMDirectory() + try source.cloneAsStackedBase(to: stacked, generateMAC: false, contentStore: contentStore) + + try stacked.resizeDisk(1, contentStore: contentStore) + + let image = try DiskImage(opening: .open(url: stacked.overlayURL, mode: .readOnly)) + XCTAssertEqual(image.blockCount, 1_000_000_000 / 512) + XCTAssertEqual(try stacked.diskSizeBytes(), 1_000_000_000) + } + + func testResolvesPublishedOverlayFromManifestAndCache() throws { + let contentStore = try temporaryContentStore() + let source = try flatSource() + let baseOnly = try temporaryVMDirectory() + try source.cloneAsStackedBase(to: baseOnly, generateMAC: false, contentStore: contentStore) + + let contentDigest = try Digest.hash(baseOnly.overlayURL) + let temporaryContentURL = try contentStore.temporaryContentURL(for: contentDigest) + try FileManager.default.copyItem(at: baseOnly.overlayURL, to: temporaryContentURL) + _ = try contentStore.install(temporaryContentURL, contentDigest: contentDigest) + + var manifest = try OCIManifest(fromJSON: Data(contentsOf: baseOnly.manifestURL)) + var overlay = OCIManifestLayer( + mediaType: asifOverlayMediaType, + size: 1, + digest: "sha256:overlay-transport", + uncompressedSize: 1, + uncompressedContentDigest: "sha256:overlay-chunk" + ) + overlay.annotations?[diskFileContentDigestAnnotation] = contentDigest + overlay.annotations?[diskFileChunkCountAnnotation] = "1" + manifest.layers.insert(overlay, at: manifest.layers.count - 1) + + let destination = try temporaryVMDirectory() + try FileManager.default.copyItem(at: baseOnly.configURL, to: destination.configURL) + try FileManager.default.copyItem(at: baseOnly.nvramURL, to: destination.nvramURL) + try manifest.toJSON().write(to: destination.manifestURL) + + let stack = try destination.diskImageStack(contentStore: contentStore) + XCTAssertEqual(stack.overlays.map(\.contentDigest), [contentDigest]) + try stack.createWritableOverlay() + _ = try stack.makeAttachment() + } + + private func flatSource(diskFormat: DiskImageFormat = .raw) throws -> VMDirectory { + let vmDir = try temporaryVMDirectory() + let config = VMConfig( + platform: Linux(), + cpuCountMin: 2, + memorySizeMin: 512 * 1024 * 1024, + diskFormat: diskFormat + ) + try config.save(toURL: vmDir.configURL) + XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data())) + switch diskFormat { + case .raw: + _ = try DiskImage(creating: .raw(url: vmDir.diskURL, blockCount: 8)) + case .asif: + _ = try DiskImage(creating: .asif(url: vmDir.diskURL, blockCount: 8, blockSize: .bytes512)) + } + + let diskChunk = OCIManifestLayer( + mediaType: diskV2MediaType, + size: 1, + digest: "sha256:transport", + uncompressedSize: 4096, + uncompressedContentDigest: "sha256:chunk" + ) + let manifest = OCIManifest( + config: OCIManifestConfig(size: 1, digest: "sha256:oci-config"), + layers: [ + OCIManifestLayer(mediaType: configMediaType, size: 1, digest: "sha256:config"), + diskChunk, + OCIManifestLayer(mediaType: nvramMediaType, size: 1, digest: "sha256:nvram"), + ] + ) + try manifest.toJSON().write(to: vmDir.manifestURL) + + return vmDir + } + + private func temporaryContentStore() throws -> ContentStore { + let url = try temporaryDirectory() + return try ContentStore(baseURL: url) + } + + private func temporaryVMDirectory() throws -> VMDirectory { + VMDirectory(baseURL: try temporaryDirectory()) + } + + private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false) + addTeardownBlock { + try? FileManager.default.removeItem(at: url) + } + + return url + } + } +#endif diff --git a/Tests/TartTests/VMDirectoryLayoutTests.swift b/Tests/TartTests/VMDirectoryLayoutTests.swift index 1d00741c..b86eec53 100644 --- a/Tests/TartTests/VMDirectoryLayoutTests.swift +++ b/Tests/TartTests/VMDirectoryLayoutTests.swift @@ -15,6 +15,8 @@ final class VMDirectoryLayoutTests: XCTestCase { XCTAssertFalse(vmDir.isStackedLocal) XCTAssertFalse(vmDir.isStackedOCIRecord) XCTAssertTrue(vmDir.initialized) + XCTAssertTrue(vmDir.isOCIRecord) + XCTAssertNoThrow(try vmDir.validateOCIRecord(userFriendlyName: "standalone")) } func testStackedLocalLayout() throws { @@ -29,6 +31,7 @@ final class VMDirectoryLayoutTests: XCTestCase { XCTAssertTrue(vmDir.isStackedLocal) XCTAssertFalse(vmDir.isStackedOCIRecord) XCTAssertTrue(vmDir.initialized) + XCTAssertFalse(vmDir.isOCIRecord) } func testStackedOCIRecordLayout() throws { @@ -42,6 +45,8 @@ final class VMDirectoryLayoutTests: XCTestCase { XCTAssertFalse(vmDir.isStackedLocal) XCTAssertTrue(vmDir.isStackedOCIRecord) XCTAssertFalse(vmDir.initialized) + XCTAssertTrue(vmDir.isOCIRecord) + XCTAssertNoThrow(try vmDir.validateOCIRecord(userFriendlyName: "stacked")) } func testAmbiguousDiskAndOverlayIsNotInitialized() throws { @@ -57,6 +62,52 @@ final class VMDirectoryLayoutTests: XCTestCase { XCTAssertFalse(vmDir.isStackedLocal) XCTAssertFalse(vmDir.isStackedOCIRecord) XCTAssertFalse(vmDir.initialized) + XCTAssertFalse(vmDir.isOCIRecord) + } + + func testStackedLocalAccountingUsesOverlay() throws { + let vmDir = try temporaryVMDirectory() + + try Data("config".utf8).write(to: vmDir.configURL) + try Data("nvram".utf8).write(to: vmDir.nvramURL) + try Data("overlay".utf8).write(to: vmDir.overlayURL) + try stackedManifest(blockSize: 512, blockCount: 8).toJSON().write(to: vmDir.manifestURL) + + XCTAssertEqual( + try vmDir.sizeBytes(), + try vmDir.configURL.sizeBytes() + vmDir.overlayURL.sizeBytes() + vmDir.nvramURL.sizeBytes() + ) + XCTAssertEqual( + try vmDir.allocatedSizeBytes(), + try vmDir.configURL.allocatedSizeBytes() + vmDir.overlayURL.allocatedSizeBytes() + vmDir.nvramURL.allocatedSizeBytes() + ) + } + + func testStackedExportIsRejected() throws { + let vmDir = try temporaryVMDirectory() + try touch(vmDir.configURL) + try touch(vmDir.nvramURL) + try touch(vmDir.manifestURL) + try touch(vmDir.overlayURL) + let archiveURL = vmDir.baseURL.appendingPathComponent("export.tvm") + + XCTAssertThrowsError(try vmDir.exportToArchive(path: archiveURL.path)) { error in + guard case RuntimeError.ExportFailed(let message) = error else { + return XCTFail("unexpected error: \(error)") + } + XCTAssertEqual(message, "exporting stacked VMs is not supported yet") + } + XCTAssertFalse(FileManager.default.fileExists(atPath: archiveURL.path)) + + try FileManager.default.removeItem(at: vmDir.overlayURL) + XCTAssertTrue(vmDir.isStackedOCIRecord) + XCTAssertThrowsError(try vmDir.exportToArchive(path: archiveURL.path)) { error in + guard case RuntimeError.ExportFailed(let message) = error else { + return XCTFail("unexpected error: \(error)") + } + XCTAssertEqual(message, "exporting stacked VMs is not supported yet") + } + XCTAssertFalse(FileManager.default.fileExists(atPath: archiveURL.path)) } private func temporaryVMDirectory() throws -> VMDirectory { @@ -72,4 +123,30 @@ final class VMDirectoryLayoutTests: XCTestCase { private func touch(_ url: URL) throws { XCTAssertTrue(FileManager.default.createFile(atPath: url.path, contents: Data())) } + + private func stackedManifest(blockSize: UInt64, blockCount: UInt64) -> OCIManifest { + var disk = OCIManifestLayer( + mediaType: diskV2MediaType, + size: 1, + digest: "sha256:transport", + uncompressedSize: blockSize * blockCount, + uncompressedContentDigest: "sha256:chunk" + ) + disk.annotations?[diskFileContentDigestAnnotation] = "sha256:base" + + var manifest = OCIManifest( + config: OCIManifestConfig(size: 1, digest: "sha256:oci-config"), + layers: [ + OCIManifestLayer(mediaType: configMediaType, size: 1, digest: "sha256:config"), + disk, + OCIManifestLayer(mediaType: nvramMediaType, size: 1, digest: "sha256:nvram"), + ] + ) + manifest.annotations = [ + diskBlockSizeAnnotation: String(blockSize), + diskBlockCountAnnotation: String(blockCount), + ] + + return manifest + } } diff --git a/Tests/TartTests/VMStorageOCITests.swift b/Tests/TartTests/VMStorageOCITests.swift new file mode 100644 index 00000000..7f00fdfe --- /dev/null +++ b/Tests/TartTests/VMStorageOCITests.swift @@ -0,0 +1,325 @@ +import Foundation +import XCTest +@testable import tart + +#if canImport(DiskImageKit) + import DiskImageKit +#endif + +final class VMStorageOCITests: XCTestCase { + func testPopulateStandalonePushedImageCachesDiskAndManifest() throws { + try withTemporaryTartHome { + let source = try standaloneSource(diskData: Data("disk".utf8)) + let manifest = try flatManifest() + let name = try digestName(for: manifest) + let storage = try VMStorageOCI() + + try storage.populate(name, from: source, manifest: manifest) + + let cached = try storage.open(name) + XCTAssertTrue(cached.isStandalone) + XCTAssertEqual(try Data(contentsOf: cached.diskURL), Data("disk".utf8)) + XCTAssertEqual(try OCIManifest(fromJSON: Data(contentsOf: cached.manifestURL)), manifest) + } + } + + func testBaseCloneRequiresManifestForLegacyFlatCacheEntry() throws { + try withTemporaryTartHome { + let manifest = try flatManifest() + let name = try digestName(for: manifest) + let storage = try VMStorageOCI() + let record = try storage.create(name) + try config().save(toURL: record.configURL) + XCTAssertTrue(FileManager.default.createFile(atPath: record.nvramURL.path, contents: Data())) + XCTAssertTrue(FileManager.default.createFile(atPath: record.diskURL.path, contents: Data())) + + XCTAssertTrue(try storage.hasUsableCachedRecordForClone(name)) + XCTAssertFalse(try storage.hasUsableCachedRecordForClone(name, requireManifest: true)) + } + } + + func testCloneCacheCheckRejectsMissingOrWrongSizedStackedContent() throws { + try withTemporaryTartHome { + let baseData = Data("base".utf8) + let overlayData = Data("overlay".utf8) + let baseDigest = Digest.hash(baseData) + let overlayDigest = Digest.hash(overlayData) + let manifest = try stackedManifest( + baseContentDigest: baseDigest, + overlayContentDigest: overlayDigest, + baseUncompressedSize: UInt64(baseData.count), + overlayUncompressedSize: UInt64(overlayData.count) + ) + let name = try digestName(for: manifest) + let storage = try VMStorageOCI() + let record = try storage.create(name) + try config().save(toURL: record.configURL) + XCTAssertTrue(FileManager.default.createFile(atPath: record.nvramURL.path, contents: Data())) + try manifest.toJSON().write(to: record.manifestURL) + + XCTAssertFalse(try storage.hasUsableCachedRecordForClone(name)) + + let contentStore = try ContentStore() + try installContent(baseData, contentDigest: baseDigest, into: contentStore) + try installContent(overlayData, contentDigest: overlayDigest, into: contentStore) + XCTAssertTrue(try storage.hasUsableCachedRecordForClone(name)) + + try Data("bad".utf8).write(to: try contentStore.contentURL(for: overlayDigest)) + XCTAssertFalse(try storage.hasUsableCachedRecordForClone(name)) + } + } + + func testListIncludesStackedOCIRecord() throws { + try withTemporaryTartHome { + let manifest = try stackedManifest() + let name = try digestName(for: manifest) + let storage = try VMStorageOCI() + let record = try storage.create(name) + try config().save(toURL: record.configURL) + XCTAssertTrue(FileManager.default.createFile(atPath: record.nvramURL.path, contents: Data())) + try manifest.toJSON().write(to: record.manifestURL) + + XCTAssertTrue(try storage.list().contains { $0.0 == name.description }) + XCTAssertEqual(try record.diskSizeBytes(), 4096) + XCTAssertNoThrow(try record.allocatedSizeBytes()) + } + } + + func testStackedCacheHitRequiresVerifiedContentAndSizesMissingFiles() throws { + try withTemporaryTartHome { + let baseData = Data("base".utf8) + let overlayData = Data("overlay".utf8) + let baseDigest = Digest.hash(baseData) + let overlayDigest = Digest.hash(overlayData) + let manifest = try stackedManifest( + baseContentDigest: baseDigest, + overlayContentDigest: overlayDigest, + baseUncompressedSize: 10, + overlayUncompressedSize: 20 + ) + let name = try digestName(for: manifest) + let storage = try VMStorageOCI() + let record = try storage.create(name) + try config().save(toURL: record.configURL) + XCTAssertTrue(FileManager.default.createFile(atPath: record.nvramURL.path, contents: Data())) + try manifest.toJSON().write(to: record.manifestURL) + + XCTAssertFalse(try storage.hasCompleteCachedRecord(name, manifest: manifest)) + XCTAssertEqual(try storage.requiredDiskStorageBytes(for: manifest), 30) + + let contentStore = try ContentStore() + try installContent(baseData, contentDigest: baseDigest, into: contentStore) + XCTAssertFalse(try storage.hasCompleteCachedRecord(name, manifest: manifest)) + XCTAssertEqual(try storage.requiredDiskStorageBytes(for: manifest), 20) + + try installContent(overlayData, contentDigest: overlayDigest, into: contentStore) + XCTAssertTrue(try storage.hasCompleteCachedRecord(name, manifest: manifest)) + XCTAssertEqual(try storage.requiredDiskStorageBytes(for: manifest), 0) + + let overlayURL = try contentStore.contentURL(for: overlayDigest) + try Data("corrupt".utf8).write(to: overlayURL) + XCTAssertFalse(try storage.hasCompleteCachedRecord(name, manifest: manifest)) + XCTAssertEqual(try storage.requiredDiskStorageBytes(for: manifest), 20) + } + } + + func testFlatLayerCacheIgnoresStackedRecords() async throws { + try await withTemporaryTartHome { + var targetManifest = try flatManifest() + var stackedCandidateManifest = try stackedManifest() + let sharedDiskSize = 2 * 1024 * 1024 * 1024 + targetManifest.layers[1].size = sharedDiskSize + stackedCandidateManifest.layers[1] = targetManifest.layers[1] + + let candidateName = try digestName(for: stackedCandidateManifest) + let storage = try VMStorageOCI() + let candidate = try storage.create(candidateName) + try config().save(toURL: candidate.configURL) + XCTAssertTrue(FileManager.default.createFile(atPath: candidate.nvramURL.path, contents: Data())) + try stackedCandidateManifest.toJSON().write(to: candidate.manifestURL) + + let targetName = RemoteName( + host: "example.com", + namespace: "org/target", + reference: Reference(digest: try targetManifest.digest()) + ) + let registry = try Registry(host: targetName.host, namespace: targetName.namespace) + + let layerCache = try await storage.chooseLocalLayerCache(targetName, targetManifest, registry) + XCTAssertNil(layerCache) + } + } + + #if canImport(DiskImageKit) + @available(macOS 27.0, *) + func testPopulateStackedPushedImageCachesImmutableTopOverlay() throws { + if #unavailable(macOS 27.0) { + throw XCTSkip("DiskImageKit tests require macOS 27 or newer") + } + + try withTemporaryTartHome { + let source = try diskImageSource() + let stacked = try temporaryVMDirectory() + try source.cloneAsStackedBase(to: stacked, generateMAC: false) + + var manifest = try OCIManifest(fromJSON: Data(contentsOf: stacked.manifestURL)) + let contentDigest = try Digest.hash(stacked.overlayURL) + var overlay = OCIManifestLayer( + mediaType: asifOverlayMediaType, + size: 1, + digest: "sha256:overlay-transport", + uncompressedSize: 1, + uncompressedContentDigest: "sha256:overlay-chunk" + ) + overlay.annotations?[diskFileContentDigestAnnotation] = contentDigest + overlay.annotations?[diskFileChunkCountAnnotation] = "1" + manifest.layers.insert(overlay, at: manifest.layers.count - 1) + + let name = try digestName(for: manifest) + let storage = try VMStorageOCI() + try storage.populate(name, from: stacked, manifest: manifest) + + let cached = try storage.open(name) + XCTAssertTrue(cached.isStackedOCIRecord) + XCTAssertFalse(FileManager.default.fileExists(atPath: cached.overlayURL.path)) + XCTAssertEqual(try OCIManifest(fromJSON: Data(contentsOf: cached.manifestURL)), manifest) + + let cachedContent = try XCTUnwrap(try ContentStore().existingContentURL(for: contentDigest)) + XCTAssertEqual(try Digest.hash(cachedContent), contentDigest) + } + } + #endif + + private func standaloneSource(diskData: Data) throws -> VMDirectory { + let vmDir = try temporaryVMDirectory() + try config().save(toURL: vmDir.configURL) + XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data())) + try diskData.write(to: vmDir.diskURL) + + return vmDir + } + + #if canImport(DiskImageKit) + @available(macOS 27.0, *) + private func diskImageSource() throws -> VMDirectory { + let vmDir = try temporaryVMDirectory() + try config().save(toURL: vmDir.configURL) + XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data())) + _ = try DiskImage(creating: .raw(url: vmDir.diskURL, blockCount: 8)) + try flatManifest().toJSON().write(to: vmDir.manifestURL) + + return vmDir + } + #endif + + private func config() -> VMConfig { + VMConfig( + platform: Linux(), + cpuCountMin: 2, + memorySizeMin: 512 * 1024 * 1024, + diskFormat: .raw + ) + } + + private func flatManifest() throws -> OCIManifest { + let disk = OCIManifestLayer( + mediaType: diskV2MediaType, + size: 1, + digest: "sha256:disk-transport", + uncompressedSize: 1, + uncompressedContentDigest: "sha256:disk-chunk" + ) + + return OCIManifest( + config: OCIManifestConfig(size: 1, digest: "sha256:oci-config"), + layers: [ + OCIManifestLayer(mediaType: configMediaType, size: 1, digest: "sha256:config"), + disk, + OCIManifestLayer(mediaType: nvramMediaType, size: 1, digest: "sha256:nvram"), + ] + ) + } + + private func stackedManifest( + baseContentDigest: String = "sha256:base", + overlayContentDigest: String = "sha256:overlay", + baseUncompressedSize: UInt64 = 1, + overlayUncompressedSize: UInt64 = 1 + ) throws -> OCIManifest { + var manifest = try flatManifest() + manifest.annotations?[diskBlockSizeAnnotation] = "512" + manifest.annotations?[diskBlockCountAnnotation] = "8" + manifest.layers[1].annotations?[diskFileContentDigestAnnotation] = baseContentDigest + manifest.layers[1].annotations?[uncompressedSizeAnnotation] = String(baseUncompressedSize) + var overlay = OCIManifestLayer( + mediaType: asifOverlayMediaType, + size: 1, + digest: "sha256:overlay-transport", + uncompressedSize: overlayUncompressedSize, + uncompressedContentDigest: "sha256:overlay-chunk" + ) + overlay.annotations?[diskFileContentDigestAnnotation] = overlayContentDigest + overlay.annotations?[diskFileChunkCountAnnotation] = "1" + manifest.layers.insert(overlay, at: manifest.layers.count - 1) + + return manifest + } + + private func installContent(_ data: Data, contentDigest: String, into contentStore: ContentStore) throws { + let temporaryURL = try contentStore.temporaryContentURL(for: contentDigest) + try data.write(to: temporaryURL) + _ = try contentStore.install(temporaryURL, contentDigest: contentDigest) + } + + private func digestName(for manifest: OCIManifest) throws -> RemoteName { + RemoteName( + host: "example.com", + namespace: "org/image", + reference: Reference(digest: try manifest.digest()) + ) + } + + private func withTemporaryTartHome(_ body: () throws -> Void) throws { + let home = try temporaryDirectory() + let previousHome = ProcessInfo.processInfo.environment["TART_HOME"] + setenv("TART_HOME", home.path, 1) + defer { + if let previousHome { + setenv("TART_HOME", previousHome, 1) + } else { + unsetenv("TART_HOME") + } + } + + try body() + } + + private func withTemporaryTartHome(_ body: () async throws -> Void) async throws { + let home = try temporaryDirectory() + let previousHome = ProcessInfo.processInfo.environment["TART_HOME"] + setenv("TART_HOME", home.path, 1) + defer { + if let previousHome { + setenv("TART_HOME", previousHome, 1) + } else { + unsetenv("TART_HOME") + } + } + + try await body() + } + + private func temporaryVMDirectory() throws -> VMDirectory { + VMDirectory(baseURL: try temporaryDirectory()) + } + + private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false) + addTeardownBlock { + try? FileManager.default.removeItem(at: url) + } + + return url + } +}