Compare commits

..

No commits in common. "main" and "2.35.0" have entirely different histories.
main ... 2.35.0

41 changed files with 182 additions and 5024 deletions

View File

@ -31,9 +31,6 @@ 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 stacked: Bool = false
@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
var pruneLimit: UInt = 100
@ -50,43 +47,14 @@ struct Clone: AsyncParsableCommand {
func run() async throws {
let ociStorage = try VMStorageOCI()
let localStorage = try VMStorageLocal()
let remoteName = try? RemoteName(sourceName)
if stacked {
guard remoteName != nil else {
throw ValidationError("--stacked requires a remote image")
}
try DiskImageStack.requireSupport()
}
if let remoteName, try !ociStorage.hasUsableCachedImageForClone(remoteName, requireManifest: stacked) {
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
// 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)
var resolvedManifest: (manifest: OCIManifest, data: Data)?
// Fail before pulling disk content when this host cannot create a writable stacked disk.
if !stacked {
let (manifest, manifestData) = try await registry.pullManifest(reference: remoteName.reference.value)
if manifest.layers.contains(where: { $0.mediaType == asifOverlayMediaType }) {
try DiskImageStack.requireSupport()
}
resolvedManifest = (manifest, manifestData)
}
try await ociStorage.pull(
remoteName,
registry: registry,
concurrency: concurrency,
deduplicate: deduplicate,
requireManifest: stacked,
resolvedManifest: resolvedManifest
)
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
}
let sourceVM = try VMStorageHelper.open(sourceName)
if sourceVM.isStackedVM || sourceVM.isStackedCachedImage {
try DiskImageStack.requireSupport()
}
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
@ -98,28 +66,9 @@ 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())
&& sourceState != .Suspended
if stacked {
guard sourceVM.isStandalone else {
throw ValidationError("--stacked cannot use an image that already has a stacked disk")
}
guard try VMConfig(fromURL: sourceVM.configURL).os == .darwin else {
throw ValidationError("--stacked currently supports only macOS images")
}
try sourceVM.cloneAsStackedBase(to: tmpVMDir, generateMAC: generateMAC)
} else if sourceVM.isStackedCachedImage {
try sourceVM.cloneStacked(to: tmpVMDir, copyWritableOverlay: false, generateMAC: generateMAC)
} else if sourceVM.isStackedVM {
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)
}
&& sourceVM.state() != .Suspended
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
try localStorage.move(newName, from: tmpVMDir)
@ -129,26 +78,14 @@ 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.
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.isStackedVM || sourceVM.isStackedCachedImage {
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 block layout 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)
}
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)
}
}, onCancel: {
try? tmpVMDir.removeFromDisk()
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
}
}

View File

@ -5,9 +5,9 @@ fileprivate struct VMInfo: Encodable {
let OS: OS
let CPU: Int
let Memory: UInt64
let Disk: HumanReadableByteCount
let Disk: Int
let DiskFormat: String
let Size: HumanReadableByteCount
let Size: String
let Display: String
let Running: Bool
let State: String
@ -27,19 +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: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
DiskFormat: vmConfig.diskFormat.rawValue,
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) {
String(format: "%.3f", Float($0) / 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.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)
print(format.renderSingle(info))
}
}

View File

@ -21,9 +21,6 @@ struct Import: AsyncParsableCommand {
// Create a temporary VM directory to which we will load the export file
let tmpVMDir = try VMDirectory.temporary()
defer {
try? tmpVMDir.removeFromDisk()
}
// Lock the temporary VM directory to prevent it's garbage collection
// while we're running
@ -33,9 +30,6 @@ struct Import: AsyncParsableCommand {
// Populate the temporary VM directory with the export file contents
print("importing...")
try tmpVMDir.importFromArchive(path: path)
guard tmpVMDir.initialized else {
throw RuntimeError.ImportFailed("archive does not contain a runnable VM")
}
try await withTaskCancellationHandler(operation: {
// Acquire a global lock
@ -51,7 +45,7 @@ struct Import: AsyncParsableCommand {
try lock.unlock()
}, onCancel: {
try? tmpVMDir.removeFromDisk()
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
}
}

View File

@ -5,8 +5,8 @@ import SwiftUI
fileprivate struct VMInfo: Encodable {
let Source: String
let Name: String
let Disk: HumanReadableByteCount
let Size: HumanReadableByteCount
let Disk: Int
let Size: Int
let Accessed: String
let Running: Bool
let State: String
@ -42,8 +42,8 @@ struct List: AsyncParsableCommand {
try VMInfo(
Source: "local",
Name: name,
Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
Disk: vmDir.sizeGB(),
Size: vmDir.allocatedSizeGB(),
Accessed: formatAccessDate(try vmDir.accessDate()),
Running: vmDir.running(),
State: vmDir.state().rawValue
@ -56,8 +56,8 @@ struct List: AsyncParsableCommand {
try VMInfo(
Source: "OCI",
Name: name,
Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
Disk: vmDir.sizeGB(),
Size: vmDir.allocatedSizeGB(),
Accessed: formatAccessDate(try vmDir.accessDate()),
Running: vmDir.running(),
State: vmDir.state().rawValue

View File

@ -81,34 +81,27 @@ struct Prune: AsyncParsableCommand {
}
static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
while true {
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() }
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() }
var remainingBudgetBytes = spaceBudgetBytes
var prunableToDelete: Prunable?
var spaceBudgetBytes = spaceBudgetBytes
var prunablesToDelete: [Prunable] = []
for prunable in prunables {
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
for prunable in prunables {
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
if prunableSizeBytes <= remainingBudgetBytes {
// Don't mark for deletion as there is budget available
remainingBudgetBytes -= prunableSizeBytes
} else {
prunableToDelete = prunable
break
}
if prunableSizeBytes <= spaceBudgetBytes {
// Don't mark for deletion as
// there's a budget available
spaceBudgetBytes -= prunableSizeBytes
} else {
// Mark for deletion
prunablesToDelete.append(prunable)
}
guard let prunableToDelete else {
return
}
// Deleting one cached stacked image can change which remaining image
// owns shared immutable content. Rebuild before choosing another.
try prunableToDelete.delete()
}
try prunablesToDelete.forEach { try $0.delete() }
}
static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {
@ -152,51 +145,46 @@ struct Prune: AsyncParsableCommand {
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated, initiator)
}
static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
let span = OTel.shared.tracer.spanBuilder(spanName: "prune").startSpan()
defer { span.end() }
let prunableStorages: [PrunableStorage] = [try VMStorageOCI(), try IPSWCache()]
let prunables = {
try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() }
}
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() }
// Does it even make sense to start?
let initialPrunables = try prunables()
let initialCacheUsedBytes = try initialPrunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
guard let reclaimBytes = Int(exactly: reclaimBytes), initialCacheUsedBytes >= reclaimBytes else {
let cacheUsedBytes = try prunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
if cacheUsedBytes < reclaimBytes {
return
}
let targetCacheUsedBytes = initialCacheUsedBytes - reclaimBytes
var currentCacheUsedBytes = initialCacheUsedBytes
let initiatorPath = initiator.map {
$0.url.resolvingSymlinksInPath().standardizedFileURL.path
}
var cacheReclaimedBytes: Int = 0
while currentCacheUsedBytes > targetCacheUsedBytes {
// Deleting one cached stacked image can transfer ownership of shared
// immutable content to another record without reclaiming those bytes.
// Rebuild the candidates after every deletion so automatic pruning
// measures the cache that remains rather than a stale ownership snapshot.
guard let prunable = try prunables().first(where: {
$0.url.resolvingSymlinksInPath().standardizedFileURL.path != initiatorPath
}) else {
var it = prunables.makeIterator()
while cacheReclaimedBytes <= reclaimBytes {
guard let prunable = it.next() else {
break
}
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
// do not prune the initiator
continue
}
let allocatedSizeBytes = try prunable.allocatedSizeBytes()
OpenTelemetry.instance.contextProvider.activeSpan?
.addEvent(name: "Pruned \(allocatedSizeBytes) bytes for \(prunable.url.path)")
cacheReclaimedBytes += allocatedSizeBytes
try prunable.delete()
currentCacheUsedBytes = try prunables().map { try $0.allocatedSizeBytes() }.reduce(0, +)
}
OpenTelemetry.instance.contextProvider.activeSpan?
.addEvent(name: "Reclaimed \(initialCacheUsedBytes - currentCacheUsedBytes) bytes")
.addEvent(name: "Reclaimed \(cacheReclaimedBytes) bytes")
}
}

View File

@ -69,7 +69,7 @@ struct Push: AsyncParsableCommand {
let references = remoteNamesForRegistry.map{ $0.reference.value }
let pushedRemoteName: RemoteName
// If we're pushing a cached remote image, check if it points to an existing registry manifest
// If we're pushing a local OCI VM, check if points to an already existing registry manifest
// and if so, only upload manifests (without config, disk and NVRAM) to the user-specified references
if let remoteName = try? RemoteName(localName) {
pushedRemoteName = try await lightweightPushToRegistry(
@ -78,18 +78,17 @@ struct Push: AsyncParsableCommand {
references: references
)
} else {
let pushedImage = try await localVMDir.pushToRegistry(
pushedRemoteName = 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 {
try ociStorage.populate(pushedImage.name, from: localVMDir, manifest: pushedImage.manifest)
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
}
}
@ -103,7 +102,7 @@ struct Push: AsyncParsableCommand {
}
func lightweightPushToRegistry(registry: Registry, remoteName: RemoteName, references: [String]) async throws -> RemoteName {
// Is the cached remote image already present in the registry?
// Is the local OCI VM already present in the registry?
let digest = try VMStorageOCI().digest(remoteName)
let (remoteManifest, _) = try await registry.pullManifest(reference: digest)

View File

@ -455,15 +455,10 @@ struct Run: AsyncParsableCommand {
let provisioning = try provisioningOpts.map { try GuestProvisioningOptions($0) }
#endif
// Keep these values alive while the VM runs. Some additional disks own a
// lock that protects their temporary backing files from Config.gc().
let additionalDisks = try additionalDisks()
defer { withExtendedLifetime(additionalDisks) {} }
vm = try VM(
vmDir: vmDir,
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
additionalStorageDevices: additionalDisks.map(\.configuration),
additionalStorageDevices: try additionalDiskAttachments(),
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
serialPorts: serialPorts,
suspendable: suspendable,
@ -570,10 +565,8 @@ struct Run: AsyncParsableCommand {
}
if #available(macOS 14, *) {
let controlSocket = try await ControlSocket(vmDir.controlSocketURL)
ErrorReportingTask("Failed to run control socket") {
try await controlSocket.run()
try await ControlSocket(vmDir.controlSocketURL).run()
}
}
@ -734,9 +727,9 @@ struct Run: AsyncParsableCommand {
}
}
func additionalDisks() throws -> [AdditionalDisk] {
func additionalDiskAttachments() throws -> [VZStorageDeviceConfiguration] {
try disk.map {
try AdditionalDisk(parseFrom: $0)
try AdditionalDisk(parseFrom: $0).configuration
}
}
@ -959,32 +952,14 @@ struct VMView: NSViewRepresentable {
struct AdditionalDisk {
let configuration: VZStorageDeviceConfiguration
// Retained for as long as the additional disk is attached, so Config.gc()
// cannot remove a temporary backing file or stacked-disk directory.
private let temporaryDiskLock: FileLock?
init(parseFrom: String) throws {
let (diskPath, readOnly, syncModeRaw, cachingModeRaw) = Self.parseOptions(parseFrom)
self = try Self.craft(
diskPath,
readOnly: readOnly,
syncModeRaw: syncModeRaw,
cachingModeRaw: cachingModeRaw
)
self.configuration = try Self.craft(diskPath, readOnly: readOnly, syncModeRaw: syncModeRaw, cachingModeRaw: cachingModeRaw)
}
private init(configuration: VZStorageDeviceConfiguration, temporaryDiskLock: FileLock? = nil) {
self.configuration = configuration
self.temporaryDiskLock = temporaryDiskLock
}
private static func craft(
_ diskPath: String,
readOnly diskReadOnly: Bool,
syncModeRaw: String,
cachingModeRaw: String
) throws -> AdditionalDisk {
static func craft(_ diskPath: String, readOnly diskReadOnly: Bool, syncModeRaw: String, cachingModeRaw: String) throws -> VZStorageDeviceConfiguration {
let diskURL = URL(string: diskPath)
if (["nbd", "nbds", "nbd+unix", "nbds+unix"].contains(diskURL?.scheme)) {
@ -999,7 +974,7 @@ struct AdditionalDisk {
synchronizationMode: try VZDiskSynchronizationMode(syncModeRaw)
)
return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment))
return VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment)
}
// Expand the tilde (~) since at this point we're dealing with a local path,
@ -1030,37 +1005,13 @@ struct AdditionalDisk {
let blockAttachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: FileHandle(fileDescriptor: fd, closeOnDealloc: true),
readOnly: diskReadOnly, synchronizationMode: try VZDiskSynchronizationMode(syncModeRaw))
return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: blockAttachment))
return VZVirtioBlockDeviceConfiguration(attachment: blockAttachment)
}
// Support remote VM names in --disk command-line argument
if let remoteName = try? RemoteName(diskPath) {
let vmDir = try VMStorageOCI().open(remoteName)
if vmDir.isStackedCachedImage {
// A cached stacked image has no writable top overlay. Create one in a
// disposable directory for this additional-disk attachment.
let temporaryVMDir = try VMDirectory.temporary()
let temporaryVMDirLock = try FileLock(lockURL: temporaryVMDir.baseURL)
try temporaryVMDirLock.lock()
try vmDir.cloneStacked(
to: temporaryVMDir,
copyWritableOverlay: false,
generateMAC: false
)
let stack = try temporaryVMDir.diskImageStack()
let attachment = try stack.makeAttachment(
readOnly: diskReadOnly,
cachingMode: try VZDiskImageCachingMode(cachingModeRaw) ?? .automatic,
synchronizationMode: try VZDiskImageSynchronizationMode(syncModeRaw)
)
return AdditionalDisk(
configuration: VZVirtioBlockDeviceConfiguration(attachment: attachment),
temporaryDiskLock: temporaryVMDirLock
)
}
// 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.
@ -1073,7 +1024,7 @@ struct AdditionalDisk {
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(url: clonedDiskURL, readOnly: diskReadOnly)
return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment), temporaryDiskLock: lock)
return VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment)
}
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
@ -1089,7 +1040,7 @@ struct AdditionalDisk {
synchronizationMode: try VZDiskImageSynchronizationMode(syncModeRaw)
)
return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment))
return VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment)
}
static func parseOptions(_ parseFrom: String) -> (String, Bool, String, String) {

View File

@ -39,14 +39,6 @@ struct Set: AsyncParsableCommand {
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
// Replacing disk.img would leave a stacked VM with both disk.img and
// overlay.asif, which is not a supported local layout. Reject before
// saving any other requested configuration changes.
if disk != nil, vmDir.isStackedVM {
throw ValidationError("--disk is not supported for VMs with a stacked disk")
}
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
if let cpu = cpu {

View File

@ -33,7 +33,7 @@ struct Config {
continue
}
try VMDirectory(baseURL: entry).removeFromDisk()
try FileManager.default.removeItem(at: entry)
try lock.unlock()
}

View File

@ -1,192 +0,0 @@
import Foundation
enum ContentStoreError: Error, Equatable {
case invalidContentDigest(String)
case contentDigestMismatch(expected: String, actual: String)
}
/// Opaque content-addressed storage for immutable reconstructed files.
///
/// Stacked disks currently use it for complete base disks and published ASIF
/// overlays reconstructed from Tart disk chunks. OCI blob digests may differ
/// across registries, so the key is the full reconstructed-file digest.
struct ContentStore {
private static let digestAlgorithm = "sha256"
private static let digestPrefix = "\(digestAlgorithm):"
let baseURL: URL
private let digestDirectoryURL: URL
private let pruneLockURL: URL
init() throws {
try self.init(baseURL: Config().tartCacheDir.appendingPathComponent("content", isDirectory: true))
}
init(baseURL: URL) throws {
self.baseURL = baseURL
self.digestDirectoryURL = baseURL.appendingPathComponent(Self.digestAlgorithm, isDirectory: true)
self.pruneLockURL = baseURL.appendingPathComponent(".gc.lock")
try FileManager.default.createDirectory(at: digestDirectoryURL, withIntermediateDirectories: true)
if !FileManager.default.fileExists(atPath: pruneLockURL.path) {
_ = FileManager.default.createFile(atPath: pruneLockURL.path, contents: Data())
}
}
/// Serializes reference publication with the final reference check and
/// deletion of immutable cache entries across Tart processes.
func withPruneLock<T>(_ body: () throws -> T) throws -> T {
let lock = try FileLock(lockURL: pruneLockURL)
try lock.lock()
defer { try? lock.unlock() }
return try body()
}
/// Waits for any prune already scanning references to finish. After this
/// returns, later prune runs can see a reference the caller already wrote.
func synchronizePublishedReferences() throws {
try withPruneLock {}
}
func contentURL(for contentDigest: String) throws -> URL {
try contentURL(for: contentDigest, under: baseURL)
}
/// Returns the canonical path for a digest under an arbitrary content-store
/// root without creating directories or lock files.
func contentURL(for contentDigest: String, under baseURL: URL) throws -> URL {
let digestHex = try validatedDigestHex(contentDigest)
return baseURL
.appendingPathComponent(Self.digestAlgorithm, isDirectory: true)
.appendingPathComponent(digestHex)
}
func temporaryContentURL(for contentDigest: String) throws -> URL {
let targetURL = try contentURL(for: contentDigest)
return targetURL.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).tmp")
}
/// Returns a stable staging path so an interrupted registry pull can resume
/// reconstructing this content entry on a later attempt.
func resumableContentURL(for contentDigest: String) throws -> URL {
let targetURL = try contentURL(for: contentDigest)
return targetURL.deletingLastPathComponent().appendingPathComponent(".\(targetURL.lastPathComponent).partial")
}
/// Returns a stable lock file for serializing reconstruction of one content
/// entry. The file is intentionally retained; flock state lives on the file
/// descriptor and disappears when the owning process exits.
func lockURL(for contentDigest: String) throws -> URL {
let targetURL = try contentURL(for: contentDigest)
let lockURL = targetURL.deletingLastPathComponent().appendingPathComponent(".\(targetURL.lastPathComponent).lock")
if !FileManager.default.fileExists(atPath: lockURL.path) {
_ = FileManager.default.createFile(atPath: lockURL.path, contents: nil)
}
return lockURL
}
/// Returns an immutable digest-addressed entry without rereading it. Files
/// are verified when installed and when deciding whether a pull is a cache
/// hit; normal clone/run/push paths trust the store like Tart's disk.img.
func contentURLIfPresent(for contentDigest: String) throws -> URL? {
let url = try contentURL(for: contentDigest)
guard FileManager.default.fileExists(atPath: url.path) else {
return nil
}
try url.updateAccessDate()
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
}
return url
}
/// Returns immutable content files that no retained cached image or local VM
/// references. Callers may prune these like other cache entries.
func prunables(excluding referencedContentDigests: Swift.Set<String>) throws -> [URL] {
guard let enumerator = FileManager.default.enumerator(
at: digestDirectoryURL,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsSubdirectoryDescendants]
) else {
return []
}
return try enumerator.compactMap { element in
guard let url = element as? URL,
try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else {
return nil
}
let contentDigest = "\(Self.digestPrefix)\(url.lastPathComponent)"
guard (try? validatedDigestHex(contentDigest)) != nil,
!referencedContentDigests.contains(contentDigest) else {
return nil
}
return url
}
}
/// Move a fully reconstructed temporary file into the cache after verifying
/// its semantic identity. The caller should create the temporary file with
/// temporaryContentURL(for:) or resumableContentURL(for:) so rename stays on
/// the same filesystem.
func install(_ temporaryURL: URL, contentDigest: String) throws -> URL {
let actualDigest = try Digest.hash(temporaryURL)
guard actualDigest == contentDigest else {
throw ContentStoreError.contentDigestMismatch(expected: contentDigest, actual: actualDigest)
}
let targetURL = try contentURL(for: contentDigest)
let lock = try FileLock(lockURL: baseURL)
try lock.lock()
defer { try? lock.unlock() }
if let existingURL = try existingContentURL(for: contentDigest) {
try? FileManager.default.removeItem(at: temporaryURL)
return existingURL
}
if FileManager.default.fileExists(atPath: targetURL.path) {
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: temporaryURL)
} else {
try FileManager.default.moveItem(at: temporaryURL, to: targetURL)
}
return targetURL
}
private func validatedDigestHex(_ contentDigest: String) throws -> String {
guard contentDigest.hasPrefix(Self.digestPrefix) else {
throw ContentStoreError.invalidContentDigest(contentDigest)
}
let digestHex = String(contentDigest.dropFirst(Self.digestPrefix.count))
let isHex = digestHex.allSatisfy { $0.isHexDigit && !$0.isUppercase }
guard digestHex.count == 64, isHex else {
throw ContentStoreError.invalidContentDigest(contentDigest)
}
return digestHex
}
}

View File

@ -6,20 +6,17 @@ import NIOPosix
@available(macOS 14, *)
class ControlSocket {
typealias ServerChannel = NIOAsyncChannel<NIOAsyncChannel<ByteBuffer, ByteBuffer>, Never>
let controlSocketURL: URL
let vmPort: UInt32
let eventLoopGroup: MultiThreadedEventLoopGroup
let serverChannel: ServerChannel
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let logger: os.Logger = os.Logger(subsystem: "org.cirruslabs.tart.control-socket", category: "network")
init(_ controlSocketURL: URL, vmPort: UInt32 = 8080) async throws {
init(_ controlSocketURL: URL, vmPort: UInt32 = 8080) {
self.controlSocketURL = controlSocketURL
self.vmPort = vmPort
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
self.eventLoopGroup = eventLoopGroup
}
func run() async throws {
// Remove control socket file from previous "tart run" invocations,
// if any, otherwise we may get the "address already in use" error
try? FileManager.default.removeItem(atPath: controlSocketURL.path())
@ -32,22 +29,15 @@ class ControlSocket {
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
}
do {
self.serverChannel = try await ServerBootstrap(group: eventLoopGroup)
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
childChannel.eventLoop.makeCompletedFuture {
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
wrappingChannelSynchronously: childChannel
)
}
let serverChannel = try await ServerBootstrap(group: eventLoopGroup)
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
childChannel.eventLoop.makeCompletedFuture {
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
wrappingChannelSynchronously: childChannel
)
}
} catch {
try? await eventLoopGroup.shutdownGracefully()
throw error
}
}
}
func run() async throws {
try await withThrowingDiscardingTaskGroup { group in
try await serverChannel.executeThenClose { serverInbound in
for try await clientChannel in serverInbound {

View File

@ -1,304 +0,0 @@
import Foundation
import Virtualization
#if canImport(DiskImageKit)
import DiskImageKit
#endif
/// The logical block layout exposed by a disk image.
struct DiskImageBlockLayout {
let blockSize: UInt64
let blockCount: UInt64
}
enum DiskImageStackError: Error, Equatable, CustomStringConvertible {
case unavailable
case writableOverlayAlreadyExists(URL)
case writableOverlayMissing(URL)
case invalidBlockLayout(String)
case invalidDiskImage(URL, String)
var description: String {
switch self {
case .unavailable:
"stacked disks require DiskImageKit on macOS 27 or newer"
case .writableOverlayAlreadyExists(let url):
"writable overlay already exists: \(url.path)"
case .writableOverlayMissing(let url):
"writable overlay is missing: \(url.path)"
case .invalidBlockLayout(let reason):
reason
case .invalidDiskImage(let url, let reason):
"\(reason): \(url.path)"
}
}
}
struct DiskImageStack {
/// DiskImageKit-ready paths and block layout after Tart disk chunks have been
/// reconstructed into complete immutable files. The writable overlay stays
/// private to one VM.
let baseURL: URL
let baseFormat: DiskImageFormat
let immutableOverlayURLs: [URL]
let writableOverlayURL: URL
let blockSize: UInt64
let blockCount: UInt64
static var isSupported: Bool {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
return true
}
#endif
return false
}
static func requireSupport() throws {
guard isSupported else {
throw DiskImageStackError.unavailable
}
}
/// Reads a disk image's current block layout 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 diskImageBlockLayout(at url: URL) throws -> DiskImageBlockLayout {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
let image = try DiskImage(opening: .open(url: url, mode: .readOnly))
return DiskImageBlockLayout(
blockSize: UInt64(image.blockSize.rawValue),
blockCount: UInt64(image.blockCount)
)
}
#endif
throw DiskImageStackError.unavailable
}
static func baseBlockLayout(
at url: URL,
expectedFormat: DiskImageFormat
) throws -> DiskImageBlockLayout {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
let image = try DiskImage(opening: .open(url: url, mode: .readOnly))
try validateBase(image, at: url, expectedFormat: expectedFormat)
return DiskImageBlockLayout(
blockSize: UInt64(image.blockSize.rawValue),
blockCount: UInt64(image.blockCount)
)
}
#endif
throw DiskImageStackError.unavailable
}
func createWritableOverlay() throws {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
try createWritableOverlayWithDiskImageKit()
return
}
#endif
throw DiskImageStackError.unavailable
}
func copyWritableOverlay(to destinationURL: URL) throws {
guard !FileManager.default.fileExists(atPath: destinationURL.path) else {
throw DiskImageStackError.writableOverlayAlreadyExists(destinationURL)
}
try FileManager.default.copyItem(at: writableOverlayURL, to: destinationURL)
}
func makeAttachment(
readOnly: Bool = false,
cachingMode: VZDiskImageCachingMode = .automatic,
synchronizationMode: VZDiskImageSynchronizationMode = .full
) throws -> VZStorageDeviceAttachment {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
return try attachmentWithDiskImageKit(
readOnly: readOnly,
cachingMode: cachingMode,
synchronizationMode: synchronizationMode
)
}
#endif
throw DiskImageStackError.unavailable
}
func growWritableOverlay(toBlockCount blockCount: UInt64) throws {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
try growWritableOverlayWithDiskImageKit(toBlockCount: blockCount)
return
}
#endif
throw DiskImageStackError.unavailable
}
#if canImport(DiskImageKit)
@available(macOS 27.0, *)
private func createWritableOverlayWithDiskImageKit() throws {
guard !FileManager.default.fileExists(atPath: writableOverlayURL.path) else {
throw DiskImageStackError.writableOverlayAlreadyExists(writableOverlayURL)
}
let parent = try validatedParentImage()
let stackedImage = try parent.appending(.asifLayer(url: writableOverlayURL, type: .overlay))
try validateAppendedOverlay(stackedImage, at: writableOverlayURL)
}
@available(macOS 27.0, *)
private func attachmentWithDiskImageKit(
readOnly: Bool,
cachingMode: VZDiskImageCachingMode,
synchronizationMode: VZDiskImageSynchronizationMode
) throws -> VZDiskImageStorageDeviceAttachment {
guard FileManager.default.fileExists(atPath: writableOverlayURL.path) else {
throw DiskImageStackError.writableOverlayMissing(writableOverlayURL)
}
let parent = try validatedParentImage()
let writableOverlay = try openOverlay(
at: writableOverlayURL,
mode: readOnly ? .readOnly : .readWrite
)
let stackedImage = try append(writableOverlay, to: parent, at: writableOverlayURL)
try validateAppendedOverlay(stackedImage, at: writableOverlayURL)
return try VZDiskImageStorageDeviceAttachment(
diskImage: stackedImage,
cachingMode: cachingMode,
synchronizationMode: synchronizationMode
)
}
@available(macOS 27.0, *)
private func growWritableOverlayWithDiskImageKit(toBlockCount blockCount: UInt64) throws {
guard blockCount > 0, let desiredBlockCount = Int(exactly: blockCount) else {
throw DiskImageStackError.invalidBlockLayout("invalid stacked disk block count \(blockCount)")
}
let parent = try validatedParentImage()
let overlay = try openOverlay(
at: writableOverlayURL,
mode: .readWrite
)
let currentBlockCount = overlay.blockCount
let stackedImage = try append(overlay, to: parent, at: writableOverlayURL)
try validateAppendedOverlay(stackedImage, at: writableOverlayURL)
guard desiredBlockCount >= currentBlockCount else {
throw DiskImageStackError.invalidDiskImage(writableOverlayURL, "ASIF overlay block count shrinks the stacked disk")
}
guard let writableOverlay = stackedImage.layers.last else {
throw DiskImageStackError.invalidDiskImage(writableOverlayURL, "disk image must be an ASIF overlay")
}
if desiredBlockCount > currentBlockCount {
try writableOverlay.truncate(blockCount: desiredBlockCount)
}
}
@available(macOS 27.0, *)
private func validatedParentImage() throws -> DiskImage {
let expectedBlockSize = try diskImageBlockSize(blockSize)
guard blockCount > 0, let expectedBlockCount = Int(exactly: blockCount) else {
throw DiskImageStackError.invalidBlockLayout("invalid stacked disk block count \(blockCount)")
}
let baseImage = try DiskImage(opening: .open(url: baseURL, mode: .readOnly))
try Self.validateBase(baseImage, at: baseURL, expectedFormat: baseFormat)
var image = baseImage
for overlayURL in immutableOverlayURLs {
let openedOverlay = try openOverlay(
at: overlayURL,
mode: .readOnly
)
let stackedImage = try append(openedOverlay, to: image, at: overlayURL)
try validateAppendedOverlay(stackedImage, at: overlayURL)
image = stackedImage
}
guard image.blockSize == expectedBlockSize else {
throw DiskImageStackError.invalidBlockLayout("immutable disk stack does not match manifest block size")
}
guard image.blockCount == expectedBlockCount else {
throw DiskImageStackError.invalidBlockLayout("immutable disk stack does not match manifest block count")
}
return image
}
@available(macOS 27.0, *)
private static func validateBase(
_ image: DiskImage,
at url: URL,
expectedFormat: DiskImageFormat
) throws {
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")
}
}
@available(macOS 27.0, *)
private func openOverlay(
at url: URL,
mode: OpenConfiguration.Mode
) throws -> DiskImage {
let image = try DiskImage(opening: .open(url: url, mode: mode))
guard image.format == .asif else {
throw DiskImageStackError.invalidDiskImage(url, "overlay must use ASIF format")
}
return image
}
@available(macOS 27.0, *)
private func append(_ overlay: DiskImage, to parent: DiskImage, at url: URL) throws -> any StackedImage {
do {
return try parent.appending(overlay)
} catch is IncompatibleStackingError {
throw DiskImageStackError.invalidDiskImage(url, "ASIF overlay is incompatible with its parent")
}
}
@available(macOS 27.0, *)
private func validateAppendedOverlay(_ image: any StackedImage, at url: URL) throws {
guard image.layers.last?.layerType == .overlay else {
throw DiskImageStackError.invalidDiskImage(url, "disk image must be an ASIF overlay")
}
}
@available(macOS 27.0, *)
private func diskImageBlockSize(_ value: UInt64) throws -> DiskImage.BlockSize {
guard let intValue = Int(exactly: value), let blockSize = DiskImage.BlockSize(rawValue: intValue) else {
throw DiskImageStackError.invalidBlockLayout("unsupported stacked disk block size \(value)")
}
return blockSize
}
#endif
}

View File

@ -1,26 +0,0 @@
import Foundation
struct HumanReadableByteCount: Encodable, CustomStringConvertible {
private let byteCount: Int
private let jsonValue: any Encodable
init<JSONValue: Encodable>(_ byteCount: Int, encodedAs: (Int) -> JSONValue) {
self.byteCount = byteCount
self.jsonValue = encodedAs(byteCount)
}
var description: String {
let formatter = MeasurementFormatter()
formatter.unitOptions = .naturalScale
formatter.unitStyle = .medium
formatter.numberFormatter.maximumFractionDigits = 0
return formatter.string(
from: Measurement(value: Double(byteCount), unit: UnitInformationStorage.bytes)
)
}
func encode(to encoder: Encoder) throws {
try jsonValue.encode(to: encoder)
}
}

View File

@ -7,8 +7,6 @@ enum DigestError: Error {
}
class Digest {
private static let fileBufferSize = 4 * 1024 * 1024
var hash: SHA256 = SHA256()
func update(_ data: Data) {
@ -24,10 +22,7 @@ class Digest {
}
static func hash(_ url: URL) throws -> String {
let file = try FileHandle(forReadingFrom: url)
defer { try? file.close() }
return try hashContents(from: file)
hash(try Data(contentsOf: url))
}
static func hash(_ url: URL, offset: UInt64, size: UInt64) throws -> String {
@ -41,53 +36,20 @@ class Digest {
throw DigestError.InvalidOffset
}
if size > fileSize - offset {
if (offset + size) > fileSize {
throw DigestError.InvalidSize
}
// Read the requested range incrementally and calculate its digest.
// Read a chunk of size ``size`` at offset ``offset``
// and calculate it's digest
let fh = try FileHandle(forReadingFrom: url)
defer { try? fh.close() }
defer { try! fh.close() }
try fh.seek(toOffset: offset)
return try hashContents(from: fh, size: size)
}
let data = try fh.read(upToCount: Int(size))!
/// Streams a file into SHA-256 while keeping Foundation's temporary read
/// buffers scoped to one chunk.
private static func hashContents(from file: FileHandle, size: UInt64? = nil) throws -> String {
let digest = Digest()
var remaining = size
while remaining.map({ $0 > 0 }) ?? true {
let didRead = try autoreleasepool { () throws -> Bool in
let count = remaining.map {
Int(min(UInt64(fileBufferSize), $0))
} ?? fileBufferSize
guard let data = try file.read(upToCount: count), !data.isEmpty else {
if remaining != nil {
throw DigestError.InvalidSize
}
return false
}
digest.update(data)
if let bytesRemaining = remaining {
remaining = bytesRemaining - UInt64(data.count)
}
return true
}
if !didRead {
break
}
}
return digest.finalize()
return hash(data)
}
}

View File

@ -1,6 +1,6 @@
import Foundation
protocol Disk {
static func push(diskURL: URL, mediaType: String, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer]
static func push(diskURL: URL, 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
}

View File

@ -22,14 +22,7 @@ class DiskV2: Disk {
private static let holeGranularityBytes = 4 * 1024 * 1024
private static let zeroChunk = Data(count: holeGranularityBytes)
static func push(
diskURL: URL,
mediaType: String,
registry: Registry,
chunkSizeMb: Int,
concurrency: UInt,
progress: Progress
) async throws -> [OCIManifestLayer] {
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] {
var pushedLayers: [(index: Int, pushedLayer: OCIManifestLayer)] = []
// Open the disk file
@ -70,7 +63,7 @@ class DiskV2: Disk {
progress.completedUnitCount += Int64(data.count)
return (index, OCIManifestLayer(
mediaType: mediaType,
mediaType: diskV2MediaType,
size: compressedData.count,
digest: compressedDataDigest,
uncompressedSize: UInt64(data.count),

View File

@ -7,13 +7,11 @@ let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
// Layer media types
let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2"
let asifOverlayMediaType = "application/vnd.cirruslabs.tart.disk.asif.overlay.v1"
let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
// Manifest annotations
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
let diskBlockSizeAnnotation = "org.cirruslabs.tart.disk.block-size"
// Manifest labels
let diskFormatLabel = "org.cirruslabs.tart.disk.format"
@ -21,51 +19,6 @@ let diskFormatLabel = "org.cirruslabs.tart.disk.format"
// Layer annotations
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
let diskFileContentDigestAnnotation = "org.cirruslabs.tart.disk-file-content-digest"
let diskFileChunkCountAnnotation = "org.cirruslabs.tart.disk-file-chunk-count"
/// The OCI-layer descriptors whose Tart disk chunks reconstruct one complete
/// base disk or ASIF overlay.
struct TartDiskFileGroup: Equatable {
enum Kind: Equatable {
case base
case asifOverlay
}
var kind: Kind
var chunks: [OCIManifestLayer]
/// Whole reconstructed-file digest. Existing flat manifests do not have
/// this until a macOS 27 clone normalizes its local manifest copy.
var contentDigest: String?
/// Expected size of the complete disk file reconstructed from these chunks.
func uncompressedSize() -> UInt64? {
var result: UInt64 = 0
for chunk in chunks {
guard let size = chunk.uncompressedSize() else {
return nil
}
let addition = result.addingReportingOverflow(size)
guard !addition.overflow else {
return nil
}
result = addition.partialValue
}
return result
}
}
enum TartDiskRepresentation: Equatable {
case flat(base: TartDiskFileGroup)
case stacked(base: TartDiskFileGroup, overlays: [TartDiskFileGroup])
}
enum OCIManifestValidationError: Error, Equatable {
case invalidLayout(String)
case invalidDiskMetadata(String)
}
struct OCIManifest: Codable, Equatable {
var schemaVersion: Int = 2
@ -110,116 +63,6 @@ struct OCIManifest: Codable, Equatable {
return UInt64(value)
}
/// Parse Tart's canonical `config -> disk descriptors -> NVRAM` order.
/// A stacked image has a leading `disk.v2` base run followed by one or more
/// contiguous ASIF overlay chunk groups.
func tartDiskRepresentation() throws -> TartDiskRepresentation {
guard layers.filter({ $0.mediaType == configMediaType }).count == 1 else {
throw OCIManifestValidationError.invalidLayout("manifest must contain exactly one Tart config descriptor")
}
guard layers.filter({ $0.mediaType == nvramMediaType }).count == 1 else {
throw OCIManifestValidationError.invalidLayout("manifest must contain exactly one NVRAM descriptor")
}
guard layers.first?.mediaType == configMediaType,
layers.last?.mediaType == nvramMediaType else {
throw OCIManifestValidationError.invalidLayout("descriptors must be ordered as config, disk chunks, then NVRAM")
}
let diskDescriptors = Array(layers.dropFirst().dropLast())
guard !diskDescriptors.isEmpty else {
throw OCIManifestValidationError.invalidLayout("manifest has no disk chunks")
}
let baseChunkCount = diskDescriptors.prefix { $0.mediaType == diskV2MediaType }.count
guard baseChunkCount > 0 else {
throw OCIManifestValidationError.invalidLayout("disk chunks must start with a disk.v2 base")
}
let baseChunks = Array(diskDescriptors.prefix(baseChunkCount))
try validateChunkMetadata(baseChunks)
guard baseChunks.first?.diskFileChunkCount() == nil,
baseChunks.dropFirst().allSatisfy({
$0.diskFileContentDigest() == nil && $0.diskFileChunkCount() == nil
}) else {
throw OCIManifestValidationError.invalidDiskMetadata("base disk metadata must appear only on its first chunk")
}
let base = TartDiskFileGroup(
kind: .base,
chunks: baseChunks,
contentDigest: baseChunks.first?.diskFileContentDigest()
)
guard baseChunkCount < diskDescriptors.count else {
return .flat(base: base)
}
guard base.contentDigest != nil else {
throw OCIManifestValidationError.invalidDiskMetadata("a stacked base disk needs a whole-file content digest")
}
var overlays: [TartDiskFileGroup] = []
var index = baseChunkCount
while index < diskDescriptors.count {
let first = diskDescriptors[index]
guard first.mediaType == asifOverlayMediaType else {
throw OCIManifestValidationError.invalidLayout("unsupported disk chunk media type: \(first.mediaType)")
}
guard let contentDigest = first.diskFileContentDigest(),
let chunkCount = first.diskFileChunkCount() else {
throw OCIManifestValidationError.invalidDiskMetadata("an ASIF overlay needs a content digest and chunk count")
}
guard chunkCount > 0, index + chunkCount <= diskDescriptors.count else {
throw OCIManifestValidationError.invalidDiskMetadata("ASIF overlay chunk count is invalid")
}
let chunks = Array(diskDescriptors[index..<(index + chunkCount)])
guard chunks.allSatisfy({ $0.mediaType == asifOverlayMediaType }) else {
throw OCIManifestValidationError.invalidLayout("ASIF overlay chunks must be contiguous")
}
guard chunks.dropFirst().allSatisfy({ $0.diskFileContentDigest() == nil && $0.diskFileChunkCount() == nil }) else {
throw OCIManifestValidationError.invalidDiskMetadata("ASIF overlay metadata must appear only on its first chunk")
}
try validateChunkMetadata(chunks)
overlays.append(TartDiskFileGroup(kind: .asifOverlay, chunks: chunks, contentDigest: contentDigest))
index += chunkCount
}
return .stacked(base: base, overlays: overlays)
}
/// Returns content-store digests needed to reconstruct this disk stack.
func diskContentDigests() throws -> [String] {
switch try tartDiskRepresentation() {
case .flat(let base):
return base.contentDigest.map { [$0] } ?? []
case .stacked(let base, let overlays):
return ([base] + overlays).compactMap(\.contentDigest)
}
}
private func validateChunkMetadata(_ chunks: [OCIManifestLayer]) throws {
guard chunks.allSatisfy({ $0.uncompressedSize() != nil && $0.uncompressedContentDigest() != nil }) else {
throw OCIManifestValidationError.invalidDiskMetadata("disk chunks need uncompressed size and content digest")
}
}
func diskBlockSize() -> UInt64? {
annotations?[diskBlockSizeAnnotation].flatMap(UInt64.init)
}
func diskBlockCount() -> UInt64? {
guard let diskSize = uncompressedDiskSize(),
let blockSize = diskBlockSize(),
blockSize > 0,
diskSize.isMultiple(of: blockSize) else {
return nil
}
return diskSize / blockSize
}
}
struct OCIConfig: Codable {
@ -278,14 +121,6 @@ struct OCIManifestLayer: Codable, Equatable, Hashable {
annotations?[uncompressedContentDigestAnnotation]
}
func diskFileContentDigest() -> String? {
annotations?[diskFileContentDigestAnnotation]
}
func diskFileChunkCount() -> Int? {
annotations?[diskFileChunkCountAnnotation].flatMap(Int.init)
}
static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.digest == rhs.digest
}

View File

@ -64,7 +64,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Initialize the virtual machine and its configuration
self.network = network
configuration = try Self.craftConfiguration(vmDir: vmDir,
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
nvramURL: vmDir.nvramURL, vmConfig: config,
network: network, additionalStorageDevices: additionalStorageDevices,
directorySharingDevices: directorySharingDevices,
@ -196,8 +196,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Initialize the virtual machine and its configuration
self.network = network
configuration = try Self.craftConfiguration(vmDir: vmDir,
nvramURL: vmDir.nvramURL,
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
vmConfig: config, network: network,
additionalStorageDevices: additionalStorageDevices,
directorySharingDevices: directorySharingDevices,
@ -313,7 +312,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
static func craftConfiguration(
vmDir: VMDirectory,
diskURL: URL,
nvramURL: URL,
vmConfig: VMConfig,
network: Network = NetworkShared(),
@ -405,25 +404,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
// Storage
// 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 cachingMode = caching ?? (vmConfig.os == .linux ? .cached : .automatic)
let attachment: VZStorageDeviceAttachment
if vmDir.isStackedVM {
attachment = try vmDir.diskImageStack().makeAttachment(
readOnly: false,
cachingMode: cachingMode,
synchronizationMode: sync
)
} else {
attachment = try VZDiskImageStorageDeviceAttachment(
url: vmDir.diskURL,
readOnly: false,
cachingMode: cachingMode,
synchronizationMode: sync
)
}
let attachment = try VZDiskImageStorageDeviceAttachment(
url: diskURL,
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
)
var devices: [VZStorageDeviceConfiguration] = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]
devices.append(contentsOf: additionalStorageDevices)

View File

@ -1,4 +1,3 @@
import Foundation
import System
import AppleArchive
@ -11,16 +10,6 @@ fileprivate let permissions = FilePermissions(rawValue: 0o644)
// [2]: https://developer.apple.com/documentation/compression/algorithm/lzfse
extension VMDirectory {
func exportToArchive(path: String) throws {
let temporaryArchive = try stackedArchiveDirectoryIfNeeded()
let archiveSourceURL = temporaryArchive?.vmDirectory.baseURL ?? baseURL
defer {
if let temporaryArchive {
try? temporaryArchive.lock.unlock()
try? temporaryArchive.vmDirectory.removeFromDisk()
}
}
guard let fileStream = ArchiveByteStream.fileStream(
path: FilePath(path),
mode: .writeOnly,
@ -60,7 +49,7 @@ extension VMDirectory {
return
}
try encodeStream.writeDirectoryContents(archiveFrom: FilePath(archiveSourceURL.path), keySet: keySet)
try encodeStream.writeDirectoryContents(archiveFrom: FilePath(baseURL.path), keySet: keySet)
}
func importFromArchive(path: String) throws {
@ -103,145 +92,5 @@ extension VMDirectory {
}
_ = try ArchiveStream.process(readingFrom: decodeStream, writingTo: extractStream)
if isStackedVM {
try restoreStackedArchive()
}
}
/// Builds a self-contained staging directory for a stacked archive, if this
/// directory currently resolves to a stacked VM or cached image.
private func stackedArchiveDirectoryIfNeeded() throws -> (vmDirectory: VMDirectory, lock: FileLock)? {
guard isStackedVM || isStackedCachedImage else {
return nil
}
try DiskImageStack.requireSupport()
let contentStore = try ContentStore()
let archiveVMDir = try VMDirectory.temporary()
let archiveVMDirLock = try FileLock(lockURL: archiveVMDir.baseURL)
try archiveVMDirLock.lock()
do {
let stagedSource: (isStackedVM: Bool, contentDigests: [String])? = try contentStore.withPruneLock {
() -> (isStackedVM: Bool, contentDigests: [String])? in
// OCI tags are mutable symlinks. Resolve one digest record while tag
// replacement and cached-image deletion are blocked, then copy every
// source-owned file before releasing the lock.
let sourceVMDir = VMDirectory(baseURL: baseURL.resolvingSymlinksInPath())
guard sourceVMDir.isStackedVM || sourceVMDir.isStackedCachedImage else {
throw RuntimeError.ExportFailed("VM changed while preparing export, retry the command")
}
let sourceIsStackedVM = sourceVMDir.isStackedVM
let sourceVMLock: PIDLock?
if sourceIsStackedVM {
let lock = try sourceVMDir.lock()
guard try lock.trylock() else {
throw RuntimeError.ExportFailed("VM \"\(sourceVMDir.name)\" must be stopped before export")
}
sourceVMLock = lock
// Holding the PID lock proves that the VM is not running. A saved
// state file is the remaining suspended state that must reject export.
guard !FileManager.default.fileExists(atPath: sourceVMDir.stateURL.path) else {
try? lock.unlock()
throw RuntimeError.ExportFailed("VM \"\(sourceVMDir.name)\" must be stopped before export")
}
} else {
sourceVMLock = nil
}
defer { try? sourceVMLock?.unlock() }
try FileManager.default.copyItem(at: sourceVMDir.configURL, to: archiveVMDir.configURL)
try FileManager.default.copyItem(at: sourceVMDir.nvramURL, to: archiveVMDir.nvramURL)
try FileManager.default.copyItem(at: sourceVMDir.manifestURL, to: archiveVMDir.manifestURL)
if sourceIsStackedVM {
try FileManager.default.copyItem(at: sourceVMDir.overlayURL, to: archiveVMDir.overlayURL)
}
return (sourceIsStackedVM, try archiveVMDir.diskContentDigests())
}
guard let stagedSource else {
try archiveVMDirLock.unlock()
try archiveVMDir.removeFromDisk()
return nil
}
if !stagedSource.isStackedVM {
try archiveVMDir.diskImageStack().createWritableOverlay()
}
// The staged manifest is now an in-progress reference, so immutable
// content remains protected while these potentially large copies run
// without holding the global prune lock.
for contentDigest in stagedSource.contentDigests {
guard let sourceURL = try contentStore.existingContentURL(for: contentDigest) else {
throw RuntimeError.ExportFailed("VM is missing cached disk content \(contentDigest)")
}
let destinationURL = try contentStore.contentURL(
for: contentDigest,
under: archiveContentStoreURL(in: archiveVMDir)
)
try FileManager.default.createDirectory(
at: destinationURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
}
return (archiveVMDir, archiveVMDirLock)
} catch {
try? archiveVMDirLock.unlock()
try? archiveVMDir.removeFromDisk()
throw error
}
}
/// Restores immutable files from an archive into the shared content store,
/// removes the archive-only payload, then validates the resulting stack.
private func restoreStackedArchive() throws {
try DiskImageStack.requireSupport()
let contentStore = try ContentStore()
// The extracted manifest is already a reference; synchronize publication
// with a concurrent prune before installing its immutable content.
try contentStore.synchronizePublishedReferences()
for contentDigest in try diskContentDigests() {
if try contentStore.existingContentURL(for: contentDigest) != nil {
continue
}
let archivedContentURL = try contentStore.contentURL(
for: contentDigest,
under: archiveContentStoreURL(in: self)
)
guard FileManager.default.fileExists(atPath: archivedContentURL.path) else {
throw RuntimeError.ImportFailed("archive is missing disk content \(contentDigest)")
}
let temporaryURL = try contentStore.temporaryContentURL(for: contentDigest)
do {
try FileManager.default.copyItem(at: archivedContentURL, to: temporaryURL)
_ = try contentStore.install(temporaryURL, contentDigest: contentDigest)
} catch {
try? FileManager.default.removeItem(at: temporaryURL)
throw error
}
}
try FileManager.default.removeItem(at: archiveContentStoreURL(in: self))
try? FileManager.default.removeItem(at: stateURL)
// Opening the attachment validates the reconstructed immutable stack and
// imported writable overlay before the VM enters local storage.
_ = try diskImageStack().makeAttachment()
}
private func archiveContentStoreURL(in vmDir: VMDirectory) -> URL {
vmDir.baseURL.appendingPathComponent("content", isDirectory: true)
}
}

View File

@ -1,142 +0,0 @@
import Foundation
extension VMDirectory {
/// Returns content-store digests needed to reconstruct this VM's disk stack.
func diskContentDigests() throws -> [String] {
let manifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL))
return try manifest.diskContentDigests()
}
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.invalidBlockLayout("disk image metadata is missing block layout")
}
let contentStore = try providedStore ?? ContentStore()
let baseURL = try diskImageURL(for: base, contentStore: contentStore)
let immutableOverlayURLs = try overlays.map { try diskImageURL(for: $0, contentStore: contentStore) }
let config = try VMConfig(fromURL: configURL)
return DiskImageStack(
baseURL: baseURL,
baseFormat: config.diskFormat,
immutableOverlayURLs: immutableOverlayURLs,
writableOverlayURL: overlayURL,
blockSize: blockSize,
blockCount: blockCount
)
}
func cloneStacked(
to destination: VMDirectory,
copyWritableOverlay: Bool,
generateMAC: Bool,
contentStore: ContentStore? = nil
) throws {
let contentStore = try contentStore ?? ContentStore()
try contentStore.withPruneLock {
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)
}
}
if !copyWritableOverlay {
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 blockLayout = try DiskImageStack.baseBlockLayout(at: diskURL, expectedFormat: config.diskFormat)
let contentDigest = try Digest.hash(diskURL)
let contentStore = try providedStore ?? ContentStore()
var manifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL))
guard case .flat = try manifest.tartDiskRepresentation() else {
throw RuntimeError.VMConfigurationError("--stacked cannot use an image that already has a stacked disk")
}
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
let diskSize = blockLayout.blockSize.multipliedReportingOverflow(by: blockLayout.blockCount)
guard !diskSize.overflow else {
throw DiskImageStackError.invalidBlockLayout("stacked disk block layout overflows UInt64")
}
var annotations = manifest.annotations ?? [:]
annotations[diskBlockSizeAnnotation] = String(blockLayout.blockSize)
annotations[uncompressedDiskSizeAnnotation] = String(diskSize.partialValue)
manifest.annotations = annotations
try FileManager.default.copyItem(at: configURL, to: destination.configURL)
try FileManager.default.copyItem(at: nvramURL, to: destination.nvramURL)
try contentStore.withPruneLock {
try manifest.toJSON().write(to: destination.manifestURL)
}
// Publish the temporary VM's manifest before installing the shared base.
// Reference-aware pruning includes in-progress manifests, so the content
// cannot be collected in the window before this VM is moved into place.
if try contentStore.contentURLIfPresent(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
}
}
try destination.diskImageStack(contentStore: contentStore).createWritableOverlay()
if generateMAC {
try destination.regenerateMACAddress()
}
}
private func diskImageURL(for group: TartDiskFileGroup, contentStore: ContentStore) throws -> URL {
guard let contentDigest = group.contentDigest else {
throw OCIManifestValidationError.invalidDiskMetadata("stacked disk files need a whole-file content digest")
}
// Pull/install verifies immutable content before publishing it. Clone and
// run use the trusted content-addressed entry without rereading a possibly
// very large disk file, matching Tart's existing disk.img behavior.
guard let url = try contentStore.contentURLIfPresent(for: contentDigest) else {
throw RuntimeError.VMMissingFiles("VM is missing cached disk content \(contentDigest)")
}
return url
}
}

View File

@ -6,6 +6,7 @@ let legacyDiskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
enum OCIError: Error {
case ShouldBeExactlyOneLayer
case ShouldBeAtLeastOneLayer
case FailedToCreateVmFile
case LayerIsMissingUncompressedSizeAnnotation
case LayerIsMissingUncompressedDigestAnnotation
@ -13,7 +14,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 store it as the local config file.
// Pull VM's config file layer and re-serialize it into a config file
let configLayers = manifest.layers.filter {
$0.mediaType == configMediaType
}
@ -29,22 +30,17 @@ extension VMDirectory {
}
try configFile.close()
// Pull VM's disk chunks and decompress them into complete disk files.
// Pull VM's disk layers and decompress them into a disk file
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 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 layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
if layers.isEmpty {
throw OCIError.ShouldBeAtLeastOneLayer
}
let diskCompressedSize = diskChunks.map { Int64($0.size) }.reduce(0, +)
let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +)
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "compressed_disk_size_bytes",
value: .int(Int(diskCompressedSize))
@ -57,42 +53,19 @@ extension VMDirectory {
ProgressObserver(progress).log(defaultLogger)
do {
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 standalone. A
// cached stacked image 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
)
}
}
try await DiskV2.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
concurrency: concurrency, progress: progress,
localLayerCache: localLayerCache,
deduplicate: deduplicate)
} 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...")
@ -110,50 +83,12 @@ extension VMDirectory {
try nvram.write(contentsOf: data)
}
try nvram.close()
// Serialize VM's manifest to enable better deduplication on subsequent "tart pull"'s
try manifest.toJSON().write(to: manifestURL)
}
/// Reconstructs one complete immutable base disk or published ASIF overlay
/// from its Tart disk chunks, unless the shared content store already has a
/// size-matching 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")
}
// Pulls for the same semantic disk file share a stable resumable path so
// DiskV2 can resume after a transient failure. Serialize writers before
// rechecking the final entry to avoid racing on that shared path.
let lock = try FileLock(lockURL: contentStore.lockURL(for: contentDigest))
try lock.lock()
defer { try? lock.unlock() }
if let existingURL = try contentStore.contentURLIfPresent(for: contentDigest),
let actualSize = UInt64(exactly: try existingURL.sizeBytes()),
let expectedSize = group.uncompressedSize(),
actualSize == expectedSize {
progress.completedUnitCount += group.chunks.reduce(0) { $0 + Int64($1.size) }
return existingURL
}
let resumableURL = try contentStore.resumableContentURL(for: contentDigest)
try await DiskV2.pull(
registry: registry,
diskLayers: group.chunks,
diskURL: resumableURL,
concurrency: concurrency,
progress: progress
)
return try contentStore.install(resumableURL, contentDigest: contentDigest)
}
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, concurrency: UInt, labels: [String: String] = [:]) async throws -> (name: RemoteName, manifest: OCIManifest) {
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, concurrency: UInt, labels: [String: String] = [:]) async throws -> RemoteName {
var layers = Array<OCIManifestLayer>()
// Read VM's config and push it as blob
@ -167,12 +102,14 @@ extension VMDirectory {
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
layers.append(OCIManifestLayer(mediaType: configMediaType, size: configJSON.count, digest: configDigest))
let (diskLayers, diskAnnotations) = try await pushDiskLayers(
registry: registry,
chunkSizeMb: chunkSizeMb,
concurrency: concurrency
)
layers.append(contentsOf: diskLayers)
// 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))
// Read VM's NVRAM and push it as blob
defaultLogger.appendNewLine("pushing NVRAM...")
@ -185,13 +122,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)
var manifest = OCIManifest(
let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
layers: layers
layers: layers,
uncompressedDiskSize: UInt64(diskSize),
uploadDate: Date()
)
var annotations = diskAnnotations
annotations[uploadTimeAnnotation] = Date().toISO()
manifest.annotations = annotations
// Manifest
for reference in references {
defaultLogger.appendNewLine("pushing manifest for \(reference)...")
@ -200,158 +137,7 @@ extension VMDirectory {
}
let pushedReference = Reference(digest: try manifest.digest())
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 pushDiskLayers(
registry: Registry,
chunkSizeMb: Int,
concurrency: UInt
) async throws -> ([OCIManifestLayer], [String: String]) {
guard isStackedVM 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))
// pushToRegistry() reads config.json before reaching this point. Closing
// that read descriptor can release the caller's fcntl PID lock, so take a
// fresh lock before hashing, uploading, and inspecting the writable overlay.
let stackedDiskLock = try lock()
guard try stackedDiskLock.trylock() else {
throw RuntimeError.VMIsRunning(name)
}
defer { try? stackedDiskLock.unlock() }
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 overlaySize = try FileManager.default.attributesOfItem(atPath: overlayURL.path)[.size] as! Int64
defaultLogger.appendNewLine("pushing overlay...")
let progress = Progress(totalUnitCount: overlaySize)
ProgressObserver(progress).log(defaultLogger)
let contentDigest = try Digest.hash(overlayURL)
let chunks = try await DiskV2.push(
diskURL: overlayURL,
mediaType: asifOverlayMediaType,
registry: registry,
chunkSizeMb: chunkSizeMb,
concurrency: concurrency,
progress: progress
)
layers.append(contentsOf: annotatedChunks(chunks, kind: .asifOverlay, contentDigest: contentDigest))
let blockLayout = try DiskImageStack.diskImageBlockLayout(at: overlayURL)
let diskSize = blockLayout.blockSize.multipliedReportingOverflow(by: blockLayout.blockCount)
guard !diskSize.overflow else {
throw DiskImageStackError.invalidBlockLayout("stacked disk block layout overflows UInt64")
}
var annotations = localManifest.annotations ?? [:]
annotations[diskBlockSizeAnnotation] = String(blockLayout.blockSize)
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
}
// Rebuilding transport blobs republishes this file under the pinned
// whole-file digest, so validate the cached bytes at this boundary.
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
return RemoteName(host: registry.host!, namespace: registry.namespace, reference: pushedReference)
}
}

View File

