mirror of https://github.com/cirruslabs/tart.git
Begin work on adding DiskImageKit to tart
This is first of several changes to add support for the new DiskImageKit ASIF layers to tart VM images. This change is focused on laying down the OCI media type for ASIF layers, content addressable store structure, as well as the VMDirectory structure for supporting layers. Add DiskImageStack type to model VM image using DiskImage APIs.
This commit is contained in:
parent
160b7cd692
commit
0a01b2ddaa
|
|
@ -0,0 +1,94 @@
|
|||
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
|
||||
|
||||
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)
|
||||
try FileManager.default.createDirectory(at: digestDirectoryURL, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func contentURL(for contentDigest: String) throws -> URL {
|
||||
let digestHex = try validatedDigestHex(contentDigest)
|
||||
|
||||
return digestDirectoryURL.appendingPathComponent(digestHex)
|
||||
}
|
||||
|
||||
func temporaryContentURL(for contentDigest: String) throws -> URL {
|
||||
let targetURL = try contentURL(for: contentDigest)
|
||||
|
||||
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? {
|
||||
let url = try contentURL(for: contentDigest)
|
||||
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard try Digest.hash(url) == 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:)` 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)
|
||||
|
||||
if let existingURL = try existingContentURL(for: contentDigest) {
|
||||
try? FileManager.default.removeItem(at: temporaryURL)
|
||||
return existingURL
|
||||
}
|
||||
|
||||
try? FileManager.default.removeItem(at: targetURL)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
#if canImport(DiskImageKit)
|
||||
import DiskImageKit
|
||||
#endif
|
||||
|
||||
/// One immutable complete disk file used by a stacked disk.
|
||||
///
|
||||
/// This is a reconstructed base disk or published ASIF overlay, not an OCI
|
||||
/// layer or an individual Tart disk chunk.
|
||||
struct DiskImageFile {
|
||||
let url: URL
|
||||
let contentDigest: String
|
||||
}
|
||||
|
||||
enum DiskImageStackError: Error, Equatable, CustomStringConvertible {
|
||||
case unavailable
|
||||
case writableOverlayAlreadyExists(URL)
|
||||
case writableOverlayMissing(URL)
|
||||
case invalidGeometry(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 .invalidGeometry(let reason):
|
||||
reason
|
||||
case .invalidDiskImage(let url, let reason):
|
||||
"\(reason): \(url.path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DiskImageStack {
|
||||
/// DiskImageKit-ready paths and geometry after Tart disk chunks have been
|
||||
/// reconstructed into complete immutable files. The writable overlay stays
|
||||
/// private to one VM.
|
||||
let base: DiskImageFile
|
||||
let baseFormat: DiskImageFormat
|
||||
let overlays: [DiskImageFile]
|
||||
let writableOverlayURL: URL
|
||||
let blockSize: UInt64
|
||||
let blockCount: UInt64
|
||||
|
||||
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(
|
||||
cachingMode: VZDiskImageCachingMode = .automatic,
|
||||
synchronizationMode: VZDiskImageSynchronizationMode = .full
|
||||
) throws -> VZDiskImageStorageDeviceAttachment {
|
||||
#if canImport(DiskImageKit)
|
||||
if #available(macOS 27.0, *) {
|
||||
return try attachmentWithDiskImageKit(
|
||||
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(
|
||||
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: .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.invalidGeometry("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.invalidGeometry("invalid stacked disk block count \(blockCount)")
|
||||
}
|
||||
|
||||
try verifyContentDigest(base)
|
||||
let baseImage = try DiskImage(opening: .open(url: base.url, mode: .readOnly))
|
||||
try validateBase(baseImage, at: base.url, expectedFormat: baseFormat)
|
||||
|
||||
var image = baseImage
|
||||
|
||||
for overlay in overlays {
|
||||
let openedOverlay = try openOverlay(
|
||||
at: overlay.url,
|
||||
expectedDigest: overlay.contentDigest,
|
||||
mode: .readOnly
|
||||
)
|
||||
let stackedImage = try append(openedOverlay, to: image, at: overlay.url)
|
||||
try validateAppendedOverlay(stackedImage, at: overlay.url)
|
||||
image = stackedImage
|
||||
}
|
||||
|
||||
guard image.blockSize == expectedBlockSize else {
|
||||
throw DiskImageStackError.invalidGeometry("immutable disk stack does not match manifest block size")
|
||||
}
|
||||
guard image.blockCount == expectedBlockCount else {
|
||||
throw DiskImageStackError.invalidGeometry("immutable disk stack does not match manifest block count")
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
@available(macOS 27.0, *)
|
||||
private 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,
|
||||
expectedDigest: String? = nil,
|
||||
mode: OpenConfiguration.Mode
|
||||
) throws -> DiskImage {
|
||||
if let expectedDigest {
|
||||
try verifyContentDigest(DiskImageFile(url: url, contentDigest: expectedDigest))
|
||||
}
|
||||
|
||||
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 verifyContentDigest(_ diskImage: DiskImageFile) throws {
|
||||
guard try Digest.hash(diskImage.url) == diskImage.contentDigest else {
|
||||
throw DiskImageStackError.invalidDiskImage(diskImage.url, "disk image content digest does not match")
|
||||
}
|
||||
}
|
||||
|
||||
@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.invalidGeometry("unsupported stacked disk block size \(value)")
|
||||
}
|
||||
|
||||
return blockSize
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
@ -7,11 +7,13 @@ 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"
|
||||
|
|
@ -19,6 +21,33 @@ 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?
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -63,6 +92,106 @@ 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)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -121,6 +250,14 @@ 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ 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)
|
||||
}
|
||||
|
|
@ -87,10 +90,56 @@ 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 {
|
||||
FileManager.default.fileExists(atPath: configURL.path) &&
|
||||
FileManager.default.fileExists(atPath: diskURL.path) &&
|
||||
FileManager.default.fileExists(atPath: nvramURL.path)
|
||||
layout?.isRunnable == true
|
||||
}
|
||||
|
||||
func initialize(overwrite: Bool = false) throws {
|
||||
|
|
@ -103,6 +152,9 @@ 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 {
|
||||
|
|
@ -111,8 +163,12 @@ struct VMDirectory: Prunable {
|
|||
}
|
||||
|
||||
if !initialized {
|
||||
throw RuntimeError.VMMissingFiles("VM is missing some of its files (\(configURL.lastPathComponent),"
|
||||
+ " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))")
|
||||
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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
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))
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
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 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 testRejectsWrongContentDigest() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
fixture.disk = DiskImageStack(
|
||||
base: DiskImageFile(url: fixture.disk.base.url, contentDigest: "sha256:wrong"),
|
||||
baseFormat: fixture.disk.baseFormat,
|
||||
overlays: fixture.disk.overlays,
|
||||
writableOverlayURL: fixture.disk.writableOverlayURL,
|
||||
blockSize: fixture.disk.blockSize,
|
||||
blockCount: fixture.disk.blockCount
|
||||
)
|
||||
|
||||
assertThrows(.invalidDiskImage(fixture.disk.base.url, "disk image content digest does not match")) {
|
||||
try fixture.disk.createWritableOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsWrongOverlayContentDigest() throws {
|
||||
let fixture = try Fixture(baseFormat: .asif, publishedOverlayCount: 1)
|
||||
fixture.disk = DiskImageStack(
|
||||
base: fixture.disk.base,
|
||||
baseFormat: fixture.disk.baseFormat,
|
||||
overlays: [
|
||||
DiskImageFile(url: fixture.disk.overlays[0].url, contentDigest: "sha256:wrong"),
|
||||
],
|
||||
writableOverlayURL: fixture.disk.writableOverlayURL,
|
||||
blockSize: fixture.disk.blockSize,
|
||||
blockCount: fixture.disk.blockCount
|
||||
)
|
||||
|
||||
assertThrows(.invalidDiskImage(fixture.disk.overlays[0].url, "disk image content digest does not match")) {
|
||||
try fixture.disk.createWritableOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
base: fixture.disk.base,
|
||||
baseFormat: fixture.disk.baseFormat,
|
||||
overlays: [
|
||||
DiskImageFile(url: overlayURL, contentDigest: try Digest.hash(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(
|
||||
base: fixture.disk.base,
|
||||
baseFormat: .asif,
|
||||
overlays: fixture.disk.overlays,
|
||||
writableOverlayURL: fixture.disk.writableOverlayURL,
|
||||
blockSize: fixture.disk.blockSize,
|
||||
blockCount: fixture.disk.blockCount
|
||||
)
|
||||
|
||||
assertThrows(.invalidDiskImage(fixture.disk.base.url, "base disk format does not match")) {
|
||||
try fixture.disk.createWritableOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsBlockSizeMismatch() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
fixture.disk = DiskImageStack(
|
||||
base: fixture.disk.base,
|
||||
baseFormat: fixture.disk.baseFormat,
|
||||
overlays: fixture.disk.overlays,
|
||||
writableOverlayURL: fixture.disk.writableOverlayURL,
|
||||
blockSize: 4096,
|
||||
blockCount: fixture.disk.blockCount
|
||||
)
|
||||
|
||||
assertThrows(.invalidGeometry("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(
|
||||
base: fixture.disk.base,
|
||||
baseFormat: fixture.disk.baseFormat,
|
||||
overlays: fixture.disk.overlays,
|
||||
writableOverlayURL: fixture.disk.writableOverlayURL,
|
||||
blockSize: 123,
|
||||
blockCount: fixture.disk.blockCount
|
||||
)
|
||||
|
||||
assertThrows(.invalidGeometry("unsupported stacked disk block size 123")) {
|
||||
try fixture.disk.createWritableOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsManifestBlockCountMismatch() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
fixture.disk = DiskImageStack(
|
||||
base: fixture.disk.base,
|
||||
baseFormat: fixture.disk.baseFormat,
|
||||
overlays: fixture.disk.overlays,
|
||||
writableOverlayURL: fixture.disk.writableOverlayURL,
|
||||
blockSize: fixture.disk.blockSize,
|
||||
blockCount: fixture.disk.blockCount + 1
|
||||
)
|
||||
|
||||
assertThrows(.invalidGeometry("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(
|
||||
base: fixture.disk.base,
|
||||
baseFormat: fixture.disk.baseFormat,
|
||||
overlays: fixture.disk.overlays,
|
||||
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(
|
||||
base: fixture.disk.base,
|
||||
baseFormat: fixture.disk.baseFormat,
|
||||
overlays: other.disk.overlays,
|
||||
writableOverlayURL: fixture.disk.writableOverlayURL,
|
||||
blockSize: fixture.disk.blockSize,
|
||||
blockCount: fixture.disk.blockCount
|
||||
)
|
||||
|
||||
assertThrows(.invalidDiskImage(other.disk.overlays[0].url, "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 overlays: [DiskImageFile] = []
|
||||
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))
|
||||
overlays.append(DiskImageFile(url: overlayURL, contentDigest: try Digest.hash(overlayURL)))
|
||||
image = stack
|
||||
}
|
||||
|
||||
disk = DiskImageStack(
|
||||
base: DiskImageFile(url: baseURL, contentDigest: try Digest.hash(baseURL)),
|
||||
baseFormat: baseFormat,
|
||||
overlays: overlays,
|
||||
writableOverlayURL: directory.appendingPathComponent("overlay.asif"),
|
||||
blockSize: 512,
|
||||
blockCount: 8
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
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 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 testManifestBlockGeometry() 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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
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)
|
||||
}
|
||||
|
||||
func testStackedLocalLayout() 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)
|
||||
}
|
||||
|
||||
func testStackedOCIRecordLayout() throws {
|
||||
let vmDir = try temporaryVMDirectory()
|
||||
|
||||
try touch(vmDir.configURL)
|
||||
try touch(vmDir.nvramURL)
|
||||
try touch(vmDir.manifestURL)
|
||||
|
||||
XCTAssertEqual(vmDir.layout, .stackedOCIRecord)
|
||||
XCTAssertFalse(vmDir.initialized)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue