Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion Sources/tart/Commands/Clone.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,28 @@ struct Clone: AsyncParsableCommand {
guard remoteName != nil else {
throw ValidationError("--stacked requires a remote image")
}
try DiskImageStack.requireSupport()
}

if let remoteName, try !ociStorage.hasUsableCachedImageForClone(remoteName, requireManifest: stacked) {
// 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)

// Fail before pulling disk content when this host cannot create a writable stacked disk.
if !stacked {
let (manifest, _) = try await registry.pullManifest(reference: remoteName.reference.value)
if manifest.layers.contains(where: { $0.mediaType == asifOverlayMediaType }) {
try DiskImageStack.requireSupport()
}
}

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
Expand Down Expand Up @@ -126,7 +139,7 @@ struct Clone: AsyncParsableCommand {
}
}
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
try? tmpVMDir.removeFromDisk()
})
}
}
11 changes: 6 additions & 5 deletions Sources/tart/Commands/Import.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ 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
Expand All @@ -30,10 +33,8 @@ struct Import: AsyncParsableCommand {
// Populate the temporary VM directory with the export file contents
print("importing...")
try tmpVMDir.importFromArchive(path: path)

if tmpVMDir.isStackedVM || tmpVMDir.isStackedCachedImage {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
throw RuntimeError.ImportFailed("importing stacked VMs is not supported yet")
guard tmpVMDir.initialized else {
throw RuntimeError.ImportFailed("archive does not contain a runnable VM")
}

try await withTaskCancellationHandler(operation: {
Expand All @@ -50,7 +51,7 @@ struct Import: AsyncParsableCommand {

try lock.unlock()
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
try? tmpVMDir.removeFromDisk()
})
}
}
88 changes: 50 additions & 38 deletions Sources/tart/Commands/Prune.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,27 +81,34 @@ struct Prune: AsyncParsableCommand {
}

static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() }

var spaceBudgetBytes = spaceBudgetBytes
var prunablesToDelete: [Prunable] = []

for prunable in prunables {
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())

if prunableSizeBytes <= spaceBudgetBytes {
// Don't mark for deletion as
// there's a budget available
spaceBudgetBytes -= prunableSizeBytes
} else {
// Mark for deletion
prunablesToDelete.append(prunable)
while true {
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() }

var remainingBudgetBytes = spaceBudgetBytes
var prunableToDelete: Prunable?

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
}
}

guard let prunableToDelete else {
return
}
}

try prunablesToDelete.forEach { try $0.delete() }
// Deleting one cached stacked image can change which remaining image
// owns shared immutable content. Rebuild before choosing another.
try prunableToDelete.delete()
}
}

static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {
Expand Down Expand Up @@ -145,46 +152,51 @@ struct Prune: AsyncParsableCommand {
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated, initiator)
}

private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
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: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() }
let prunables = {
try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() }
}

// Does it even make sense to start?
let cacheUsedBytes = try prunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
if cacheUsedBytes < reclaimBytes {
let initialPrunables = try prunables()
let initialCacheUsedBytes = try initialPrunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
guard let reclaimBytes = Int(exactly: reclaimBytes), initialCacheUsedBytes >= reclaimBytes else {
return
}

var cacheReclaimedBytes: Int = 0

var it = prunables.makeIterator()
let targetCacheUsedBytes = initialCacheUsedBytes - reclaimBytes
var currentCacheUsedBytes = initialCacheUsedBytes
let initiatorPath = initiator.map {
$0.url.resolvingSymlinksInPath().standardizedFileURL.path
}

while cacheReclaimedBytes <= reclaimBytes {
guard let prunable = it.next() else {
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 {
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 \(cacheReclaimedBytes) bytes")
.addEvent(name: "Reclaimed \(initialCacheUsedBytes - currentCacheUsedBytes) bytes")
}
}
18 changes: 11 additions & 7 deletions Sources/tart/Commands/Run.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1039,20 +1039,24 @@ struct AdditionalDisk {
// 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()
try FileManager.default.copyItem(at: vmDir.configURL, to: temporaryVMDir.configURL)
try FileManager.default.copyItem(at: vmDir.nvramURL, to: temporaryVMDir.nvramURL)
try FileManager.default.copyItem(at: vmDir.manifestURL, to: temporaryVMDir.manifestURL)
let lock = try FileLock(lockURL: temporaryVMDir.baseURL)
try lock.lock()
let temporaryVMDirLock = try FileLock(lockURL: temporaryVMDir.baseURL)
try temporaryVMDirLock.lock()
try vmDir.cloneStacked(
to: temporaryVMDir,
copyWritableOverlay: false,
generateMAC: false
)
let stack = try temporaryVMDir.diskImageStack()
try stack.createWritableOverlay()
let attachment = try stack.makeAttachment(
readOnly: diskReadOnly,
cachingMode: try VZDiskImageCachingMode(cachingModeRaw) ?? .automatic,
synchronizationMode: try VZDiskImageSynchronizationMode(syncModeRaw)
)

return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: attachment), temporaryDiskLock: lock)
return AdditionalDisk(
configuration: VZVirtioBlockDeviceConfiguration(attachment: attachment),
temporaryDiskLock: temporaryVMDirLock
)
}

// Unfortunately, VZDiskImageStorageDeviceAttachment does not support
Expand Down
2 changes: 1 addition & 1 deletion Sources/tart/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ struct Config {
continue
}

try FileManager.default.removeItem(at: entry)
try VMDirectory(baseURL: entry).removeFromDisk()

try lock.unlock()
}
Expand Down
66 changes: 62 additions & 4 deletions Sources/tart/ContentStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct ContentStore {

let baseURL: URL
private let digestDirectoryURL: URL
private let pruneLockURL: URL

init() throws {
try self.init(baseURL: Config().tartCacheDir.appendingPathComponent("content", isDirectory: true))
Expand All @@ -24,13 +25,41 @@ struct ContentStore {
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 digestDirectoryURL.appendingPathComponent(digestHex)
return baseURL
.appendingPathComponent(Self.digestAlgorithm, isDirectory: true)
.appendingPathComponent(digestHex)
}

func temporaryContentURL(for contentDigest: String) throws -> URL {
Expand Down Expand Up @@ -61,16 +90,18 @@ struct ContentStore {
return lockURL
}

/// Returns a digest-addressed entry without rereading it. Pull verifies
/// content hashes before accepting a cache hit; clone only needs a cheap
/// structural check, like Tart's existing disk.img path.
/// 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
}

Expand All @@ -88,6 +119,33 @@ struct ContentStore {
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
Expand Down
Loading