@ -26,9 +26,6 @@ struct VMDirectory: Prunable {
var manifestURL: URL {
baseURL.appendingPathComponent("manifest.json")
}
var overlayURL: URL {
baseURL.appendingPathComponent("overlay.asif")
}
var controlSocketURL: URL {
URL(fileURLWithPath: "control.sock", relativeTo: baseURL)
}
@ -90,74 +87,10 @@ struct VMDirectory: Prunable {
return VMDirectory(baseURL: tmpDir)
}
private var hasRequiredMetadata: Bool {
let fileManager = FileManager.default
return fileManager.fileExists(atPath: configURL.path) &&
fileManager.fileExists(atPath: nvramURL.path)
}
enum Layout: Equatable {
/// Existing Tart layout with one independently attachable `disk.img`.
/// A pulled standalone OCI record may also carry `manifest.json`.
case standalone
/// Runnable stacked VM with immutable disk files from `manifest.json` and
/// a private writable `overlay.asif`.
case stackedLocal
/// Pulled OCI record for a stacked image. It intentionally has no writable
/// overlay and becomes runnable only after `tart clone` creates one.
case stackedOCIRecord
var isRunnable: Bool {
self != .stackedOCIRecord
}
}
var layout: Layout? {
let fileManager = FileManager.default
let hasDisk = fileManager.fileExists(atPath: diskURL.path)
let hasManifest = fileManager.fileExists(atPath: manifestURL.path)
let hasOverlay = fileManager.fileExists(atPath: overlayURL.path)
guard hasRequiredMetadata else {
return nil
}
if hasDisk && !hasOverlay {
return .standalone
}
if !hasDisk && hasManifest && hasOverlay {
return .stackedLocal
}
if !hasDisk && hasManifest && !hasOverlay {
return .stackedOCIRecord
}
return nil
}
var initialized: Bool {
layout?.isRunnable == true
}
var isStandalone: Bool {
layout == .standalone
}
var isStackedVM: Bool {
layout == .stackedLocal
}
var isStackedCachedImage: Bool {
layout == .stackedOCIRecord
}
/// Shapes that may live in the remote-image cache. A cached stacked image
/// has no writable overlay and is intentionally not runnable as a local VM.
var isCachedImage: Bool {
layout == .standalone || layout == .stackedOCIRecord
FileManager.default.fileExists(atPath: configURL.path) &&
FileManager.default.fileExists(atPath: diskURL.path) &&
FileManager.default.fileExists(atPath: nvramURL.path)
}
func initialize(overwrite: Bool = false) throws {
@ -170,9 +103,6 @@ struct VMDirectory: Prunable {
try? FileManager.default.removeItem(at: configURL)
try? FileManager.default.removeItem(at: diskURL)
try? FileManager.default.removeItem(at: nvramURL)
try? FileManager.default.removeItem(at: manifestURL)
try? FileManager.default.removeItem(at: overlayURL)
try? FileManager.default.removeItem(at: stateURL)
}
func validate(userFriendlyName: String) throws {
@ -181,26 +111,8 @@ struct VMDirectory: Prunable {
}
if !initialized {
throw RuntimeError.VMMissingFiles(
"VM is missing files for a supported layout: "
+ "standalone requires \(configURL.lastPathComponent), \(diskURL.lastPathComponent) and \(nvramURL.lastPathComponent); "
+ "stacked requires \(configURL.lastPathComponent), \(manifestURL.lastPathComponent), "
+ "\(overlayURL.lastPathComponent) and \(nvramURL.lastPathComponent)"
)
}
}
func validateCachedImage(userFriendlyName: String) throws {
if !FileManager.default.fileExists(atPath: baseURL.path) {
throw RuntimeError.VMDoesNotExist(name: userFriendlyName)
}
if !isCachedImage {
throw RuntimeError.VMMissingFiles(
"cached image is missing files for a supported layout: "
+ "standalone requires \(configURL.lastPathComponent), \(diskURL.lastPathComponent) and \(nvramURL.lastPathComponent); "
+ "stacked requires \(configURL.lastPathComponent), \(manifestURL.lastPathComponent) and \(nvramURL.lastPathComponent)"
)
throw RuntimeError.VMMissingFiles("VM is missing some of its files (\(configURL.lastPathComponent),"
+ " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))")
}
}
@ -230,38 +142,7 @@ struct VMDirectory: Prunable {
try vmConfig.save(toURL: configURL)
}
func resizeDisk(
_ sizeGB: UInt16,
format: DiskImageFormat = .raw,
contentStore: ContentStore? = nil
) throws {
if isStackedVM {
// Resolve the stack before taking the config.json PID lock. Reading
// config.json after acquiring an fcntl lock would release that lock
// when the read file descriptor is closed.
let stack = try diskImageStack(contentStore: contentStore)
let lock = try lock()
guard try lock.trylock() else {
throw RuntimeError.VMConfigurationError("VM \"\(name)\" must be stopped before resizing its disk")
}
defer { try? lock.unlock() }
// Holding the PID lock proves that the VM is not running. A saved state
// file is the remaining suspended state that must also reject resize.
guard !FileManager.default.fileExists(atPath: stateURL.path) else {
throw RuntimeError.VMConfigurationError("VM \"\(name)\" must be stopped before resizing its disk")
}
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")
}
let desiredBlockCount = desiredSizeBytes / stack.blockSize
try stack.growWritableOverlay(toBlockCount: desiredBlockCount)
return
}
func resizeDisk(_ sizeGB: UInt16, format: DiskImageFormat = .raw) throws {
let diskExists = FileManager.default.fileExists(atPath: diskURL.path)
if diskExists {
@ -385,33 +266,17 @@ struct VMDirectory: Prunable {
throw RuntimeError.VMIsRunning(name)
}
// Standalone local VMs do not reference the shared content store. Delete
// them directly so a full disk can still be recovered before the content
// store has ever been initialized.
if isStandalone {
try FileManager.default.removeItem(at: baseURL)
} else {
try removeFromDisk()
}
try FileManager.default.removeItem(at: baseURL)
try lock.unlock()
}
/// Removes a VM directory while preserving the content-store reference
/// protocol for any complete or partially published manifest it contains.
func removeFromDisk() throws {
let contentStore = try ContentStore()
try contentStore.withPruneLock {
try FileManager.default.removeItem(at: baseURL)
}
}
func accessDate() throws -> Date {
try baseURL.accessDate()
}
func allocatedSizeBytes() throws -> Int {
try configURL.allocatedSizeBytes() + localDiskStorageAllocatedSizeBytes() + nvramURL.allocatedSizeBytes()
try configURL.allocatedSizeBytes() + diskURL.allocatedSizeBytes() + nvramURL.allocatedSizeBytes()
}
func allocatedSizeGB() throws -> Int {
@ -419,7 +284,7 @@ struct VMDirectory: Prunable {
}
func deduplicatedSizeBytes() throws -> Int {
try configURL.deduplicatedSizeBytes() + localDiskStorageDeduplicatedSizeBytes() + nvramURL.deduplicatedSizeBytes()
try configURL.deduplicatedSizeBytes() + diskURL.deduplicatedSizeBytes() + nvramURL.deduplicatedSizeBytes()
}
func deduplicatedSizeGB() throws -> Int {
@ -427,7 +292,7 @@ struct VMDirectory: Prunable {
}
func sizeBytes() throws -> Int {
try configURL.sizeBytes() + localDiskStorageSizeBytes() + nvramURL.sizeBytes()
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
}
func sizeGB() throws -> Int {
@ -435,30 +300,6 @@ struct VMDirectory: Prunable {
}
func diskSizeBytes() throws -> Int {
if isStackedVM {
let blockLayout = try DiskImageStack.diskImageBlockLayout(at: overlayURL)
let product = blockLayout.blockSize.multipliedReportingOverflow(by: blockLayout.blockCount)
guard !product.overflow, let diskSizeBytes = Int(exactly: product.partialValue) else {
throw RuntimeError.VMConfigurationError("VM has invalid stacked disk block layout")
}
return diskSizeBytes
}
if isStackedCachedImage {
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 block layout")
}
let product = blockSize.multipliedReportingOverflow(by: blockCount)
guard !product.overflow, let diskSizeBytes = Int(exactly: product.partialValue) else {
throw RuntimeError.VMConfigurationError("VM has invalid stacked disk block layout")
}
return diskSizeBytes
}
let vmConfig = try VMConfig(fromURL: configURL)
return switch vmConfig.diskFormat {
@ -480,23 +321,4 @@ struct VMDirectory: Prunable {
func isExplicitlyPulled() -> Bool {
FileManager.default.fileExists(atPath: explicitlyPulledMark.path)
}
private var localDiskStorageURL: URL {
isStackedVM ? overlayURL : diskURL
}
// Cached stacked images 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 cached image that references it.
private func localDiskStorageAllocatedSizeBytes() throws -> Int {
isStackedCachedImage ? 0 : try localDiskStorageURL.allocatedSizeBytes()
}
private func localDiskStorageDeduplicatedSizeBytes() throws -> Int {
isStackedCachedImage ? 0 : try localDiskStorageURL.deduplicatedSizeBytes()
}
private func localDiskStorageSizeBytes() throws -> Int {
isStackedCachedImage ? 0 : try localDiskStorageURL.sizeBytes()
}
}

View File

@ -35,27 +35,11 @@ class VMStorageLocal: PrunableStorage {
func move(_ name: String, from: VMDirectory) throws {
_ = try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
try replace(VMDirectory(baseURL: vmURL(name)), with: from)
_ = try FileManager.default.replaceItemAt(vmURL(name), withItemAt: from.baseURL)
}
func rename(_ name: String, _ newName: String) throws {
let source = VMDirectory(baseURL: vmURL(name))
let destination = VMDirectory(baseURL: vmURL(newName))
try replace(destination, with: source)
}
/// References in a manifest must not disappear while content GC is deciding
/// whether their immutable disk files are still in use.
private func replace(_ destination: VMDirectory, with source: VMDirectory) throws {
if FileManager.default.fileExists(atPath: source.manifestURL.path) ||
FileManager.default.fileExists(atPath: destination.manifestURL.path) {
let contentStore = try ContentStore()
try contentStore.withPruneLock {
_ = try FileManager.default.replaceItemAt(destination.baseURL, withItemAt: source.baseURL)
}
} else {
_ = try FileManager.default.replaceItemAt(destination.baseURL, withItemAt: source.baseURL)
}
_ = try FileManager.default.replaceItemAt(vmURL(newName), withItemAt: vmURL(name))
}
func delete(_ name: String) throws {

View File

@ -18,104 +18,7 @@ class VMStorageOCI: PrunableStorage {
}
func exists(_ name: RemoteName) -> Bool {
VMDirectory(baseURL: vmURL(name)).isCachedImage
}
/// Whether clone can use a cached image without pulling. Standalone images keep
/// Tart's existing structural check. Stacked cached images require every
/// immutable file with its expected length.
func hasUsableCachedImageForClone(_ 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.isStackedCachedImage 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 try hasUsableCachedDiskFile(group, contentStore: contentStore) else {
return false
}
}
return true
}
/// Whether a cached image is complete enough for `pull` to return without
/// repairing it. Standalone images keep Tart's existing structural cache-hit
/// behavior; stacked cached images additionally need every immutable disk file in
/// the shared content store.
func hasCompleteCachedImage(
_ name: RemoteName,
manifest: OCIManifest,
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 let missingGroups = try missingStackedDiskFileGroups(for: manifest) else {
return true
}
return missingGroups.isEmpty
}
/// The lock-free pull fast path is only useful for a tag that already
/// points at this digest. New or retargeted tags validate once after taking
/// the host lock instead of hashing a large stack twice.
func hasCompleteLinkedImage(
_ name: RemoteName,
digestName: RemoteName,
manifest: OCIManifest,
requireManifest: Bool = false
) throws -> Bool {
guard exists(name), linked(from: name, to: digestName) else {
return false
}
return try hasCompleteCachedImage(digestName, manifest: manifest, requireManifest: requireManifest)
}
/// 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 block layout.
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
VMDirectory(baseURL: vmURL(name)).initialized
}
func digest(_ name: RemoteName) throws -> String {
@ -131,7 +34,7 @@ class VMStorageOCI: PrunableStorage {
func open(_ name: RemoteName, _ accessDate: Date = Date()) throws -> VMDirectory {
let vmDir = VMDirectory(baseURL: vmURL(name))
try vmDir.validateCachedImage(userFriendlyName: name.description)
try vmDir.validate(userFriendlyName: name.description)
try vmDir.baseURL.updateAccessDate(accessDate)
@ -141,64 +44,11 @@ class VMStorageOCI: PrunableStorage {
func create(_ name: RemoteName, overwrite: Bool = false) throws -> VMDirectory {
let vmDir = VMDirectory(baseURL: vmURL(name))
if !overwrite && vmDir.isCachedImage {
throw RuntimeError.VMDirectoryAlreadyInitialized("VM directory is already initialized, preventing overwrite")
}
try vmDir.initialize(overwrite: overwrite)
return vmDir
}
/// Materialize the digest-addressed cached image 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 hasCompleteCachedImage(name, manifest: manifest) {
return
}
let vmDir = try create(name, overwrite: exists(name))
do {
if source.isStackedVM {
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 cached image. Keep a
// semantic copy so later clones do not need to fetch it back.
let contentStore = try ContentStore()
try contentStore.withPruneLock {
try FileManager.default.copyItem(at: source.configURL, to: vmDir.configURL)
try FileManager.default.copyItem(at: source.nvramURL, to: vmDir.nvramURL)
// Publish the reference before installing the immutable top overlay,
// so reference-aware pruning cannot collect it in between.
try manifest.toJSON().write(to: vmDir.manifestURL)
}
if try contentStore.contentURLIfPresent(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
}
}
} 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 cached image.
try manifest.toJSON().write(to: vmDir.manifestURL)
}
} catch {
try? vmDir.removeFromDisk()
throw error
}
}
func move(_ name: RemoteName, from: VMDirectory) throws{
let targetURL = vmURL(name)
@ -207,20 +57,11 @@ class VMStorageOCI: PrunableStorage {
try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
let target = VMDirectory(baseURL: targetURL)
if FileManager.default.fileExists(atPath: from.manifestURL.path) ||
FileManager.default.fileExists(atPath: target.manifestURL.path) {
let contentStore = try ContentStore()
try contentStore.withPruneLock {
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
}
} else {
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
}
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
}
func delete(_ name: RemoteName) throws {
try removeRecord(at: vmURL(name))
try FileManager.default.removeItem(at: vmURL(name))
try gc()
}
@ -229,7 +70,6 @@ class VMStorageOCI: PrunableStorage {
guard let enumerator = FileManager.default.enumerator(at: baseURL,
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
try gcContent()
return
}
@ -244,7 +84,7 @@ class VMStorageOCI: PrunableStorage {
}
let vmDir = VMDirectory(baseURL: foundURL.resolvingSymlinksInPath())
if !vmDir.isCachedImage {
if !vmDir.initialized {
continue
}
@ -257,28 +97,7 @@ class VMStorageOCI: PrunableStorage {
let vmDir = VMDirectory(baseURL: baseURL)
if !vmDir.isExplicitlyPulled() && incRefCount == 0 {
try removeRecord(at: baseURL)
}
}
try gcContent()
}
/// Cached images with a manifest publish references into the shared content
/// store. Remove them through VMDirectory so reference removal is serialized
/// with clone, export, pull, and content GC, even if a record is incomplete.
private func removeRecord(at url: URL) throws {
try VMDirectory(baseURL: url).removeFromDisk()
}
/// Remove immutable files whose final published or in-progress reference
/// has disappeared, without collecting unrelated cached images.
fileprivate func gcContent() throws {
let contentStore = try ContentStore()
try contentStore.withPruneLock {
let referencedContentDigests = try referencedContentDigests(includeCachedImages: true)
for contentURL in try contentStore.prunables(excluding: referencedContentDigests) {
try FileManager.default.removeItem(at: contentURL)
try FileManager.default.removeItem(at: baseURL)
}
}
}
@ -294,7 +113,7 @@ class VMStorageOCI: PrunableStorage {
for case let foundURL as URL in enumerator {
let vmDir = VMDirectory(baseURL: foundURL)
if !vmDir.isCachedImage {
if !vmDir.initialized {
continue
}
@ -322,67 +141,10 @@ class VMStorageOCI: PrunableStorage {
}
func prunables() throws -> [Prunable] {
let records = try list().filter { (_, _, isSymlink) in
!isSymlink
}.map { (_, vmDir, _) in vmDir }
// Attribute shared content to the newest cached image that references it.
// This counts each file once while charging it to the last record that
// normally needs to be removed before the file becomes reclaimable.
let nonCacheContentDigests = try referencedContentDigests(includeCachedImages: false)
var contentOwners = [String: VMDirectory]()
for record in records where record.isStackedCachedImage {
// Interrupted cache population can leave a truncated manifest in an
// otherwise recognizable cached record. It has no reliable content
// references, but it must not prevent pruning other cache entries.
for contentDigest in (try? record.diskContentDigests()) ?? []
where !nonCacheContentDigests.contains(contentDigest) {
guard let currentOwner = contentOwners[contentDigest] else {
contentOwners[contentDigest] = record
continue
}
let recordAccessDate = try record.accessDate()
let currentAccessDate = try currentOwner.accessDate()
if recordAccessDate > currentAccessDate ||
(recordAccessDate == currentAccessDate && record.url.path > currentOwner.url.path) {
contentOwners[contentDigest] = record
}
}
}
let contentStore = try ContentStore()
var ownedContentURLs = [URL: [URL]]()
for (contentDigest, owner) in contentOwners {
let contentURL = try contentStore.contentURL(for: contentDigest)
guard FileManager.default.fileExists(atPath: contentURL.path) else {
continue
}
ownedContentURLs[owner.url, default: []].append(contentURL)
}
var result: [Prunable] = records.map { record in
CachedImagePrunable(
vmDir: record,
ownedContentURLs: ownedContentURLs[record.url] ?? []
)
}
result += try contentStore.prunables(excluding: referencedContentDigests(includeCachedImages: true))
.map(ContentPrunable.init)
return result
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
}
func pull(
_ name: RemoteName,
registry: Registry,
concurrency: UInt,
deduplicate: Bool,
requireManifest: Bool = false,
resolvedManifest: (manifest: OCIManifest, data: Data)? = nil
) async throws {
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "oci.image-name",
value: .string(name.description)
@ -390,23 +152,12 @@ class VMStorageOCI: PrunableStorage {
defaultLogger.appendNewLine("pulling manifest...")
let (manifest, manifestData): (OCIManifest, Data)
if let resolvedManifest {
manifest = resolvedManifest.manifest
manifestData = resolvedManifest.data
} else {
(manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
}
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
let digestName = RemoteName(host: name.host, namespace: name.namespace,
reference: Reference(digest: Digest.hash(manifestData)))
if try hasCompleteLinkedImage(
name,
digestName: digestName,
manifest: manifest,
requireManifest: requireManifest
) {
if exists(name) && exists(digestName) && 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
@ -430,22 +181,11 @@ class VMStorageOCI: PrunableStorage {
throw CancellationError()
}
let digestVMDir = VMDirectory(baseURL: vmURL(digestName))
if requireManifest,
!FileManager.default.fileExists(atPath: digestVMDir.manifestURL.path),
try hasCompleteCachedImage(digestName, manifest: manifest) {
// Old Tart versions cached standalone OCI images without manifest.json.
// A stacked clone needs the manifest to describe its immutable base, but
// the existing disk remains usable and must not be downloaded again.
try manifestData.write(to: digestVMDir.manifestURL, options: .atomic)
}
if try !hasCompleteCachedImage(digestName, manifest: manifest, requireManifest: requireManifest) {
if !exists(digestName) {
let span = OTel.shared.tracer.spanBuilder(spanName: "pull").setActive(true).startSpan()
defer { span.end() }
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
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
@ -455,47 +195,22 @@ class VMStorageOCI: PrunableStorage {
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
// Make in-progress stacked content references visible before reclaiming
// space or reconstructing immutable files.
try ContentStore().withPruneLock {
try manifestData.write(to: tmpVMDir.manifestURL)
}
// A previously pulled standalone image already has the complete base
// disk locally as disk.img. Promote that file into the content store
// before sizing or pulling so a stacked child only fetches overlays.
try reuseStandaloneDiskForStackedBaseIfPossible(manifest)
// Try to reclaim some cache space if we know the VM size in advance
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)
)
}
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "oci.image-uncompressed-disk-size-bytes",
value: .int(Int(uncompressedDiskSize))
)
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(requiredStorage.partialValue)
try Prune.reclaimIfNeeded(uncompressedDiskSize + otherVMFilesSize)
}
try await withTaskCancellationHandler(operation: {
try await retry(maxAttempts: 5) {
// Existing standalone 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
}
// Choose the best base image which has the most deduplication ratio
let localLayerCache = try await chooseLocalLayerCache(name, manifest, registry)
if let llc = localLayerCache {
let deduplicatedHuman = ByteCountFormatter.string(fromByteCount: Int64(llc.deduplicatedBytes), countStyle: .file)
@ -517,14 +232,9 @@ class VMStorageOCI: PrunableStorage {
return .throw
}
if preserveExplicitlyPulledMark {
tmpVMDir.markExplicitlyPulled()
}
try move(digestName, from: tmpVMDir)
}, onCancel: {
try? tmpVMDir.removeFromDisk()
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
} else {
defaultLogger.appendNewLine("\(digestName) image is already cached! creating a symlink...")
@ -543,115 +253,6 @@ class VMStorageOCI: PrunableStorage {
_ = try VMStorageOCI().open(name)
}
/// Returns nil for standalone images and the missing immutable disk-file
/// groups for stacked images. Like existing standalone cached images, cache hits trust
/// already-installed files; checking size still repairs truncated entries
/// without hashing a large prewarmed base on every 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 {
if try !hasUsableCachedDiskFile(group, contentStore: contentStore) {
missingGroups.append(group)
}
}
return missingGroups
}
/// Seed a stacked image's immutable base from an already pulled standalone
/// OCI record when both manifests describe the same transport chunks. The
/// content store still verifies the whole-file digest before publishing it.
func reuseStandaloneDiskForStackedBaseIfPossible(_ manifest: OCIManifest) throws {
guard case .stacked(let base, _) = try manifest.tartDiskRepresentation(),
let contentDigest = base.contentDigest else {
return
}
let contentStore = try ContentStore()
var attemptedCandidates = Swift.Set<String>()
while true {
// Keep the source record alive only while cloning its disk. The pull's
// in-progress manifest already protects the destination content digest,
// so hashing and installing the staged clone need not hold the global
// prune lock.
let temporaryURL = try contentStore.withPruneLock { () -> URL? in
// Content-store entries are verified when installed. Avoid hashing a
// potentially large prewarmed base again on every stacked pull.
guard try contentStore.contentURLIfPresent(for: contentDigest) == nil else {
return nil
}
for (_, vmDir, isSymlink) in try list() where !isSymlink && vmDir.isStandalone {
guard !attemptedCandidates.contains(vmDir.baseURL.path),
let manifestData = try? Data(contentsOf: vmDir.manifestURL),
let candidateManifest = try? OCIManifest(fromJSON: manifestData),
case .flat(let candidateBase) = try? candidateManifest.tartDiskRepresentation(),
diskChunksMatch(candidateBase.chunks, base.chunks) else {
continue
}
attemptedCandidates.insert(vmDir.baseURL.path)
let temporaryURL = try contentStore.temporaryContentURL(for: contentDigest)
do {
try FileManager.default.copyItem(at: vmDir.diskURL, to: temporaryURL)
return temporaryURL
} catch {
try? FileManager.default.removeItem(at: temporaryURL)
throw error
}
}
return nil
}
guard let temporaryURL else {
return
}
do {
_ = try contentStore.install(temporaryURL, contentDigest: contentDigest)
return
} catch ContentStoreError.contentDigestMismatch {
try? FileManager.default.removeItem(at: temporaryURL)
} catch {
try? FileManager.default.removeItem(at: temporaryURL)
throw error
}
}
}
/// Compare the OCI transport identity while ignoring stacked-only
/// whole-file annotations added to the first base chunk.
private func diskChunksMatch(_ left: [OCIManifestLayer], _ right: [OCIManifestLayer]) -> Bool {
guard left.count == right.count else {
return false
}
return zip(left, right).allSatisfy { left, right in
left.mediaType == right.mediaType &&
left.size == right.size &&
left.digest == right.digest &&
left.uncompressedSize() == right.uncompressedSize() &&
left.uncompressedContentDigest() == right.uncompressedContentDigest()
}
}
private func hasUsableCachedDiskFile(_ group: TartDiskFileGroup, contentStore: ContentStore) throws -> Bool {
guard let contentDigest = group.contentDigest,
let contentURL = try contentStore.contentURLIfPresent(for: contentDigest),
let actualSize = UInt64(exactly: try contentURL.sizeBytes()),
let expectedSize = group.uncompressedSize() else {
return false
}
return actualSize == expectedSize
}
func linked(from: RemoteName, to: RemoteName) -> Bool {
do {
let resolvedFrom = try FileManager.default.destinationOfSymbolicLink(atPath: vmURL(from).path)
@ -662,13 +263,9 @@ class VMStorageOCI: PrunableStorage {
}
func link(from: RemoteName, to: RemoteName) throws {
// Export resolves mutable tags while holding this same lock, so replace
// the symlink atomically with respect to stacked archive staging.
let contentStore = try ContentStore()
try contentStore.withPruneLock {
try? FileManager.default.removeItem(at: vmURL(from))
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
}
try? FileManager.default.removeItem(at: vmURL(from))
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
try gc()
}
@ -683,16 +280,10 @@ class VMStorageOCI: PrunableStorage {
}
// Load OCI VM images and their manifests (if present)
var candidates: [(
name: String,
vmDir: VMDirectory,
manifest: OCIManifest,
manifestDigest: String,
deduplicatedBytes: UInt64
)] = []
var candidates: [(name: String, vmDir: VMDirectory, manifest: OCIManifest, deduplicatedBytes: UInt64)] = []
for (name, vmDir, isSymlink) in try list() {
if isSymlink || !vmDir.isStandalone {
if isSymlink {
continue
}
@ -704,13 +295,7 @@ class VMStorageOCI: PrunableStorage {
continue
}
candidates.append((
name,
vmDir,
manifest,
Digest.hash(manifestJSON),
calculateDeduplicatedBytes(manifest)
))
candidates.append((name, vmDir, manifest, calculateDeduplicatedBytes(manifest)))
}
// Previously we haven't stored the OCI VM image manifests, but still fetched the VM image manifest if
@ -720,17 +305,10 @@ 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),
!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)
))
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)))
}
// Now, find the best match based on how many bytes we'll deduplicate
@ -744,108 +322,6 @@ class VMStorageOCI: PrunableStorage {
try LocalLayerCache(choosen.name, choosen.deduplicatedBytes, choosen.vmDir.diskURL, choosen.manifest)
})
}
/// Returns content referenced outside the OCI cache, optionally including
/// references published by retained cached images.
private func referencedContentDigests(includeCachedImages: Bool) throws -> Swift.Set<String> {
var result = Swift.Set<String>()
for (_, vmDir) in try VMStorageLocal().list() where vmDir.isStackedVM {
result.formUnion(try vmDir.diskContentDigests())
}
// Clone, pull, and import publish their manifest before installing
// immutable content. Include partially populated temporary directories so
// pruning cannot race those operations.
for url in try FileManager.default.contentsOfDirectory(
at: Config().tartTmpDir,
includingPropertiesForKeys: [],
options: .skipsHiddenFiles
) {
let vmDir = VMDirectory(baseURL: url)
guard FileManager.default.fileExists(atPath: vmDir.manifestURL.path),
let contentDigests = try? vmDir.diskContentDigests() else {
continue
}
result.formUnion(contentDigests)
}
if includeCachedImages {
for (_, vmDir, isSymlink) in try list() where !isSymlink && vmDir.isStackedCachedImage {
// Malformed cached records are invalid references. Keep scanning so
// one interrupted population does not disable content GC globally.
if let contentDigests = try? vmDir.diskContentDigests() {
result.formUnion(contentDigests)
}
}
}
return result
}
fileprivate func deleteContentIfUnused(_ url: URL) throws {
let contentStore = try ContentStore()
try contentStore.withPruneLock {
let referencedContentDigests = try referencedContentDigests(includeCachedImages: true)
let stillPrunable = try contentStore.prunables(excluding: referencedContentDigests).contains {
$0.resolvingSymlinksInPath() == url.resolvingSymlinksInPath()
}
if stillPrunable {
try FileManager.default.removeItem(at: url)
}
}
}
}
private struct ContentPrunable: Prunable {
let url: URL
func delete() throws {
try VMStorageOCI().deleteContentIfUnused(url)
}
func accessDate() throws -> Date {
try url.accessDate()
}
func sizeBytes() throws -> Int {
try url.sizeBytes()
}
func allocatedSizeBytes() throws -> Int {
try url.allocatedSizeBytes()
}
}
/// A digest-addressed cached image plus immutable content attributed to the
/// final remote reference that can release it.
private struct CachedImagePrunable: Prunable {
let vmDir: VMDirectory
let ownedContentURLs: [URL]
var url: URL {
vmDir.url
}
func delete() throws {
try vmDir.delete()
// Deleting a record can make attributed content unreferenced. Run GC now
// so one prune invocation reclaims those bytes.
try VMStorageOCI().gcContent()
}
func accessDate() throws -> Date {
try vmDir.accessDate()
}
func sizeBytes() throws -> Int {
try vmDir.sizeBytes() + ownedContentURLs.map { try $0.sizeBytes() }.reduce(0, +)
}
func allocatedSizeBytes() throws -> Int {
try vmDir.allocatedSizeBytes() + ownedContentURLs.map { try $0.allocatedSizeBytes() }.reduce(0, +)
}
}
extension URL {

View File

@ -1,152 +0,0 @@
import Foundation
import ArgumentParser
import XCTest
@testable import tart
final class CommandBehaviorTests: XCTestCase {
func testStandaloneDeleteDoesNotInitializeContentStore() throws {
try withTemporaryTartHome {
let vmDir = try VMStorageLocal().create("standalone")
try config().save(toURL: vmDir.configURL)
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.diskURL.path, contents: Data()))
let contentStoreURL = try Config().tartCacheDir.appendingPathComponent("content", isDirectory: true)
XCTAssertFalse(FileManager.default.fileExists(atPath: contentStoreURL.path))
try vmDir.delete()
XCTAssertFalse(FileManager.default.fileExists(atPath: vmDir.baseURL.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: contentStoreURL.path))
}
}
func testSetDiskRejectsStackedVMBeforeSavingConfig() async throws {
try await withTemporaryTartHome {
let vmDir = try VMStorageLocal().create("stacked")
let originalConfig = config()
try originalConfig.save(toURL: vmDir.configURL)
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.manifestURL.path, contents: Data()))
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.overlayURL.path, contents: Data()))
let replacementURL = try temporaryDirectory().appendingPathComponent("replacement.img")
XCTAssertTrue(FileManager.default.createFile(atPath: replacementURL.path, contents: Data("replacement".utf8)))
let command = try Set.parseAsRoot([
"stacked",
"--cpu", "4",
"--disk", replacementURL.path,
]) as! Set
do {
try await command.run()
XCTFail("expected stacked disk replacement to be rejected")
} catch let error as ValidationError {
XCTAssertEqual(error.message, "--disk is not supported for VMs with a stacked disk")
}
XCTAssertEqual(try VMConfig(fromURL: vmDir.configURL).cpuCount, originalConfig.cpuCount)
XCTAssertFalse(FileManager.default.fileExists(atPath: vmDir.diskURL.path))
}
}
func testRemoteAdditionalDiskRetainsTemporaryBackingFileLock() throws {
try withTemporaryTartHome {
let storage = try VMStorageOCI()
let name = try RemoteName("example.com/org/image:latest")
let cachedImage = try storage.create(name)
try config().save(toURL: cachedImage.configURL)
XCTAssertTrue(FileManager.default.createFile(atPath: cachedImage.nvramURL.path, contents: Data()))
XCTAssertTrue(FileManager.default.createFile(
atPath: cachedImage.diskURL.path,
contents: Data(repeating: 0, count: 4096)
))
do {
let additionalDisk = try AdditionalDisk(parseFrom: name.description)
let entriesBeforeGC = try temporaryEntries()
XCTAssertEqual(entriesBeforeGC.count, 1)
try Config().gc()
XCTAssertEqual(try temporaryEntries(), entriesBeforeGC)
withExtendedLifetime(additionalDisk) {}
}
try Config().gc()
XCTAssertTrue(try temporaryEntries().isEmpty)
}
}
func testGarbageCollectionPreservesLockedTemporaryDirectory() throws {
try withTemporaryTartHome {
let temporaryVMDir = try VMDirectory.temporary()
let lock = try FileLock(lockURL: temporaryVMDir.baseURL)
try lock.lock()
XCTAssertTrue(FileManager.default.createFile(
atPath: temporaryVMDir.overlayURL.path,
contents: Data("overlay".utf8)
))
try Config().gc()
XCTAssertTrue(FileManager.default.fileExists(atPath: temporaryVMDir.overlayURL.path))
try lock.unlock()
try Config().gc()
XCTAssertFalse(FileManager.default.fileExists(atPath: temporaryVMDir.baseURL.path))
}
}
private func config() -> VMConfig {
VMConfig(
platform: Linux(),
cpuCountMin: 2,
memorySizeMin: 512 * 1024 * 1024,
diskFormat: .raw
)
}
private func temporaryEntries() throws -> [URL] {
try FileManager.default.contentsOfDirectory(
at: Config().tartTmpDir,
includingPropertiesForKeys: nil
)
}
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 { restoreEnvironment("TART_HOME", to: previousHome) }
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 { restoreEnvironment("TART_HOME", to: previousHome) }
try await body()
}
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
}
private func restoreEnvironment(_ name: String, to value: String?) {
if let value {
setenv(name, value, 1)
} else {
unsetenv(name)
}
}
}

