Skip to content
Open
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
57 changes: 49 additions & 8 deletions Sources/tart/Commands/Clone.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ struct Clone: AsyncParsableCommand {
@Flag(help: .hidden)
var deduplicate: Bool = false

@Flag(help: "create a stacked disk that uses the source image as an immutable base")
var base: Bool = false

@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
var pruneLimit: UInt = 100

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

if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
if base {
guard remoteName != nil else {
throw ValidationError("--base requires an OCI source")
}
}

if let remoteName, try !ociStorage.hasUsableCachedRecordForClone(remoteName, requireManifest: base) {
// Pull the VM in case it's OCI-based and doesn't exist locally yet
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
Expand All @@ -66,9 +76,28 @@ struct Clone: AsyncParsableCommand {
let lock = try FileLock(lockURL: Config().tartHomeDir)
try lock.lock()

let sourceState = try sourceVM.state()
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
&& sourceVM.state() != .Suspended
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
&& sourceState != .Suspended

if base {
guard sourceVM.isStandalone else {
throw ValidationError("--base currently supports only flat OCI images")
}
guard try VMConfig(fromURL: sourceVM.configURL).os == .darwin else {
throw ValidationError("--base currently supports only macOS OCI images")
}
try sourceVM.cloneAsStackedBase(to: tmpVMDir, generateMAC: generateMAC)
} else if sourceVM.isStackedOCIRecord {
try sourceVM.cloneStacked(to: tmpVMDir, copyWritableOverlay: false, generateMAC: generateMAC)
} else if sourceVM.isStackedLocal {
guard sourceState == .Stopped else {
throw RuntimeError.VMConfigurationError("VM \"\(sourceName)\" must be stopped before cloning")
}
try sourceVM.cloneStacked(to: tmpVMDir, copyWritableOverlay: true, generateMAC: generateMAC)
} else {
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
}

try localStorage.move(newName, from: tmpVMDir)

Expand All @@ -78,11 +107,23 @@ struct Clone: AsyncParsableCommand {
// is not actually claiming new space until the VM is started and it writes something to disk.
//
// So, once we clone the VM let's try to claim the rest of space for the VM to run without errors.
let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
// Avoid reclaiming an excessive amount of disk space.
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
if reclaimBytes > 0 {
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM)
if sourceVM.isStandalone {
Comment thread
yzhuang-oai marked this conversation as resolved.
let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
// Avoid reclaiming an excessive amount of disk space.
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
if reclaimBytes > 0 {
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM)
}
} else if sourceVM.isStackedLocal || sourceVM.isStackedOCIRecord {
let clonedVM = try localStorage.open(newName)
// A stacked clone owns only its writable overlay locally, but that
// overlay may grow to the full guest-visible disk geometry at
// runtime. Reclaim against the clone so it is not pruned itself.
let unallocatedBytes = try clonedVM.diskSizeBytes() - clonedVM.allocatedSizeBytes()
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
if reclaimBytes > 0 {
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), clonedVM)
}
}
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
Expand Down
2 changes: 1 addition & 1 deletion Sources/tart/Commands/Get.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ struct Get: AsyncParsableCommand {
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024

let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.diskSizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
print(format.renderSingle(info))
}
}
5 changes: 5 additions & 0 deletions Sources/tart/Commands/Import.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ struct Import: AsyncParsableCommand {
print("importing...")
try tmpVMDir.importFromArchive(path: path)

if tmpVMDir.isStackedLocal || tmpVMDir.isStackedOCIRecord {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
throw RuntimeError.ImportFailed("importing stacked VMs is not supported yet")
}

try await withTaskCancellationHandler(operation: {
// Acquire a global lock
let lock = try FileLock(lockURL: Config().tartHomeDir)
Expand Down
4 changes: 2 additions & 2 deletions Sources/tart/Commands/List.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ struct List: AsyncParsableCommand {
try VMInfo(
Source: "local",
Name: name,
Disk: vmDir.sizeGB(),
Disk: vmDir.diskSizeGB(),
Size: vmDir.allocatedSizeGB(),
Accessed: formatAccessDate(try vmDir.accessDate()),
Running: vmDir.running(),
Expand All @@ -56,7 +56,7 @@ struct List: AsyncParsableCommand {
try VMInfo(
Source: "OCI",
Name: name,
Disk: vmDir.sizeGB(),
Disk: vmDir.diskSizeGB(),
Size: vmDir.allocatedSizeGB(),
Accessed: formatAccessDate(try vmDir.accessDate()),
Running: vmDir.running(),
Expand Down
7 changes: 4 additions & 3 deletions Sources/tart/Commands/Push.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,18 @@ struct Push: AsyncParsableCommand {
references: references
)
} else {
pushedRemoteName = try await localVMDir.pushToRegistry(
let pushedImage = try await localVMDir.pushToRegistry(
registry: registry,
references: references,
chunkSizeMb: chunkSize,
concurrency: concurrency,
labels: parseLabels()
)
pushedRemoteName = pushedImage.name

// Populate the local cache (if requested)
if populateCache {
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
try ociStorage.populate(pushedImage.name, from: localVMDir, manifest: pushedImage.manifest)
}
}

Expand Down
18 changes: 18 additions & 0 deletions Sources/tart/Commands/Run.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,24 @@ struct AdditionalDisk {
if let remoteName = try? RemoteName(diskPath) {
let vmDir = try VMStorageOCI().open(remoteName)

if vmDir.isStackedOCIRecord {
// A stacked OCI record has no writable top overlay. Create one in a
// disposable directory for this additional-disk attachment.
let temporaryVMDir = try VMDirectory.temporary()
try FileManager.default.copyItem(at: vmDir.configURL, to: temporaryVMDir.configURL)
try FileManager.default.copyItem(at: vmDir.nvramURL, to: temporaryVMDir.nvramURL)
try FileManager.default.copyItem(at: vmDir.manifestURL, to: temporaryVMDir.manifestURL)
let stack = try temporaryVMDir.diskImageStack()
try stack.createWritableOverlay()
let attachment = try stack.makeAttachment(
readOnly: diskReadOnly,
cachingMode: try VZDiskImageCachingMode(cachingModeRaw) ?? .automatic,
synchronizationMode: try VZDiskImageSynchronizationMode(syncModeRaw)
)

return VZVirtioBlockDeviceConfiguration(attachment: attachment)
}

// Unfortunately, VZDiskImageStorageDeviceAttachment does not support
// FileHandle, so we can't easily clone the disk, open it and unlink(2)
// to simplify the garbage collection, so use an intermediate directory.
Expand Down
70 changes: 61 additions & 9 deletions Sources/tart/ContentStore.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import Foundation
import System

enum ContentStoreError: Error, Equatable {
case invalidContentDigest(String)
case contentDigestMismatch(expected: String, actual: String)
case operationFailed(String)
}

/// Opaque content-addressed storage for immutable reconstructed files.
Expand Down Expand Up @@ -39,15 +41,26 @@ struct ContentStore {
return targetURL.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).tmp")
}

/// Returns a validated cache hit. Corrupt files are treated as misses so a
/// later pull can safely rebuild them.
func existingContentURL(for contentDigest: String) throws -> URL? {
/// Returns a digest-addressed entry without rereading it. Pull verifies
/// content hashes before accepting a cache hit; clone only needs a cheap
/// structural check, like Tart's existing disk.img path.
func contentURLIfPresent(for contentDigest: String) throws -> URL? {
let url = try contentURL(for: contentDigest)

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

return url
}

/// Returns a validated cache hit. Corrupt files are treated as misses so a
/// later pull can safely rebuild them.
func existingContentURL(for contentDigest: String) throws -> URL? {
guard let url = try contentURLIfPresent(for: contentDigest) else {
return nil
}

guard try Digest.hash(url) == contentDigest else {
return nil
}
Expand All @@ -67,15 +80,54 @@ struct ContentStore {
let targetURL = try contentURL(for: contentDigest)
try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(), withIntermediateDirectories: true)

if let existingURL = try existingContentURL(for: contentDigest) {
try? FileManager.default.removeItem(at: temporaryURL)
return existingURL
while true {
if try moveItemWithoutReplacing(at: temporaryURL, to: targetURL) {
return targetURL
}

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

// The destination exists but is corrupt. Swapping keeps the digest path
// continuously populated: if another repair wins first, both sides of
// this exchange are already digest-valid and the result remains valid.
if try exchangeItem(at: temporaryURL, with: targetURL) {
try? FileManager.default.removeItem(at: temporaryURL)
return targetURL
}
}
}

/// Atomically publishes a content entry without replacing an existing one.
/// Returns false when another installer already created the destination.
private func moveItemWithoutReplacing(at sourceURL: URL, to destinationURL: URL) throws -> Bool {
if renamex_np(sourceURL.path, destinationURL.path, UInt32(RENAME_EXCL)) == 0 {
return true
}

if errno == EEXIST {
return false
}

try? FileManager.default.removeItem(at: targetURL)
try FileManager.default.moveItem(at: temporaryURL, to: targetURL)
let details = Errno(rawValue: CInt(errno))
throw ContentStoreError.operationFailed("failed to install content entry \(destinationURL.path): \(details)")
}

/// Atomically exchanges a verified temporary file with a corrupt content
/// entry. Returns false when the destination disappeared before the swap.
private func exchangeItem(at sourceURL: URL, with destinationURL: URL) throws -> Bool {
if renamex_np(sourceURL.path, destinationURL.path, UInt32(RENAME_SWAP)) == 0 {
return true
}

if errno == ENOENT {
return false
}

return targetURL
let details = Errno(rawValue: CInt(errno))
throw ContentStoreError.operationFailed("failed to repair content entry \(destinationURL.path): \(details)")
}

private func validatedDigestHex(_ contentDigest: String) throws -> String {
Expand Down
28 changes: 28 additions & 0 deletions Sources/tart/DiskAttachmentSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation
import Virtualization

/// A disk-image-backed source that can become a Virtualization.Framework storage attachment.
protocol DiskAttachmentSource {
func makeAttachment(
readOnly: Bool,
cachingMode: VZDiskImageCachingMode,
synchronizationMode: VZDiskImageSynchronizationMode
) throws -> VZStorageDeviceAttachment
}

struct DiskImageAttachment: DiskAttachmentSource {
let url: URL

func makeAttachment(
readOnly: Bool,
cachingMode: VZDiskImageCachingMode,
synchronizationMode: VZDiskImageSynchronizationMode
) throws -> VZStorageDeviceAttachment {
try VZDiskImageStorageDeviceAttachment(
url: url,
readOnly: readOnly,
cachingMode: cachingMode,
synchronizationMode: synchronizationMode
)
}
}
53 changes: 50 additions & 3 deletions Sources/tart/DiskImageStack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ enum DiskImageStackError: Error, Equatable, CustomStringConvertible {
}
}

struct DiskImageStack {
struct DiskImageStack: DiskAttachmentSource {
/// DiskImageKit-ready paths and geometry after Tart disk chunks have been
/// reconstructed into complete immutable files. The writable overlay stays
/// private to one VM.
Expand All @@ -48,6 +48,50 @@ struct DiskImageStack {
let blockSize: UInt64
let blockCount: UInt64

/// Reads a disk image's current geometry without resolving or validating a
/// whole stack. This is used for the VM's private writable overlay, whose
/// size may be newer than the pinned immutable parent manifest.
static func diskImageGeometry(at url: URL) throws -> (blockSize: UInt64, blockCount: UInt64) {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
let image = try DiskImage(opening: .open(url: url, mode: .readOnly))
return (UInt64(image.blockSize.rawValue), UInt64(image.blockCount))
}
#endif

throw DiskImageStackError.unavailable
}

static func baseGeometry(
at url: URL,
expectedFormat: DiskImageFormat
) throws -> (blockSize: UInt64, blockCount: UInt64) {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
let image = try DiskImage(opening: .open(url: url, mode: .readOnly))
let matchesFormat = switch expectedFormat {
case .raw:
image.format == .raw
case .asif:
image.format == .asif
}
guard matchesFormat else {
throw DiskImageStackError.invalidDiskImage(url, "base disk format does not match")
}
guard image.layerType == nil, image.parentUUID == nil else {
throw DiskImageStackError.invalidDiskImage(url, "base disk must not be an overlay")
}
if expectedFormat == .asif && image.layerUUID == nil {
throw DiskImageStackError.invalidDiskImage(url, "ASIF base disk is missing a UUID")
}

return (UInt64(image.blockSize.rawValue), UInt64(image.blockCount))
}
#endif

throw DiskImageStackError.unavailable
}

func createWritableOverlay() throws {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
Expand All @@ -68,12 +112,14 @@ struct DiskImageStack {
}

func makeAttachment(
readOnly: Bool = false,
cachingMode: VZDiskImageCachingMode = .automatic,
synchronizationMode: VZDiskImageSynchronizationMode = .full
) throws -> VZDiskImageStorageDeviceAttachment {
) throws -> VZStorageDeviceAttachment {
#if canImport(DiskImageKit)
if #available(macOS 27.0, *) {
return try attachmentWithDiskImageKit(
readOnly: readOnly,
cachingMode: cachingMode,
synchronizationMode: synchronizationMode
)
Expand Down Expand Up @@ -108,6 +154,7 @@ struct DiskImageStack {

@available(macOS 27.0, *)
private func attachmentWithDiskImageKit(
readOnly: Bool,
cachingMode: VZDiskImageCachingMode,
synchronizationMode: VZDiskImageSynchronizationMode
) throws -> VZDiskImageStorageDeviceAttachment {
Expand All @@ -121,7 +168,7 @@ struct DiskImageStack {
previousUUID: parent.topUUID,
previousBlockCount: parent.topBlockCount,
blockSize: diskImageBlockSize(blockSize),
mode: .readWrite
mode: readOnly ? .readOnly : .readWrite
)
let stackedImage = try parent.image.appending(writableOverlay.image)
try validateAppendedOverlay(stackedImage, at: writableOverlayURL)
Expand Down
Loading