View File

@ -1,190 +0,0 @@
import Foundation
import XCTest
@testable import tart
final class ContentStoreTests: XCTestCase {
func testCreatesDigestDirectoryDuringInitialization() throws {
let store = try temporaryStore()
let contentURL = try store.contentURL(for: Digest.hash(Data()))
XCTAssertTrue(FileManager.default.fileExists(atPath: contentURL.deletingLastPathComponent().path))
}
func testInstallAndValidatedLookup() throws {
let store = try temporaryStore()
let data = Data("base disk".utf8)
let digest = Digest.hash(data)
let temporaryURL = try store.temporaryContentURL(for: digest)
try data.write(to: temporaryURL)
let installedURL = try store.install(temporaryURL, contentDigest: digest)
XCTAssertEqual(installedURL, try store.contentURL(for: digest))
XCTAssertEqual(try store.existingContentURL(for: digest), installedURL)
}
func testCorruptCacheEntryIsMiss() throws {
let store = try temporaryStore()
let expectedDigest = Digest.hash(Data("expected".utf8))
let contentURL = try store.contentURL(for: expectedDigest)
try FileManager.default.createDirectory(at: contentURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("corrupt".utf8).write(to: contentURL)
XCTAssertNil(try store.existingContentURL(for: expectedDigest))
XCTAssertEqual(try store.contentURLIfPresent(for: expectedDigest), contentURL)
}
func testResumableAndLockURLsAreStablePerDigest() throws {
let store = try temporaryStore()
let firstDigest = Digest.hash(Data("first".utf8))
let secondDigest = Digest.hash(Data("second".utf8))
XCTAssertEqual(
try store.resumableContentURL(for: firstDigest),
try store.resumableContentURL(for: firstDigest)
)
XCTAssertNotEqual(
try store.resumableContentURL(for: firstDigest),
try store.resumableContentURL(for: secondDigest)
)
XCTAssertEqual(
try store.lockURL(for: firstDigest),
try store.lockURL(for: firstDigest)
)
XCTAssertTrue(FileManager.default.fileExists(atPath: try store.lockURL(for: firstDigest).path))
}
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))
let temporaryURL = try store.temporaryContentURL(for: expectedDigest)
try Data("actual".utf8).write(to: temporaryURL)
XCTAssertThrowsError(try store.install(temporaryURL, contentDigest: expectedDigest)) { error in
guard case ContentStoreError.contentDigestMismatch(let expected, _) = error else {
return XCTFail("unexpected error: \(error)")
}
XCTAssertEqual(expected, expectedDigest)
}
}
func testRejectsNonCanonicalDigest() throws {
let store = try temporaryStore()
XCTAssertThrowsError(try store.contentURL(for: "sha256:ABC")) { error in
XCTAssertEqual(error as? ContentStoreError, .invalidContentDigest("sha256:ABC"))
}
}
func testContentURLUnderArbitraryRootHasNoSideEffects() throws {
let rootURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
addTeardownBlock {
try? FileManager.default.removeItem(at: rootURL)
}
let digest = Digest.hash(Data("content".utf8))
let contentStore = try temporaryStore()
let contentURL = try contentStore.contentURL(for: digest, under: rootURL)
XCTAssertEqual(
contentURL,
rootURL.appendingPathComponent("sha256", isDirectory: true)
.appendingPathComponent(String(digest.dropFirst("sha256:".count)))
)
XCTAssertFalse(FileManager.default.fileExists(atPath: rootURL.path))
}
private func temporaryStore() throws -> ContentStore {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
addTeardownBlock {
try? FileManager.default.removeItem(at: url)
}
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)
}
}
}

View File

@ -1,55 +0,0 @@
import XCTest
@testable import tart
@available(macOS 14, *)
final class ControlSocketTests: XCTestCase {
func testInitializerCreatesControlSocketBeforeReturning() async throws {
let temporaryDirectory = try makeTemporaryDirectory()
let originalDirectory = FileManager.default.currentDirectoryPath
defer {
FileManager.default.changeCurrentDirectoryPath(originalDirectory)
try? FileManager.default.removeItem(at: temporaryDirectory)
}
let socketURL = URL(fileURLWithPath: "control.sock", relativeTo: temporaryDirectory)
var controlSocket: ControlSocket? = try await ControlSocket(socketURL)
let eventLoopGroup = try XCTUnwrap(controlSocket?.eventLoopGroup)
do {
let serverChannel = try XCTUnwrap(controlSocket?.serverChannel)
XCTAssertTrue(FileManager.default.fileExists(atPath: socketURL.path))
try await serverChannel.executeThenClose { _ in }
}
controlSocket = nil
try await eventLoopGroup.shutdownGracefully()
}
func testInitializerPropagatesControlSocketCreationFailure() async throws {
let temporaryDirectory = try makeTemporaryDirectory()
let originalDirectory = FileManager.default.currentDirectoryPath
defer {
FileManager.default.changeCurrentDirectoryPath(originalDirectory)
try? FileManager.default.removeItem(at: temporaryDirectory)
}
let socketURL = URL(fileURLWithPath: "missing/control.sock", relativeTo: temporaryDirectory)
do {
_ = try await ControlSocket(socketURL)
XCTFail("Binding should fail when the socket's parent directory does not exist")
} catch {
XCTAssertFalse(FileManager.default.fileExists(atPath: socketURL.path))
}
}
private func makeTemporaryDirectory() throws -> URL {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
return directory
}
}

View File

@ -1,4 +1,3 @@
import Foundation
import XCTest
@testable import tart
@ -22,34 +21,4 @@ final class DigestTests: XCTestCase {
XCTAssertEqual(Digest.hash(data), "sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592")
}
func testFileAndRangeHashingMatchDataHashing() throws {
let prefix = Data(repeating: 0x61, count: 4 * 1024 * 1024 + 17)
let range = Data("range".utf8)
let suffix = Data(repeating: 0x62, count: 23)
let data = prefix + range + suffix
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try data.write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
XCTAssertEqual(try Digest.hash(url), Digest.hash(data))
XCTAssertEqual(try Digest.hash(url, offset: UInt64(prefix.count), size: UInt64(range.count)), Digest.hash(range))
}
func testRangeHashingRejectsOutOfBoundsRanges() throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try Data("range".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
XCTAssertThrowsError(try Digest.hash(url, offset: 6, size: 0)) { error in
guard case DigestError.InvalidOffset = error else {
return XCTFail("unexpected error: \(error)")
}
}
XCTAssertThrowsError(try Digest.hash(url, offset: 1, size: UInt64.max)) { error in
guard case DigestError.InvalidSize = error else {
return XCTFail("unexpected error: \(error)")
}
}
}
}

View File

@ -1,263 +0,0 @@
import Foundation
import XCTest
@testable import tart
#if canImport(DiskImageKit)
import DiskImageKit
@available(macOS 27.0, *)
final class DiskImageStackTests: XCTestCase {
override func setUpWithError() throws {
try super.setUpWithError()
if #unavailable(macOS 27.0) {
throw XCTSkip("DiskImageKit tests require macOS 27 or newer")
}
}
func testCreatesAndAttachesRawBaseWithWritableOverlay() throws {
let fixture = try Fixture(baseFormat: .raw)
try fixture.disk.createWritableOverlay()
XCTAssertTrue(FileManager.default.fileExists(atPath: fixture.disk.writableOverlayURL.path))
_ = try fixture.disk.makeAttachment()
}
func testCreatesAndAttachesASIFBaseWithPublishedOverlay() throws {
let fixture = try Fixture(baseFormat: .asif, publishedOverlayCount: 1)
try fixture.disk.createWritableOverlay()
_ = try fixture.disk.makeAttachment()
}
func testCreatesAndAttachesMultiplePublishedOverlays() throws {
let fixture = try Fixture(baseFormat: .asif, publishedOverlayCount: 2)
try fixture.disk.createWritableOverlay()
_ = try fixture.disk.makeAttachment()
}
func testCreatesAndAttachesLongPublishedOverlayChain() throws {
let fixture = try Fixture(baseFormat: .asif, publishedOverlayCount: 8)
try fixture.disk.createWritableOverlay()
_ = 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)
assertThrows(.writableOverlayMissing(fixture.disk.writableOverlayURL)) {
try fixture.disk.makeAttachment()
}
}
func testRejectsExistingWritableOverlayWhenCreating() throws {
let fixture = try Fixture(baseFormat: .raw)
XCTAssertTrue(FileManager.default.createFile(atPath: fixture.disk.writableOverlayURL.path, contents: nil))
assertThrows(.writableOverlayAlreadyExists(fixture.disk.writableOverlayURL)) {
try fixture.disk.createWritableOverlay()
}
}
func testRejectsExistingCopyDestination() throws {
let fixture = try Fixture(baseFormat: .raw)
try fixture.disk.createWritableOverlay()
let destinationURL = fixture.directory.appendingPathComponent("existing-overlay.asif")
XCTAssertTrue(FileManager.default.createFile(atPath: destinationURL.path, contents: nil))
assertThrows(.writableOverlayAlreadyExists(destinationURL)) {
try fixture.disk.copyWritableOverlay(to: destinationURL)
}
}
func testRejectsNonASIFPublishedOverlay() throws {
let fixture = try Fixture(baseFormat: .raw)
let overlayURL = fixture.directory.appendingPathComponent("published-raw.img")
_ = try DiskImage(creating: .raw(url: overlayURL, blockCount: 8))
fixture.disk = DiskImageStack(
baseURL: fixture.disk.baseURL,
baseFormat: fixture.disk.baseFormat,
immutableOverlayURLs: [
overlayURL,
],
writableOverlayURL: fixture.disk.writableOverlayURL,
blockSize: fixture.disk.blockSize,
blockCount: fixture.disk.blockCount
)
assertThrows(.invalidDiskImage(overlayURL, "overlay must use ASIF format")) {
try fixture.disk.createWritableOverlay()
}
}
func testRejectsWrongBaseFormat() throws {
let fixture = try Fixture(baseFormat: .raw)
fixture.disk = DiskImageStack(
baseURL: fixture.disk.baseURL,
baseFormat: .asif,
immutableOverlayURLs: fixture.disk.immutableOverlayURLs,
writableOverlayURL: fixture.disk.writableOverlayURL,
blockSize: fixture.disk.blockSize,
blockCount: fixture.disk.blockCount
)
assertThrows(.invalidDiskImage(fixture.disk.baseURL, "base disk format does not match")) {
try fixture.disk.createWritableOverlay()
}
}
func testRejectsBlockSizeMismatch() throws {
let fixture = try Fixture(baseFormat: .raw)
fixture.disk = DiskImageStack(
baseURL: fixture.disk.baseURL,
baseFormat: fixture.disk.baseFormat,
immutableOverlayURLs: fixture.disk.immutableOverlayURLs,
writableOverlayURL: fixture.disk.writableOverlayURL,
blockSize: 4096,
blockCount: fixture.disk.blockCount
)
assertThrows(.invalidBlockLayout("immutable disk stack does not match manifest block size")) {
try fixture.disk.createWritableOverlay()
}
}
func testRejectsUnsupportedBlockSize() throws {
let fixture = try Fixture(baseFormat: .raw)
fixture.disk = DiskImageStack(
baseURL: fixture.disk.baseURL,
baseFormat: fixture.disk.baseFormat,
immutableOverlayURLs: fixture.disk.immutableOverlayURLs,
writableOverlayURL: fixture.disk.writableOverlayURL,
blockSize: 123,
blockCount: fixture.disk.blockCount
)
assertThrows(.invalidBlockLayout("unsupported stacked disk block size 123")) {
try fixture.disk.createWritableOverlay()
}
}
func testRejectsManifestBlockCountMismatch() throws {
let fixture = try Fixture(baseFormat: .raw)
fixture.disk = DiskImageStack(
baseURL: fixture.disk.baseURL,
baseFormat: fixture.disk.baseFormat,
immutableOverlayURLs: fixture.disk.immutableOverlayURLs,
writableOverlayURL: fixture.disk.writableOverlayURL,
blockSize: fixture.disk.blockSize,
blockCount: fixture.disk.blockCount + 1
)
assertThrows(.invalidBlockLayout("immutable disk stack does not match manifest block count")) {
try fixture.disk.createWritableOverlay()
}
}
func testCopiesAndGrowsWritableOverlay() throws {
let fixture = try Fixture(baseFormat: .raw)
try fixture.disk.createWritableOverlay()
let copiedURL = fixture.directory.appendingPathComponent("copied-overlay.asif")
try fixture.disk.copyWritableOverlay(to: copiedURL)
fixture.disk = DiskImageStack(
baseURL: fixture.disk.baseURL,
baseFormat: fixture.disk.baseFormat,
immutableOverlayURLs: fixture.disk.immutableOverlayURLs,
writableOverlayURL: copiedURL,
blockSize: fixture.disk.blockSize,
blockCount: fixture.disk.blockCount
)
try fixture.disk.growWritableOverlay(toBlockCount: 16)
let copied = try DiskImage(opening: .open(url: copiedURL, mode: .readOnly))
XCTAssertEqual(copied.blockCount, 16)
}
func testRejectsOverlayFromDifferentASIFParent() throws {
let fixture = try Fixture(baseFormat: .asif)
let other = try Fixture(baseFormat: .asif, publishedOverlayCount: 1)
fixture.disk = DiskImageStack(
baseURL: fixture.disk.baseURL,
baseFormat: fixture.disk.baseFormat,
immutableOverlayURLs: other.disk.immutableOverlayURLs,
writableOverlayURL: fixture.disk.writableOverlayURL,
blockSize: fixture.disk.blockSize,
blockCount: fixture.disk.blockCount
)
assertThrows(.invalidDiskImage(other.disk.immutableOverlayURLs[0], "ASIF overlay is incompatible with its parent")) {
try fixture.disk.createWritableOverlay()
}
}
func testRejectsWritableOverlayShrink() throws {
let fixture = try Fixture(baseFormat: .raw)
try fixture.disk.createWritableOverlay()
assertThrows(.invalidDiskImage(fixture.disk.writableOverlayURL, "ASIF overlay block count shrinks the stacked disk")) {
try fixture.disk.growWritableOverlay(toBlockCount: 4)
}
}
private func assertThrows<T>(
_ expected: DiskImageStackError,
operation: () throws -> T
) {
XCTAssertThrowsError(try operation()) { error in
XCTAssertEqual(error as? DiskImageStackError, expected)
}
}
private final class Fixture {
let directory: URL
var disk: DiskImageStack
init(baseFormat: DiskImageFormat, publishedOverlayCount: Int = 0) throws {
directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
let baseURL = directory.appendingPathComponent("base.img")
switch baseFormat {
case .raw:
_ = try DiskImage(creating: .raw(url: baseURL, blockCount: 8))
case .asif:
_ = try DiskImage(creating: .asif(url: baseURL, blockCount: 8, blockSize: .bytes512))
}
var immutableOverlayURLs: [URL] = []
var image = try DiskImage(opening: .open(url: baseURL, mode: .readOnly))
for index in 0..<publishedOverlayCount {
let overlayURL = directory.appendingPathComponent("published-\(index).asif")
let stack = try image.appending(.asifLayer(url: overlayURL, type: .overlay))
immutableOverlayURLs.append(overlayURL)
image = stack
}
disk = DiskImageStack(
baseURL: baseURL,
baseFormat: baseFormat,
immutableOverlayURLs: immutableOverlayURLs,
writableOverlayURL: directory.appendingPathComponent("overlay.asif"),
blockSize: 512,
blockCount: 8
)
}
deinit {
try? FileManager.default.removeItem(at: directory)
}
}
}
#endif

View File

@ -1,15 +0,0 @@
import Foundation
import XCTest
@testable import tart
final class HumanReadableByteCountTests: XCTestCase {
func testTextAndJSONRepresentations() throws {
let integer = HumanReadableByteCount(51_400_000_000) { _ in 51 }
let string = HumanReadableByteCount(17_234_000_000) { _ in "17.234" }
let encoder = JSONEncoder()
XCTAssertEqual(string.description.compactMap(\.wholeNumberValue), [1, 7])
XCTAssertEqual(try JSONDecoder().decode(Int.self, from: encoder.encode(integer)), 51)
XCTAssertEqual(try JSONDecoder().decode(String.self, from: encoder.encode(string)), "17.234")
}
}

View File

@ -36,14 +36,7 @@ final class LayerizerTests: XCTestCase {
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
print("pushing disk...")
let diskLayers = try await DiskV2.push(
diskURL: originalDiskFileURL,
mediaType: diskV2MediaType,
registry: registry,
chunkSizeMb: 0,
concurrency: 4,
progress: Progress()
)
let diskLayers = try await DiskV2.push(diskURL: originalDiskFileURL, 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())

View File

@ -1,196 +0,0 @@
import XCTest
@testable import tart
final class OCIManifestTests: XCTestCase {
func testFlatDiskRepresentation() throws {
let chunks = [chunk(mediaType: diskV2MediaType, suffix: "base-0")]
let representation = try manifest(diskDescriptors: chunks).tartDiskRepresentation()
XCTAssertEqual(representation, .flat(base: TartDiskFileGroup(kind: .base, chunks: chunks, contentDigest: nil)))
}
func testStackedDiskRepresentation() throws {
let base = chunk(mediaType: diskV2MediaType, suffix: "base-0", diskFileDigest: "sha256:base")
let overlay0 = chunk(mediaType: asifOverlayMediaType, suffix: "overlay-0", diskFileDigest: "sha256:overlay", chunkCount: 2)
let overlay1 = chunk(mediaType: asifOverlayMediaType, suffix: "overlay-1")
let representation = try manifest(diskDescriptors: [base, overlay0, overlay1]).tartDiskRepresentation()
XCTAssertEqual(representation, .stacked(
base: TartDiskFileGroup(kind: .base, chunks: [base], contentDigest: "sha256:base"),
overlays: [TartDiskFileGroup(kind: .asifOverlay, chunks: [overlay0, overlay1], contentDigest: "sha256:overlay")]
))
}
func testDiskFileGroupUncompressedSize() {
let first = chunk(mediaType: diskV2MediaType, suffix: "base-0")
let second = chunk(mediaType: diskV2MediaType, suffix: "base-1")
XCTAssertEqual(TartDiskFileGroup(kind: .base, chunks: [first, second], contentDigest: nil).uncompressedSize(), 2)
var overflowing = first
overflowing.annotations?[uncompressedSizeAnnotation] = String(UInt64.max)
XCTAssertNil(TartDiskFileGroup(kind: .base, chunks: [overflowing, second], contentDigest: nil).uncompressedSize())
}
func testDiskContentDigests() throws {
let flatBase = chunk(mediaType: diskV2MediaType, suffix: "flat", diskFileDigest: "sha256:flat")
XCTAssertEqual(try manifest(diskDescriptors: [flatBase]).diskContentDigests(), ["sha256:flat"])
let stackedBase = chunk(mediaType: diskV2MediaType, suffix: "base", diskFileDigest: "sha256:base")
let overlay = chunk(
mediaType: asifOverlayMediaType,
suffix: "overlay",
diskFileDigest: "sha256:overlay",
chunkCount: 1
)
XCTAssertEqual(
try manifest(diskDescriptors: [stackedBase, overlay]).diskContentDigests(),
["sha256:base", "sha256:overlay"]
)
}
func testStackedRepresentationRequiresBaseDigest() throws {
let base = chunk(mediaType: diskV2MediaType, suffix: "base-0")
let overlay = chunk(mediaType: asifOverlayMediaType, suffix: "overlay-0", diskFileDigest: "sha256:overlay", chunkCount: 1)
assertManifestError(
.invalidDiskMetadata("a stacked base disk needs a whole-file content digest"),
diskDescriptors: [base, overlay]
)
}
func testBaseGroupRejectsMetadataAfterFirstChunk() throws {
let base0 = chunk(mediaType: diskV2MediaType, suffix: "base-0", diskFileDigest: "sha256:base")
let base1 = chunk(mediaType: diskV2MediaType, suffix: "base-1", diskFileDigest: "sha256:other-base")
assertManifestError(
.invalidDiskMetadata("base disk metadata must appear only on its first chunk"),
diskDescriptors: [base0, base1]
)
}
func testBaseGroupRejectsOverlayChunkCount() throws {
let base = chunk(mediaType: diskV2MediaType, suffix: "base-0", diskFileDigest: "sha256:base", chunkCount: 1)
assertManifestError(
.invalidDiskMetadata("base disk metadata must appear only on its first chunk"),
diskDescriptors: [base]
)
}
func testOverlayGroupRequiresDigestAndChunkCount() throws {
let base = chunk(mediaType: diskV2MediaType, suffix: "base-0", diskFileDigest: "sha256:base")
let overlay = chunk(mediaType: asifOverlayMediaType, suffix: "overlay-0")
assertManifestError(
.invalidDiskMetadata("an ASIF overlay needs a content digest and chunk count"),
diskDescriptors: [base, overlay]
)
}
func testOverlayGroupRejectsInconsistentChunkCount() throws {
let base = chunk(mediaType: diskV2MediaType, suffix: "base-0", diskFileDigest: "sha256:base")
let overlay = chunk(mediaType: asifOverlayMediaType, suffix: "overlay-0", diskFileDigest: "sha256:overlay", chunkCount: 2)
assertManifestError(
.invalidDiskMetadata("ASIF overlay chunk count is invalid"),
diskDescriptors: [base, overlay]
)
}
func testDiskV2AfterOverlayIsRejected() throws {
let base = chunk(mediaType: diskV2MediaType, suffix: "base-0", diskFileDigest: "sha256:base")
let overlay = chunk(mediaType: asifOverlayMediaType, suffix: "overlay-0", diskFileDigest: "sha256:overlay", chunkCount: 2)
let lateBase = chunk(mediaType: diskV2MediaType, suffix: "base-1")
assertManifestError(
.invalidLayout("ASIF overlay chunks must be contiguous"),
diskDescriptors: [base, overlay, lateBase]
)
}
func testChunkMetadataIsRequired() throws {
var base = chunk(mediaType: diskV2MediaType, suffix: "base-0")
base.annotations = nil
assertManifestError(
.invalidDiskMetadata("disk chunks need uncompressed size and content digest"),
diskDescriptors: [base]
)
}
func testCanonicalConfigAndNVRAMOrderIsRequired() throws {
let disk = chunk(mediaType: diskV2MediaType, suffix: "base-0")
let manifest = OCIManifest(
config: OCIManifestConfig(size: 1, digest: "sha256:oci-config"),
layers: [disk, configLayer(), nvramLayer()]
)
XCTAssertThrowsError(try manifest.tartDiskRepresentation()) { error in
XCTAssertEqual(
error as? OCIManifestValidationError,
.invalidLayout("descriptors must be ordered as config, disk chunks, then NVRAM")
)
}
}
func testManifestBlockLayout() throws {
var manifest = manifest(diskDescriptors: [chunk(mediaType: diskV2MediaType, suffix: "base-0")])
manifest.annotations = [
uncompressedDiskSizeAnnotation: "100000000000",
diskBlockSizeAnnotation: "512",
]
XCTAssertEqual(manifest.diskBlockSize(), 512)
XCTAssertEqual(manifest.diskBlockCount(), 195312500)
}
func testManifestRejectsInexactDerivedBlockCount() throws {
var manifest = manifest(diskDescriptors: [chunk(mediaType: diskV2MediaType, suffix: "base-0")])
manifest.annotations = [
uncompressedDiskSizeAnnotation: "513",
diskBlockSizeAnnotation: "512",
]
XCTAssertNil(manifest.diskBlockCount())
}
private func assertManifestError(_ expected: OCIManifestValidationError, diskDescriptors: [OCIManifestLayer]) {
XCTAssertThrowsError(try manifest(diskDescriptors: diskDescriptors).tartDiskRepresentation()) { error in
XCTAssertEqual(error as? OCIManifestValidationError, expected)
}
}
private func manifest(diskDescriptors: [OCIManifestLayer]) -> OCIManifest {
OCIManifest(
config: OCIManifestConfig(size: 1, digest: "sha256:oci-config"),
layers: [configLayer()] + diskDescriptors + [nvramLayer()]
)
}
private func configLayer() -> OCIManifestLayer {
OCIManifestLayer(mediaType: configMediaType, size: 1, digest: "sha256:tart-config")
}
private func nvramLayer() -> OCIManifestLayer {
OCIManifestLayer(mediaType: nvramMediaType, size: 1, digest: "sha256:nvram")
}
private func chunk(mediaType: String, suffix: String, diskFileDigest: String? = nil, chunkCount: Int? = nil) -> OCIManifestLayer {
var descriptor = OCIManifestLayer(
mediaType: mediaType,
size: 1,
digest: "sha256:\(suffix)",
uncompressedSize: 1,
uncompressedContentDigest: "sha256:uncompressed-\(suffix)"
)
if let diskFileDigest {
descriptor.annotations?[diskFileContentDigestAnnotation] = diskFileDigest
}
if let chunkCount {
descriptor.annotations?[diskFileChunkCountAnnotation] = String(chunkCount)
}
return descriptor
}
}

View File

@ -1,359 +0,0 @@
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 testBaseBlockLayoutReadsRawAndASIFImages() 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.baseBlockLayout(at: rawURL, expectedFormat: .raw).blockCount, 8)
XCTAssertEqual(try DiskImageStack.baseBlockLayout(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.isStackedVM)
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.baseURL, try contentStore.contentURL(for: 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.isStackedVM)
_ = 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.isStackedVM)
XCTAssertTrue(FileManager.default.fileExists(atPath: fresh.overlayURL.path))
}
func testStackedRemoteAdditionalDiskRetainsTemporaryVM() throws {
try withTemporaryTartHome {
let source = try flatSource()
let stacked = try temporaryVMDirectory()
try source.cloneAsStackedBase(to: stacked, generateMAC: false)
let storage = try VMStorageOCI()
let name = try RemoteName("example.com/org/image:latest")
let cachedImage = try storage.create(name)
try FileManager.default.copyItem(at: stacked.configURL, to: cachedImage.configURL)
try FileManager.default.copyItem(at: stacked.nvramURL, to: cachedImage.nvramURL)
try FileManager.default.copyItem(at: stacked.manifestURL, to: cachedImage.manifestURL)
do {
let additionalDisk = try AdditionalDisk(parseFrom: name.description)
let entries = try temporaryEntries()
XCTAssertEqual(entries.count, 1)
XCTAssertTrue(VMDirectory(baseURL: entries[0]).isStackedVM)
try Config().gc()
XCTAssertEqual(try temporaryEntries(), entries)
withExtendedLifetime(additionalDisk) {}
}
try Config().gc()
XCTAssertTrue(try temporaryEntries().isEmpty)
}
}
func testResizeDiskGrowsWritableOverlayAndPreservesParentGeometry() 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)
let manifest = try OCIManifest(fromJSON: Data(contentsOf: stacked.manifestURL))
XCTAssertEqual(manifest.diskBlockSize(), 512)
XCTAssertEqual(manifest.diskBlockCount(), 8)
_ = try stacked.diskImageStack(contentStore: contentStore).makeAttachment()
}
func testStackedArchiveRoundTripsImmutableContentAndOverlay() throws {
try withTemporaryTartHome {
let source = try flatSource()
let stacked = try temporaryVMDirectory()
try source.cloneAsStackedBase(to: stacked, generateMAC: false)
let contentDigest = try Digest.hash(source.diskURL)
let contentStore = try ContentStore()
let archivedOverlayDigest = try Digest.hash(stacked.overlayURL)
let archiveURL = try temporaryDirectory().appendingPathComponent("stacked.tvm")
try stacked.exportToArchive(path: archiveURL.path)
let cachedBaseURL = try XCTUnwrap(try contentStore.existingContentURL(for: contentDigest))
// Import must repair a corrupt cache entry from the valid archive
// instead of discarding the archive copy as an apparent cache hit.
try Data("corrupt".utf8).write(to: cachedBaseURL)
XCTAssertNil(try contentStore.existingContentURL(for: contentDigest))
let imported = try temporaryVMDirectory()
try imported.importFromArchive(path: archiveURL.path)
XCTAssertTrue(imported.isStackedVM)
XCTAssertEqual(try Digest.hash(imported.overlayURL), archivedOverlayDigest)
XCTAssertNotNil(try contentStore.existingContentURL(for: contentDigest))
_ = try imported.diskImageStack().makeAttachment()
}
}
func testStackedArchiveRejectsCorruptImmutableContent() throws {
try withTemporaryTartHome {
let source = try flatSource()
let stacked = try temporaryVMDirectory()
try source.cloneAsStackedBase(to: stacked, generateMAC: false)
let contentDigest = try Digest.hash(source.diskURL)
let contentStore = try ContentStore()
let cachedBaseURL = try XCTUnwrap(try contentStore.contentURLIfPresent(for: contentDigest))
try Data("corrupt".utf8).write(to: cachedBaseURL)
let archiveURL = try temporaryDirectory().appendingPathComponent("stacked.tvm")
XCTAssertThrowsError(try stacked.exportToArchive(path: archiveURL.path)) { error in
guard case RuntimeError.ExportFailed(let message) = error else {
return XCTFail("unexpected error: \(error)")
}
XCTAssertEqual(message, "VM is missing cached disk content \(contentDigest)")
}
}
}
func testStackedOCIArchiveSurvivesConcurrentRecordDeletion() throws {
try withTemporaryTartHome {
let source = try flatSource()
let stacked = try temporaryVMDirectory()
try source.cloneAsStackedBase(to: stacked, generateMAC: false)
let manifest = try OCIManifest(fromJSON: Data(contentsOf: stacked.manifestURL))
let storage = try VMStorageOCI()
let record = try storage.create(RemoteName(
host: "example.com",
namespace: "org/image",
reference: Reference(digest: try manifest.digest())
))
try FileManager.default.copyItem(at: stacked.configURL, to: record.configURL)
try FileManager.default.copyItem(at: stacked.nvramURL, to: record.nvramURL)
try FileManager.default.copyItem(at: stacked.manifestURL, to: record.manifestURL)
XCTAssertTrue(record.isStackedCachedImage)
let archiveURL = try temporaryDirectory().appendingPathComponent("stacked-race.tvm")
let contentStore = try ContentStore()
let lockHeld = DispatchSemaphore(value: 0)
let releaseLock = DispatchSemaphore(value: 0)
let exportStarted = DispatchSemaphore(value: 0)
let exportFinished = DispatchSemaphore(value: 0)
let deletionStarted = DispatchSemaphore(value: 0)
let deletionFinished = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
try? contentStore.withPruneLock {
lockHeld.signal()
releaseLock.wait()
}
}
XCTAssertEqual(lockHeld.wait(timeout: .now() + 1), .success)
// Queue export first so it is the next prune-lock waiter, then queue
// deletion behind it. Export must finish staging everything it needs
// before deletion can remove the source cached image.
DispatchQueue.global().async {
exportStarted.signal()
try? record.exportToArchive(path: archiveURL.path)
exportFinished.signal()
}
XCTAssertEqual(exportStarted.wait(timeout: .now() + 1), .success)
Thread.sleep(forTimeInterval: 0.1)
DispatchQueue.global().async {
deletionStarted.signal()
try? record.delete()
deletionFinished.signal()
}
XCTAssertEqual(deletionStarted.wait(timeout: .now() + 1), .success)
XCTAssertEqual(exportFinished.wait(timeout: .now() + 0.1), .timedOut)
XCTAssertEqual(deletionFinished.wait(timeout: .now() + 0.1), .timedOut)
releaseLock.signal()
XCTAssertEqual(exportFinished.wait(timeout: .now() + 5), .success)
XCTAssertEqual(deletionFinished.wait(timeout: .now() + 5), .success)
XCTAssertFalse(FileManager.default.fileExists(atPath: record.baseURL.path))
let imported = try temporaryVMDirectory()
try imported.importFromArchive(path: archiveURL.path)
XCTAssertTrue(imported.isStackedVM)
_ = try imported.diskImageStack().makeAttachment()
}
}
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.immutableOverlayURLs, [try contentStore.contentURL(for: 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 temporaryEntries() throws -> [URL] {
try FileManager.default.contentsOfDirectory(
at: Config().tartTmpDir,
includingPropertiesForKeys: nil
)
}
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 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

View File

@ -1,154 +0,0 @@
import Foundation
import XCTest
@testable import tart
final class VMDirectoryLayoutTests: XCTestCase {
func testStandaloneLayoutWithPinnedManifest() throws {
let vmDir = try temporaryVMDirectory()
try touch(vmDir.configURL)
try touch(vmDir.nvramURL)
try touch(vmDir.diskURL)
try touch(vmDir.manifestURL)
XCTAssertEqual(vmDir.layout, .standalone)
XCTAssertTrue(vmDir.initialized)
XCTAssertTrue(vmDir.isCachedImage)
XCTAssertNoThrow(try vmDir.validateCachedImage(userFriendlyName: "standalone"))
}
func testStackedVMLayout() throws {
let vmDir = try temporaryVMDirectory()
try touch(vmDir.configURL)
try touch(vmDir.nvramURL)
try touch(vmDir.manifestURL)
try touch(vmDir.overlayURL)
XCTAssertEqual(vmDir.layout, .stackedLocal)
XCTAssertTrue(vmDir.initialized)
XCTAssertFalse(vmDir.isCachedImage)
}
func testStackedCachedImageLayout() throws {
let vmDir = try temporaryVMDirectory()
try touch(vmDir.configURL)
try touch(vmDir.nvramURL)
try touch(vmDir.manifestURL)
XCTAssertEqual(vmDir.layout, .stackedOCIRecord)
XCTAssertFalse(vmDir.initialized)
XCTAssertTrue(vmDir.isCachedImage)
XCTAssertNoThrow(try vmDir.validateCachedImage(userFriendlyName: "stacked"))
}
func testAmbiguousDiskAndOverlayIsNotInitialized() throws {
let vmDir = try temporaryVMDirectory()
try touch(vmDir.configURL)
try touch(vmDir.nvramURL)
try touch(vmDir.diskURL)
try touch(vmDir.manifestURL)
try touch(vmDir.overlayURL)
XCTAssertNil(vmDir.layout)
XCTAssertFalse(vmDir.initialized)
XCTAssertFalse(vmDir.isCachedImage)
}
func testStackedVMAccountingUsesOverlay() 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 testStackedCachedImageAccountingUsesManifestBlockLayout() throws {
let vmDir = try temporaryVMDirectory()
try Data("config".utf8).write(to: vmDir.configURL)
try Data("nvram".utf8).write(to: vmDir.nvramURL)
try stackedManifest(blockSize: 512, blockCount: 8).toJSON().write(to: vmDir.manifestURL)
XCTAssertEqual(
try vmDir.sizeBytes(),
try vmDir.configURL.sizeBytes() + vmDir.nvramURL.sizeBytes()
)
XCTAssertEqual(
try vmDir.allocatedSizeBytes(),
try vmDir.configURL.allocatedSizeBytes() + vmDir.nvramURL.allocatedSizeBytes()
)
XCTAssertEqual(try vmDir.diskSizeBytes(), 4096)
}
func testStackedArchiveRequiresMacOS27() throws {
if #available(macOS 27.0, *) {
throw XCTSkip("macOS 26 compatibility test")
}
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)
let archiveURL = try temporaryVMDirectory().baseURL.appendingPathComponent("stacked.tvm")
XCTAssertThrowsError(try vmDir.exportToArchive(path: archiveURL.path)) { error in
guard case DiskImageStackError.unavailable = error else {
return XCTFail("unexpected error: \(error)")
}
}
XCTAssertFalse(FileManager.default.fileExists(atPath: archiveURL.path))
}
private func temporaryVMDirectory() throws -> VMDirectory {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false)
addTeardownBlock {
try? FileManager.default.removeItem(at: url)
}
return VMDirectory(baseURL: url)
}
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 = [
uncompressedDiskSizeAnnotation: String(blockSize * blockCount),
diskBlockSizeAnnotation: String(blockSize),
]
return manifest
}
}

View File

@ -1,974 +0,0 @@
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 testStackedCloneRequiresManifestForLegacyStandaloneCachedImage() 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.hasUsableCachedImageForClone(name))
XCTAssertFalse(try storage.hasUsableCachedImageForClone(name, requireManifest: true))
XCTAssertTrue(try storage.hasCompleteCachedImage(name, manifest: manifest))
XCTAssertFalse(try storage.hasCompleteCachedImage(name, manifest: manifest, 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.hasUsableCachedImageForClone(name))
let contentStore = try ContentStore()
try installContent(baseData, contentDigest: baseDigest, into: contentStore)
try installContent(overlayData, contentDigest: overlayDigest, into: contentStore)
XCTAssertTrue(try storage.hasUsableCachedImageForClone(name))
try Data("bad".utf8).write(to: try contentStore.contentURL(for: overlayDigest))
XCTAssertFalse(try storage.hasUsableCachedImageForClone(name))
}
}
func testListIncludesStackedCachedImage() 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 testStackedCacheHitRequiresExpectedContentSizes() throws {
try withTemporaryTartHome {
let baseData = Data(repeating: 0x41, count: 10)
let overlayData = Data(repeating: 0x42, count: 20)
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.hasCompleteCachedImage(name, manifest: manifest))
XCTAssertEqual(try storage.requiredDiskStorageBytes(for: manifest), 30)
let contentStore = try ContentStore()
try installContent(baseData, contentDigest: baseDigest, into: contentStore)
XCTAssertFalse(try storage.hasCompleteCachedImage(name, manifest: manifest))
XCTAssertEqual(try storage.requiredDiskStorageBytes(for: manifest), 20)
try installContent(overlayData, contentDigest: overlayDigest, into: contentStore)
XCTAssertTrue(try storage.hasCompleteCachedImage(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.hasCompleteCachedImage(name, manifest: manifest))
XCTAssertEqual(try storage.requiredDiskStorageBytes(for: manifest), 20)
}
}
func testStackedPullReusesPreviouslyPulledStandaloneDisk() throws {
try withTemporaryTartHome {
let diskData = Data([0])
let contentDigest = Digest.hash(diskData)
let flatManifest = try flatManifest()
let flatName = try digestName(for: flatManifest)
let storage = try VMStorageOCI()
let flatRecord = try storage.create(flatName)
try config().save(toURL: flatRecord.configURL)
XCTAssertTrue(FileManager.default.createFile(atPath: flatRecord.nvramURL.path, contents: Data()))
try diskData.write(to: flatRecord.diskURL)
try flatManifest.toJSON().write(to: flatRecord.manifestURL)
let stackedManifest = try stackedManifest(baseContentDigest: contentDigest)
XCTAssertNil(try ContentStore().existingContentURL(for: contentDigest))
try storage.reuseStandaloneDiskForStackedBaseIfPossible(stackedManifest)
let reusedURL = try XCTUnwrap(try ContentStore().existingContentURL(for: contentDigest))
XCTAssertEqual(try Data(contentsOf: reusedURL), diskData)
}
}
func testStackedPullDoesNotRehashInstalledBaseBeforeReuse() throws {
try withTemporaryTartHome {
let contentDigest = Digest.hash(Data("base".utf8))
let manifest = try stackedManifest(baseContentDigest: contentDigest)
let contentURL = try ContentStore().contentURL(for: contentDigest)
// Hashing this path would throw. Once an entry is published, this
// fast path must trust its presence and let normal pull validation
// repair unusable content later.
try FileManager.default.createDirectory(at: contentURL, withIntermediateDirectories: false)
XCTAssertNoThrow(try VMStorageOCI().reuseStandaloneDiskForStackedBaseIfPossible(manifest))
}
}
func testNewTagDoesNotValidateCachedStackBeforeLock() throws {
try withTemporaryTartHome {
let baseDigest = Digest.hash(Data("base".utf8))
let overlayDigest = Digest.hash(Data("overlay".utf8))
let manifest = try stackedManifest(
baseContentDigest: baseDigest,
overlayContentDigest: overlayDigest
)
let digestName = try digestName(for: manifest)
let tagName = RemoteName(
host: digestName.host,
namespace: digestName.namespace,
reference: Reference(tag: "latest")
)
let storage = try VMStorageOCI()
let record = try storage.create(digestName)
try config().save(toURL: record.configURL)
XCTAssertTrue(FileManager.default.createFile(atPath: record.nvramURL.path, contents: Data()))
try manifest.toJSON().write(to: record.manifestURL)
// Hashing this directory as a disk file throws. A new tag must skip
// validation until after it has taken the host lock.
let contentURL = try ContentStore().contentURL(for: baseDigest)
try FileManager.default.createDirectory(at: contentURL, withIntermediateDirectories: false)
XCTAssertFalse(try storage.hasCompleteLinkedImage(tagName, digestName: digestName, manifest: manifest))
}
}
func testStandaloneLayerCacheIgnoresStackedCachedImages() 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)
}
}
func testGCPrunesOnlyUnreferencedContent() throws {
try withTemporaryTartHome {
let contentStore = try ContentStore()
let referenced = try installContent(Data("referenced".utf8), into: contentStore)
let unreferenced = try installContent(Data("unreferenced".utf8), into: contentStore)
let stacked = try VMStorageLocal().create("stacked")
try config().save(toURL: stacked.configURL)
XCTAssertTrue(FileManager.default.createFile(atPath: stacked.nvramURL.path, contents: Data()))
XCTAssertTrue(FileManager.default.createFile(atPath: stacked.overlayURL.path, contents: Data()))
try pinnedBaseManifest(contentDigest: referenced.digest).toJSON().write(to: stacked.manifestURL)
try VMStorageOCI().gc()
XCTAssertTrue(FileManager.default.fileExists(atPath: referenced.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: unreferenced.url.path))
}
}
func testGCPrunesContentWithoutOCIStorageDirectory() throws {
try withTemporaryTartHome {
let contentStore = try ContentStore()
let unreferenced = try installContent(Data("unreferenced".utf8), into: contentStore)
let storage = try VMStorageOCI()
XCTAssertFalse(FileManager.default.fileExists(atPath: storage.baseURL.path))
try storage.gc()
XCTAssertFalse(FileManager.default.fileExists(atPath: unreferenced.url.path))
}
}
func testGCDoesNotPruneContentReferencedByInProgressManifest() throws {
try withTemporaryTartHome {
let contentStore = try ContentStore()
let referenced = try installContent(Data("in-progress".utf8), into: contentStore)
let temporaryVMDir = try VMDirectory.temporary()
// Pull and clone publish the manifest before config, NVRAM, or a
// writable overlay necessarily exist.
try pinnedBaseManifest(contentDigest: referenced.digest).toJSON().write(to: temporaryVMDir.manifestURL)
try VMStorageOCI().gc()
XCTAssertTrue(FileManager.default.fileExists(atPath: referenced.url.path))
}
}
func testStalePrunableDoesNotDeleteNewlyReferencedContent() throws {
try withTemporaryTartHome {
let contentStore = try ContentStore()
let content = try installContent(Data("new-reference".utf8), into: contentStore)
let storage = try VMStorageOCI()
let candidate = try XCTUnwrap(storage.prunables().first {
$0.url.resolvingSymlinksInPath() == content.url.resolvingSymlinksInPath()
})
// Simulate a clone or pull publishing its manifest after prune built
// the candidate list but before deletion starts.
let temporaryVMDir = try VMDirectory.temporary()
try contentStore.withPruneLock {
try pinnedBaseManifest(contentDigest: content.digest).toJSON().write(to: temporaryVMDir.manifestURL)
}
try candidate.delete()
XCTAssertTrue(FileManager.default.fileExists(atPath: content.url.path))
}
}
func testVMDirectoryDeletionOfStackedOCIRecordWaitsForPruneLock() throws {
try withTemporaryTartHome {
let storage = try VMStorageOCI()
let record = try createRecord(for: stackedManifest(), in: storage)
try assertDeletionWaitsForPruneLock(record: record) {
try record.delete()
}
}
}
func testStorageDeletionOfStackedOCIRecordWaitsForPruneLock() throws {
try withTemporaryTartHome {
let storage = try VMStorageOCI()
let manifest = try stackedManifest()
let name = try digestName(for: manifest)
let record = try createRecord(for: manifest, in: storage)
try assertDeletionWaitsForPruneLock(record: record) {
try storage.delete(name)
}
}
}
func testStorageDeletionOfIncompleteManifestRecordWaitsForPruneLock() throws {
try withTemporaryTartHome {
let storage = try VMStorageOCI()
let name = RemoteName(
host: "example.com",
namespace: "org/image",
reference: Reference(digest: "sha256:incomplete")
)
let record = try storage.create(name)
try Data("{}".utf8).write(to: record.manifestURL)
try assertDeletionWaitsForPruneLock(record: record) {
try storage.delete(name)
}
}
}
func testVMDirectoryDeletionOfUnpublishedRecordWaitsForPruneLock() throws {
try withTemporaryTartHome {
let storage = try VMStorageOCI()
let record = try storage.create(RemoteName(
host: "example.com",
namespace: "org/image",
reference: Reference(digest: "sha256:unpublished")
))
try assertDeletionWaitsForPruneLock(record: record) {
try record.removeFromDisk()
}
}
}
func testTagReplacementWaitsForPruneLock() throws {
try withTemporaryTartHome {
let storage = try VMStorageOCI()
let firstManifest = try stackedManifest(baseContentDigest: "sha256:first")
let secondManifest = try stackedManifest(baseContentDigest: "sha256:second")
let firstName = try digestName(for: firstManifest)
let secondName = try digestName(for: secondManifest)
_ = try createRecord(for: firstManifest, in: storage)
_ = try createRecord(for: secondManifest, in: storage)
let tagName = RemoteName(
host: secondName.host,
namespace: secondName.namespace,
reference: Reference(tag: "latest")
)
let contentStore = try ContentStore()
let lockHeld = DispatchSemaphore(value: 0)
let releaseLock = DispatchSemaphore(value: 0)
let replacementStarted = DispatchSemaphore(value: 0)
let replacementFinished = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
try? contentStore.withPruneLock {
lockHeld.signal()
releaseLock.wait()
}
}
XCTAssertEqual(lockHeld.wait(timeout: .now() + 1), .success)
DispatchQueue.global().async {
replacementStarted.signal()
try? storage.link(from: tagName, to: secondName)
replacementFinished.signal()
}
XCTAssertEqual(replacementStarted.wait(timeout: .now() + 1), .success)
XCTAssertEqual(replacementFinished.wait(timeout: .now() + 0.1), .timedOut)
releaseLock.signal()
XCTAssertEqual(replacementFinished.wait(timeout: .now() + 1), .success)
XCTAssertTrue(storage.linked(from: tagName, to: secondName))
XCTAssertFalse(storage.linked(from: tagName, to: firstName))
}
}
func testGCDeletionOfStackedOCIRecordWaitsForPruneLock() throws {
try withTemporaryTartHome {
let storage = try VMStorageOCI()
let record = try createRecord(for: stackedManifest(), in: storage)
try assertDeletionWaitsForPruneLock(record: record) {
try storage.gc()
}
}
}
func testTemporaryManifestGCWaitsForPruneLock() throws {
try withTemporaryTartHome {
let temporaryVMDir = try VMDirectory.temporary()
try stackedManifest().toJSON().write(to: temporaryVMDir.manifestURL)
try assertDeletionWaitsForPruneLock(record: temporaryVMDir) {
try Config().gc()
}
}
}
func testLockedTemporaryDirectorySurvivesGarbageCollection() throws {
try withTemporaryTartHome {
let temporaryVMDir = try VMDirectory.temporary()
let lock = try FileLock(lockURL: temporaryVMDir.baseURL)
try lock.lock()
try Config().gc()
XCTAssertTrue(FileManager.default.fileExists(atPath: temporaryVMDir.baseURL.path))
try lock.unlock()
try Config().gc()
XCTAssertFalse(FileManager.default.fileExists(atPath: temporaryVMDir.baseURL.path))
}
}
func testMovingStackedOCIRecordWaitsForPruneLock() throws {
try withTemporaryTartHome {
let storage = try VMStorageOCI()
let source = try temporaryVMDirectory()
let manifest = try stackedManifest()
let name = try digestName(for: manifest)
try config().save(toURL: source.configURL)
XCTAssertTrue(FileManager.default.createFile(atPath: source.nvramURL.path, contents: Data()))
try manifest.toJSON().write(to: source.manifestURL)
let contentStore = try ContentStore()
let lockHeld = DispatchSemaphore(value: 0)
let releaseLock = DispatchSemaphore(value: 0)
let moveStarted = DispatchSemaphore(value: 0)
let moveFinished = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
try? contentStore.withPruneLock {
lockHeld.signal()
releaseLock.wait()
}
}
XCTAssertEqual(lockHeld.wait(timeout: .now() + 1), .success)
DispatchQueue.global().async {
moveStarted.signal()
try? storage.move(name, from: source)
moveFinished.signal()
}
XCTAssertEqual(moveStarted.wait(timeout: .now() + 1), .success)
XCTAssertEqual(moveFinished.wait(timeout: .now() + 0.1), .timedOut)
XCTAssertTrue(FileManager.default.fileExists(atPath: source.baseURL.path))
releaseLock.signal()
XCTAssertEqual(moveFinished.wait(timeout: .now() + 1), .success)
XCTAssertFalse(FileManager.default.fileExists(atPath: source.baseURL.path))
XCTAssertTrue(try storage.open(name).isStackedCachedImage)
}
}
func testPruningLastStackedOCIRecordReclaimsItsContent() throws {
try withTemporaryTartHome {
let contentStore = try ContentStore()
let baseContent = try installContent(Data("record-only-base".utf8), into: contentStore)
let overlayContent = try installContent(Data("record-only-overlay".utf8), into: contentStore)
let manifest = try stackedManifest(
baseContentDigest: baseContent.digest,
overlayContentDigest: overlayContent.digest,
baseUncompressedSize: UInt64(try baseContent.url.sizeBytes()),
overlayUncompressedSize: UInt64(try overlayContent.url.sizeBytes())
)
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)
let candidate = try XCTUnwrap(storage.prunables().first {
$0.url.lastPathComponent == record.url.lastPathComponent
})
XCTAssertGreaterThanOrEqual(
try candidate.allocatedSizeBytes(),
try baseContent.url.allocatedSizeBytes() + overlayContent.url.allocatedSizeBytes()
)
// The record itself fits in this budget, so pruning only succeeds if it
// accounts for the immutable content released with the final reference.
try Prune.pruneSpaceBudget(
prunableStorages: [storage],
spaceBudgetBytes: UInt64(try record.allocatedSizeBytes())
)
XCTAssertFalse(FileManager.default.fileExists(atPath: record.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: baseContent.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: overlayContent.url.path))
}
}
func testMalformedStackedOCIRecordDoesNotBlockPruning() throws {
try withTemporaryTartHome {
let contentStore = try ContentStore()
let baseContent = try installContent(Data("valid-base".utf8), into: contentStore)
let overlayContent = try installContent(Data("valid-overlay".utf8), into: contentStore)
let storage = try VMStorageOCI()
let validRecord = try createRecord(for: stackedManifest(
baseContentDigest: baseContent.digest,
overlayContentDigest: overlayContent.digest,
baseUncompressedSize: UInt64(try baseContent.url.sizeBytes()),
overlayUncompressedSize: UInt64(try overlayContent.url.sizeBytes())
), in: storage)
let malformedRecord = try storage.create(RemoteName(
host: "example.com",
namespace: "org/image",
reference: Reference(digest: "sha256:malformed")
))
try config().save(toURL: malformedRecord.configURL)
XCTAssertTrue(FileManager.default.createFile(atPath: malformedRecord.nvramURL.path, contents: Data()))
try Data("{".utf8).write(to: malformedRecord.manifestURL)
XCTAssertNoThrow(try storage.prunables())
try Prune.pruneSpaceBudget(prunableStorages: [storage], spaceBudgetBytes: 0)
XCTAssertFalse(FileManager.default.fileExists(atPath: validRecord.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: malformedRecord.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: baseContent.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: overlayContent.url.path))
}
}
func testPruningOneStackedOCIRecordPreservesSharedContent() throws {
try withTemporaryTartHome {
let contentStore = try ContentStore()
let baseContent = try installContent(Data("shared-base".utf8), into: contentStore)
let firstOverlay = try installContent(Data("first-overlay".utf8), into: contentStore)
let secondOverlay = try installContent(Data("second-overlay".utf8), into: contentStore)
let storage = try VMStorageOCI()
let firstManifest = try stackedManifest(
baseContentDigest: baseContent.digest,
overlayContentDigest: firstOverlay.digest,
baseUncompressedSize: UInt64(try baseContent.url.sizeBytes()),
overlayUncompressedSize: UInt64(try firstOverlay.url.sizeBytes())
)
let secondManifest = try stackedManifest(
baseContentDigest: baseContent.digest,
overlayContentDigest: secondOverlay.digest,
baseUncompressedSize: UInt64(try baseContent.url.sizeBytes()),
overlayUncompressedSize: UInt64(try secondOverlay.url.sizeBytes())
)
let firstRecord = try createRecord(for: firstManifest, in: storage)
let secondRecord = try createRecord(for: secondManifest, in: storage)
let firstCandidate = try XCTUnwrap(storage.prunables().first {
$0.url.lastPathComponent == firstRecord.url.lastPathComponent
})
try firstCandidate.delete()
XCTAssertTrue(FileManager.default.fileExists(atPath: baseContent.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: firstOverlay.url.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: secondOverlay.url.path))
let secondCandidate = try XCTUnwrap(storage.prunables().first {
$0.url.lastPathComponent == secondRecord.url.lastPathComponent
})
try secondCandidate.delete()
XCTAssertFalse(FileManager.default.fileExists(atPath: baseContent.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: secondOverlay.url.path))
}
}
func testSpaceBudgetRecomputesSharedContentAfterOwnerDeletion() throws {
try withTemporaryTartHome {
let contentStore = try ContentStore()
let baseContent = try installContent(Data("shared-base".utf8), into: contentStore)
let firstOverlay = try installContent(Data("first-overlay".utf8), into: contentStore)
let secondOverlay = try installContent(Data("second-overlay".utf8), into: contentStore)
let storage = try VMStorageOCI()
let firstManifest = try stackedManifest(
baseContentDigest: baseContent.digest,
overlayContentDigest: firstOverlay.digest,
baseUncompressedSize: UInt64(try baseContent.url.sizeBytes()),
overlayUncompressedSize: UInt64(try firstOverlay.url.sizeBytes())
)
let secondManifest = try stackedManifest(
baseContentDigest: baseContent.digest,
overlayContentDigest: secondOverlay.digest,
baseUncompressedSize: UInt64(try baseContent.url.sizeBytes()),
overlayUncompressedSize: UInt64(try secondOverlay.url.sizeBytes())
)
let olderRecord = try createRecord(for: firstManifest, in: storage)
let newerRecord = try createRecord(for: secondManifest, in: storage)
try olderRecord.url.updateAccessDate(Date(timeIntervalSince1970: 1))
try newerRecord.url.updateAccessDate(Date(timeIntervalSince1970: 2))
let olderCandidate = try XCTUnwrap(storage.prunables().first {
$0.url.lastPathComponent == olderRecord.url.lastPathComponent
})
let budget = UInt64(try olderCandidate.allocatedSizeBytes())
// On the first pass the newer record owns the shared base and is
// selected for deletion, while the older record fits this budget.
// Recomputing must then charge the surviving record for the base and
// prune it too.
try Prune.pruneSpaceBudget(
prunableStorages: [storage],
spaceBudgetBytes: budget
)
XCTAssertFalse(FileManager.default.fileExists(atPath: olderRecord.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: newerRecord.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: baseContent.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: firstOverlay.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: secondOverlay.url.path))
}
}
func testSpaceBudgetRecomputesBeforeDeletingAnotherCandidate() throws {
try withTemporaryTartHome {
let contentStore = try ContentStore()
let sharedBase = try installContent(Data(repeating: 0x41, count: 128 * 1024), into: contentStore)
let newestOverlay = try installContent(Data("newest-overlay".utf8), into: contentStore)
let middleOverlay = try installContent(Data("middle-overlay".utf8), into: contentStore)
let retainedBase = try installContent(Data(repeating: 0x42, count: 32 * 1024), into: contentStore)
let retainedOverlay = try installContent(Data("retained-overlay".utf8), into: contentStore)
let storage = try VMStorageOCI()
let newest = try createRecord(for: stackedManifest(
baseContentDigest: sharedBase.digest,
overlayContentDigest: newestOverlay.digest,
baseUncompressedSize: UInt64(try sharedBase.url.sizeBytes()),
overlayUncompressedSize: UInt64(try newestOverlay.url.sizeBytes())
), in: storage)
let middle = try createRecord(for: stackedManifest(
baseContentDigest: sharedBase.digest,
overlayContentDigest: middleOverlay.digest,
baseUncompressedSize: UInt64(try sharedBase.url.sizeBytes()),
overlayUncompressedSize: UInt64(try middleOverlay.url.sizeBytes())
), in: storage)
let retained = try createRecord(for: stackedManifest(
baseContentDigest: retainedBase.digest,
overlayContentDigest: retainedOverlay.digest,
baseUncompressedSize: UInt64(try retainedBase.url.sizeBytes()),
overlayUncompressedSize: UInt64(try retainedOverlay.url.sizeBytes())
), in: storage)
try newest.url.updateAccessDate(Date(timeIntervalSince1970: 3))
try middle.url.updateAccessDate(Date(timeIntervalSince1970: 2))
try retained.url.updateAccessDate(Date(timeIntervalSince1970: 1))
let retainedCandidate = try XCTUnwrap(storage.prunables().first {
$0.url.lastPathComponent == retained.url.lastPathComponent
})
// Initially the newest record owns the shared base and is too large.
// The middle record appears small enough to retain, making the oldest
// unrelated record look like a second deletion candidate. After the
// first deletion, ownership moves to the middle record; recomputing
// before selecting again must delete it and preserve the unrelated one.
try Prune.pruneSpaceBudget(
prunableStorages: [storage],
spaceBudgetBytes: UInt64(try retainedCandidate.allocatedSizeBytes())
)
XCTAssertFalse(FileManager.default.fileExists(atPath: newest.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: middle.url.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: retained.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: sharedBase.url.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: retainedBase.url.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: retainedOverlay.url.path))
}
}
func testAutomaticReclaimRecomputesSharedContentAfterOwnerDeletion() throws {
try withTemporaryTartHome {
let contentStore = try ContentStore()
let sharedBase = try installContent(Data(repeating: 0x41, count: 128 * 1024), into: contentStore)
let initiatorOverlay = try installContent(Data("initiator-overlay".utf8), into: contentStore)
let ownerOverlay = try installContent(Data("owner-overlay".utf8), into: contentStore)
let unrelatedBase = try installContent(Data("unrelated-base".utf8), into: contentStore)
let unrelatedOverlay = try installContent(Data("unrelated-overlay".utf8), into: contentStore)
let storage = try VMStorageOCI()
let initiatorManifest = try stackedManifest(
baseContentDigest: sharedBase.digest,
overlayContentDigest: initiatorOverlay.digest,
baseUncompressedSize: UInt64(try sharedBase.url.sizeBytes()),
overlayUncompressedSize: UInt64(try initiatorOverlay.url.sizeBytes())
)
let ownerManifest = try stackedManifest(
baseContentDigest: sharedBase.digest,
overlayContentDigest: ownerOverlay.digest,
baseUncompressedSize: UInt64(try sharedBase.url.sizeBytes()),
overlayUncompressedSize: UInt64(try ownerOverlay.url.sizeBytes())
)
let unrelatedManifest = try stackedManifest(
baseContentDigest: unrelatedBase.digest,
overlayContentDigest: unrelatedOverlay.digest,
baseUncompressedSize: UInt64(try unrelatedBase.url.sizeBytes()),
overlayUncompressedSize: UInt64(try unrelatedOverlay.url.sizeBytes())
)
let initiator = try createRecord(for: initiatorManifest, in: storage)
let owner = try createRecord(for: ownerManifest, in: storage)
let unrelated = try createRecord(for: unrelatedManifest, in: storage)
try initiator.url.updateAccessDate(Date(timeIntervalSince1970: 1))
try owner.url.updateAccessDate(Date(timeIntervalSince1970: 2))
try unrelated.url.updateAccessDate(Date(timeIntervalSince1970: 3))
let sharedBaseSize = UInt64(try sharedBase.url.allocatedSizeBytes())
// The owner is the first deletable record and is initially charged for
// the shared base. Deleting it cannot reclaim that base because the
// protected initiator still references it, so reclaim must continue.
try Prune.reclaimIfPossible(sharedBaseSize, initiator)
XCTAssertTrue(FileManager.default.fileExists(atPath: initiator.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: owner.url.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: unrelated.url.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: sharedBase.url.path))
}
}
#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.isStackedCachedImage)
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
}
private func createRecord(for manifest: OCIManifest, in storage: VMStorageOCI) throws -> VMDirectory {
let record = try storage.create(try digestName(for: manifest))
try config().save(toURL: record.configURL)
XCTAssertTrue(FileManager.default.createFile(atPath: record.nvramURL.path, contents: Data()))
try manifest.toJSON().write(to: record.manifestURL)
return record
}
private func assertDeletionWaitsForPruneLock(
record: VMDirectory,
deletion: @escaping () throws -> Void
) throws {
let contentStore = try ContentStore()
let lockHeld = DispatchSemaphore(value: 0)
let releaseLock = DispatchSemaphore(value: 0)
let deletionStarted = DispatchSemaphore(value: 0)
let deletionFinished = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
try? contentStore.withPruneLock {
lockHeld.signal()
releaseLock.wait()
}
}
XCTAssertEqual(lockHeld.wait(timeout: .now() + 1), .success)
DispatchQueue.global().async {
deletionStarted.signal()
try? deletion()
deletionFinished.signal()
}
XCTAssertEqual(deletionStarted.wait(timeout: .now() + 1), .success)
XCTAssertEqual(deletionFinished.wait(timeout: .now() + 0.1), .timedOut)
XCTAssertTrue(FileManager.default.fileExists(atPath: record.baseURL.path))
releaseLock.signal()
XCTAssertEqual(deletionFinished.wait(timeout: .now() + 1), .success)
XCTAssertFalse(FileManager.default.fileExists(atPath: record.baseURL.path))
}
#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?[uncompressedDiskSizeAnnotation] = "4096"
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 pinnedBaseManifest(contentDigest: String) -> OCIManifest {
var disk = OCIManifestLayer(
mediaType: diskV2MediaType,
size: 1,
digest: "sha256:disk-transport",
uncompressedSize: 1,
uncompressedContentDigest: "sha256:disk-chunk"
)
disk.annotations?[diskFileContentDigestAnnotation] = contentDigest
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 installContent(_ data: Data, into contentStore: ContentStore) throws -> (digest: String, url: URL) {
let digest = Digest.hash(data)
let temporaryURL = try contentStore.temporaryContentURL(for: digest)
try data.write(to: temporaryURL)
return (digest, try contentStore.install(temporaryURL, contentDigest: digest))
}
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
}
}

View File

@ -22,32 +22,6 @@ You can also enable the debugging output to diagnose issues:
go run cmd/main.go fio --debug
```
To compare an empty Tart home with the same pull after its immutable base has
been prewarmed, provide a standalone remote base image and a stacked image built
from it. For example, create and push a stacked child of the public Tahoe base:
```shell
BASE_IMAGE=ghcr.io/cirruslabs/macos-tahoe-base:latest
STACKED_IMAGE=ghcr.io/your-org/macos-tahoe-stacked:latest
tart clone --stacked "$BASE_IMAGE" macos-tahoe-stacked
tart push macos-tahoe-stacked "$STACKED_IMAGE"
```
Then benchmark that pair:
```shell
go run cmd/main.go stacked-oci \
--base-image "$BASE_IMAGE" \
--image "$STACKED_IMAGE"
```
The command first performs an unmeasured pull to warm registry, CDN, and
filesystem caches. It then uses disposable `TART_HOME` directories for both
measured scenarios. The prewarmed scenario keeps the VM created by
`tart clone --stacked` alive while pulling the child, so the shared immutable
base layer remains referenced and available for reuse.
## Results
### Mar 27, 2024

View File

@ -2,7 +2,6 @@ package command
import (
"github.com/cirruslabs/tart/benchmark/internal/command/fio"
"github.com/cirruslabs/tart/benchmark/internal/command/stackedoci"
"github.com/cirruslabs/tart/benchmark/internal/command/xcode"
"github.com/spf13/cobra"
)
@ -16,7 +15,6 @@ func NewCommand() *cobra.Command {
cmd.AddCommand(
fio.NewCommand(),
stackedoci.NewCommand(),
xcode.NewCommand(),
)

View File

@ -1,161 +0,0 @@
package stackedoci
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/gosuri/uitable"
"github.com/spf13/cobra"
"go.uber.org/zap"
"go.uber.org/zap/zapio"
)
var (
debug bool
baseImage string
stackedImage string
insecure bool
concurrency uint
)
func NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "stacked-oci",
Short: "benchmark empty and prewarmed Tart homes for stacked OCI pulls",
Long: "Warm the registry once, then compare an empty Tart home with one whose " +
"immutable base has already been materialized by tart clone --stacked. " +
"Every scenario uses a disposable TART_HOME and leaves the user's Tart home untouched.",
RunE: run,
}
cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging")
cmd.Flags().StringVar(&baseImage, "base-image", "", "remote flat OCI image used as the stacked image's base")
cmd.Flags().StringVar(&stackedImage, "image", "", "remote stacked OCI image to pull and clone")
cmd.Flags().BoolVar(&insecure, "insecure", false, "connect to the OCI registry via insecure HTTP")
cmd.Flags().UintVar(&concurrency, "concurrency", 4, "network concurrency passed to tart pull and clone")
_ = cmd.MarkFlagRequired("base-image")
_ = cmd.MarkFlagRequired("image")
return cmd
}
func run(cmd *cobra.Command, _ []string) error {
if concurrency < 1 {
return fmt.Errorf("concurrency cannot be less than 1")
}
config := zap.NewProductionConfig()
if debug {
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
}
logger, err := config.Build()
if err != nil {
return err
}
defer func() { _ = logger.Sync() }()
warmupHome, err := os.MkdirTemp("", "tart-stacked-oci-warmup-*")
if err != nil {
return err
}
defer os.RemoveAll(warmupHome)
emptyHome, err := os.MkdirTemp("", "tart-stacked-oci-empty-*")
if err != nil {
return err
}
defer os.RemoveAll(emptyHome)
warmHome, err := os.MkdirTemp("", "tart-stacked-oci-warm-*")
if err != nil {
return err
}
defer os.RemoveAll(warmHome)
table := uitable.New()
table.AddRow("Scenario", "Operation", "Time")
// Warm registry, CDN, and filesystem caches before either measured
// scenario so their difference reflects Tart's local base reuse.
if _, err := timedTart(cmd.Context(), logger, warmupHome, pullArguments(stackedImage)...); err != nil {
return fmt.Errorf("registry warmup failed: %w", err)
}
if err := os.RemoveAll(warmupHome); err != nil {
return fmt.Errorf("removing registry warmup home: %w", err)
}
duration, err := timedTart(cmd.Context(), logger, emptyHome, pullArguments(stackedImage)...)
if err != nil {
return fmt.Errorf("empty-home stacked pull failed: %w", err)
}
table.AddRow("empty", "pull stacked image", duration)
duration, err = timedTart(cmd.Context(), logger, emptyHome, "clone", stackedImage, "empty-clone")
if err != nil {
return fmt.Errorf("empty-home stacked clone failed: %w", err)
}
table.AddRow("empty", "clone stacked image", duration)
if err := os.RemoveAll(emptyHome); err != nil {
return fmt.Errorf("removing empty home: %w", err)
}
duration, err = timedTart(cmd.Context(), logger, warmHome, cloneBaseArguments(baseImage)...)
if err != nil {
return fmt.Errorf("base prewarm failed: %w", err)
}
table.AddRow("prewarmed", "clone --stacked base image", duration)
duration, err = timedTart(cmd.Context(), logger, warmHome, pullArguments(stackedImage)...)
if err != nil {
return fmt.Errorf("prewarmed stacked pull failed: %w", err)
}
table.AddRow("prewarmed", "pull stacked image", duration)
duration, err = timedTart(cmd.Context(), logger, warmHome, "clone", stackedImage, "warm-clone")
if err != nil {
return fmt.Errorf("prewarmed stacked clone failed: %w", err)
}
table.AddRow("prewarmed", "clone stacked image", duration)
fmt.Println(table.String())
return nil
}
func pullArguments(image string) []string {
args := []string{"pull", "--concurrency", fmt.Sprint(concurrency)}
if insecure {
args = append(args, "--insecure")
}
return append(args, image)
}
func cloneBaseArguments(image string) []string {
args := []string{"clone", "--stacked", "--concurrency", fmt.Sprint(concurrency)}
if insecure {
args = append(args, "--insecure")
}
return append(args, image, "prewarmed-base")
}
func timedTart(
ctx context.Context,
logger *zap.Logger,
tartHome string,
args ...string,
) (time.Duration, error) {
logger.Sugar().Debugf("TART_HOME=%s tart %s", tartHome, strings.Join(args, " "))
start := time.Now()
command := exec.CommandContext(ctx, "tart", args...)
command.Env = append(os.Environ(), "TART_HOME="+tartHome)
loggerWriter := &zapio.Writer{Log: logger, Level: zap.DebugLevel}
command.Stdout = loggerWriter
command.Stderr = loggerWriter
err := command.Run()
return time.Since(start).Round(time.Millisecond), err
}

View File

@ -231,28 +231,6 @@ export TART_NO_AUTO_PRUNE=
TART_NO_AUTO_PRUNE= tart pull ...
```
## Stacked disk images
On macOS 27 or newer, `tart clone --stacked` can create a VM from a remote,
standalone macOS OCI image whose writes are stored in a private ASIF overlay while
its source disk remains a shared read-only base:
```shell
tart clone --stacked ghcr.io/cirruslabs/macos-tahoe-base:latest macos-build
```
Running and pushing `macos-build` preserves that disk relationship. Pulling
another image from the same lineage only downloads immutable disk files that
are not already present in Tart's cache. For a stopped stacked VM,
`tart set --disk-size` grows its private writable overlay without changing the
base; a subsequent push records the new guest-visible disk size.
`tart pull` can cache a stacked image without assembling its disk. Clone, run,
import, and export require a Tart build with DiskImageKit support and macOS 27
or newer. Existing standalone raw and ASIF images continue to work on older
hosts. Keep published lineages shallow when possible: every additional parent
overlay adds another ASIF file to validate and assemble at run time.
## Disk resizing
Disk resizing works on most cloud-ready Linux distributions out-of-the box (e.g. Ubuntu Cloud Images have the `cloud-initramfs-growroot` package installed that runs on boot) and on the rest of the distributions by running the `growpart` or `resize2fs` commands.

View File

@ -265,15 +265,3 @@ tart clone acme.io/remoteorg/name:latest my-local-vm-name
```
If the specified image is not already present, this invocation calls the `tart pull` implicitly before cloning.
### Creating a Stacked Disk
On macOS 27 or newer, use `--stacked` to create a VM that keeps a remote standalone
macOS OCI image as an immutable base and stores only its own writes separately:
```bash
tart clone --stacked ghcr.io/cirruslabs/macos-tahoe-base my-local-vm-name
```
Pushing this VM preserves the disk relationship. Pulling another image from the
same lineage reuses immutable disk files that are already in Tart's cache.

View File

@ -1,94 +0,0 @@
import os
import platform
import subprocess
import uuid
from typing import Optional
import pytest
from paramiko.client import AutoAddPolicy, SSHClient
def _macos_major_version() -> int:
version = platform.mac_ver()[0]
return int(version.split(".", maxsplit=1)[0]) if version else 0
def _shutdown_vm(tart, vm_name: str, guest_command: Optional[str] = None) -> None:
tart_run_process = tart.run_async(["run", "--no-graphics", vm_name])
client = None
try:
stdout, _ = tart.run(["ip", vm_name, "--wait", "180"])
client = SSHClient()
client.set_missing_host_key_policy(AutoAddPolicy)
client.connect(stdout.strip(), username="admin", password="admin")
if guest_command:
_, command_stdout, command_stderr = client.exec_command(guest_command)
assert command_stdout.channel.recv_exit_status() == 0, command_stderr.read().decode()
client.exec_command("sudo shutdown -h now")
tart_run_process.wait(timeout=180)
assert tart_run_process.returncode == 0
finally:
if client is not None:
client.close()
if tart_run_process.poll() is None:
tart_run_process.terminate()
try:
tart_run_process.wait(timeout=30)
except subprocess.TimeoutExpired:
tart_run_process.kill()
tart_run_process.wait(timeout=30)
@pytest.mark.skipif(
_macos_major_version() < 27,
reason="stacked disk images require DiskImageKit on macOS 27 or newer",
)
def test_stacked_oci_round_trip_and_boot(tart, docker_registry):
suffix = str(uuid.uuid4())
source_image = os.environ.get(
"TART_STACKED_INTEGRATION_BASE_IMAGE",
"ghcr.io/cirruslabs/macos-tahoe-base:latest",
)
source_clone_args = ["clone"]
if os.environ.get("TART_STACKED_INTEGRATION_BASE_INSECURE") == "1":
source_clone_args.append("--insecure")
standalone_vm = f"stacked-base-{suffix}"
stacked_vm = f"stacked-child-{suffix}"
restored_vm = f"stacked-restored-{suffix}"
base_remote = docker_registry.remote_name(f"stacked-base-{suffix}")
child_remote = docker_registry.remote_name(f"stacked-child-{suffix}")
try:
# Publish a normal Tart image, then start a stacked lineage from that
# remote image. The base remains a normal flat OCI image.
tart.run(source_clone_args + [source_image, standalone_vm])
tart.run(["push", "--insecure", standalone_vm, base_remote])
tart.run(["clone", "--insecure", "--stacked", base_remote, stacked_vm])
stacked_path = os.path.join(tart.home(), "vms", stacked_vm)
assert os.path.isfile(os.path.join(stacked_path, "overlay.asif"))
assert os.path.isfile(os.path.join(stacked_path, "manifest.json"))
assert not os.path.exists(os.path.join(stacked_path, "disk.img"))
# Boot once so the top ASIF overlay contains real guest writes, then
# exercise stacked push, pull, clone, assembly, and a second boot.
_shutdown_vm(tart, stacked_vm, "touch ~/stacked-round-trip-marker")
tart.run(["push", "--insecure", stacked_vm, child_remote])
tart.run(["delete", stacked_vm])
tart.run(["pull", "--insecure", child_remote])
tart.run(["clone", child_remote, restored_vm])
restored_path = os.path.join(tart.home(), "vms", restored_vm)
assert os.path.isfile(os.path.join(restored_path, "overlay.asif"))
assert os.path.isfile(os.path.join(restored_path, "manifest.json"))
assert not os.path.exists(os.path.join(restored_path, "disk.img"))
_shutdown_vm(tart, restored_vm, "test -f ~/stacked-round-trip-marker")
finally:
for vm_name in (restored_vm, stacked_vm, standalone_vm, child_remote, base_remote):
try:
tart.run(["delete", vm_name])
except Exception:
pass