From 2087608d5026b772a546ff7c9ed72c97e93bf852 Mon Sep 17 00:00:00 2001 From: Ben Boeckel Date: Tue, 7 Jul 2026 12:09:59 -0400 Subject: [PATCH 1/9] feat(oci): extract BlobStorage protocol and conform Registry --- Sources/tart/OCI/BlobStorage.swift | 7 +++++++ Sources/tart/OCI/Registry.swift | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 Sources/tart/OCI/BlobStorage.swift diff --git a/Sources/tart/OCI/BlobStorage.swift b/Sources/tart/OCI/BlobStorage.swift new file mode 100644 index 00000000..c99ae323 --- /dev/null +++ b/Sources/tart/OCI/BlobStorage.swift @@ -0,0 +1,7 @@ +import Foundation + +protocol BlobStorage { + func pushBlob(fromData: Data, chunkSizeMb: Int, digest: String?) async throws -> String + func blobExists(_ digest: String) async throws -> Bool + func pushManifest(reference: String, manifest: OCIManifest) async throws -> String +} diff --git a/Sources/tart/OCI/Registry.swift b/Sources/tart/OCI/Registry.swift index 25d13cb5..d26c3c5e 100644 --- a/Sources/tart/OCI/Registry.swift +++ b/Sources/tart/OCI/Registry.swift @@ -110,7 +110,7 @@ struct TokenResponse: Decodable, Authentication { } } -class Registry { +class Registry: BlobStorage { private let baseURL: URL let namespace: String let credentialsProviders: [CredentialsProvider] From dd5993d752a3f18295cf8df495fa81586a901b63 Mon Sep 17 00:00:00 2001 From: Ben Boeckel Date: Tue, 7 Jul 2026 12:10:02 -0400 Subject: [PATCH 2/9] feat(oci): accept any BlobStorage in DiskV2 --- Sources/tart/OCI/Layerizer/Disk.swift | 2 +- Sources/tart/OCI/Layerizer/DiskV2.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/tart/OCI/Layerizer/Disk.swift b/Sources/tart/OCI/Layerizer/Disk.swift index 051f5439..2734b959 100644 --- a/Sources/tart/OCI/Layerizer/Disk.swift +++ b/Sources/tart/OCI/Layerizer/Disk.swift @@ -1,6 +1,6 @@ import Foundation protocol Disk { - static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] + static func push(diskURL: URL, registry: any BlobStorage, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws } diff --git a/Sources/tart/OCI/Layerizer/DiskV2.swift b/Sources/tart/OCI/Layerizer/DiskV2.swift index 2239985d..5cdabd5a 100644 --- a/Sources/tart/OCI/Layerizer/DiskV2.swift +++ b/Sources/tart/OCI/Layerizer/DiskV2.swift @@ -22,7 +22,7 @@ class DiskV2: Disk { private static let holeGranularityBytes = 4 * 1024 * 1024 private static let zeroChunk = Data(count: holeGranularityBytes) - static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] { + static func push(diskURL: URL, registry: any BlobStorage, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] { var pushedLayers: [(index: Int, pushedLayer: OCIManifestLayer)] = [] // Open the disk file From 8b86545a079daf596d50d09f3f42ae5ecce2de4d Mon Sep 17 00:00:00 2001 From: Ben Boeckel Date: Tue, 7 Jul 2026 12:10:05 -0400 Subject: [PATCH 3/9] feat(oci): add OCIArchiveWriter for OCI Image Layout tar archives --- Sources/tart/OCI/OCIArchiveWriter.swift | 113 ++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 Sources/tart/OCI/OCIArchiveWriter.swift diff --git a/Sources/tart/OCI/OCIArchiveWriter.swift b/Sources/tart/OCI/OCIArchiveWriter.swift new file mode 100644 index 00000000..80021bab --- /dev/null +++ b/Sources/tart/OCI/OCIArchiveWriter.swift @@ -0,0 +1,113 @@ +import Foundation + +class OCIArchiveWriter { + private let tmpDir: URL + private let blobsDir: URL + private var manifestDigest: String? + private var manifestSize: Int? + private var manifestReferences: [String] = [] + private var manifestData: Data? + + init() throws { + tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString) + blobsDir = tmpDir.appendingPathComponent("blobs/sha256") + try FileManager.default.createDirectory(at: blobsDir, withIntermediateDirectories: true) + } + + deinit { + try? FileManager.default.removeItem(at: tmpDir) + } +} + +extension OCIArchiveWriter: BlobStorage { + func pushBlob(fromData: Data, chunkSizeMb: Int, digest: String?) async throws -> String { + let resolvedDigest = digest ?? Digest.hash(fromData) + let hex = resolvedDigest.replacingOccurrences(of: "sha256:", with: "") + let blobPath = blobsDir.appendingPathComponent(hex) + try fromData.write(to: blobPath) + return resolvedDigest + } + + func blobExists(_ digest: String) async throws -> Bool { + let hex = digest.replacingOccurrences(of: "sha256:", with: "") + let blobPath = blobsDir.appendingPathComponent(hex) + return FileManager.default.fileExists(atPath: blobPath.path) + } + + func pushManifest(reference: String, manifest: OCIManifest) async throws -> String { + if let existingDigest = manifestDigest, let existingData = manifestData { + let newData = try manifest.toJSON() + if newData == existingData { + manifestReferences.append(reference) + return existingDigest + } + } + + let data = try manifest.toJSON() + let digest = Digest.hash(data) + let hex = digest.replacingOccurrences(of: "sha256:", with: "") + let blobPath = blobsDir.appendingPathComponent(hex) + try data.write(to: blobPath) + manifestDigest = digest + manifestSize = data.count + manifestData = data + manifestReferences.append(reference) + return digest + } + + func finalize(path: String, tag: String? = nil) throws { + guard let manifestDigest = manifestDigest, let manifestSize = manifestSize else { + throw RuntimeError.Generic("no manifest was pushed") + } + + let ociLayoutData = try JSONSerialization.data(withJSONObject: ["imageLayoutVersion": "1.0.0"]) + try ociLayoutData.write(to: tmpDir.appendingPathComponent("oci-layout")) + + var manifests: [[String: Any]] = [] + + let baseDescriptor: [String: Any] = [ + "mediaType": ociManifestMediaType, + "digest": manifestDigest, + "size": manifestSize, + ] + + let refs = manifestReferences.isEmpty + ? (tag.map { [$0] } ?? ["latest"]) + : manifestReferences + + for ref in refs { + var entry = baseDescriptor + entry["annotations"] = [ + "org.opencontainers.image.ref.name": ref + ] + manifests.append(entry) + } + + let index: [String: Any] = [ + "schemaVersion": 2, + "manifests": manifests + ] + + let indexData = try JSONSerialization.data(withJSONObject: index, options: [.prettyPrinted, .sortedKeys]) + try indexData.write(to: tmpDir.appendingPathComponent("index.json")) + + let absolutePath = URL(fileURLWithPath: path).path + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/tar") + process.arguments = ["-cf", absolutePath, "-C", tmpDir.path, "."] + + let pipe = Pipe() + process.standardError = pipe + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + let errorData = pipe.fileHandleForReading.readDataToEndOfFile() + throw RuntimeError.Generic( + "creating OCI archive failed: \(String(data: errorData, encoding: .utf8) ?? "unknown error")" + ) + } + } +} From fcfacfec030f9ed21bdbf80e7a76f0780ad49c1e Mon Sep 17 00:00:00 2001 From: Ben Boeckel Date: Tue, 7 Jul 2026 12:10:08 -0400 Subject: [PATCH 4/9] feat(oci): add saveToArchive method to VMDirectory --- Sources/tart/VMDirectory+OCIArchive.swift | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 Sources/tart/VMDirectory+OCIArchive.swift diff --git a/Sources/tart/VMDirectory+OCIArchive.swift b/Sources/tart/VMDirectory+OCIArchive.swift new file mode 100644 index 00000000..0f51e985 --- /dev/null +++ b/Sources/tart/VMDirectory+OCIArchive.swift @@ -0,0 +1,47 @@ +import Foundation + +extension VMDirectory { + func saveToArchive(path: String, concurrency: UInt, labels: [String: String] = [:], tag: String? = nil) async throws { + let archive = try OCIArchiveWriter() + + var layers = [OCIManifestLayer]() + + let config = try VMConfig(fromURL: configURL) + var labels = labels + labels[diskFormatLabel] = config.diskFormat.rawValue + let configJSON = try JSONEncoder().encode(config) + defaultLogger.appendNewLine("saving config...") + let configDigest = try await archive.pushBlob(fromData: configJSON, chunkSizeMb: 0, digest: nil) + layers.append(OCIManifestLayer(mediaType: configMediaType, size: configJSON.count, digest: configDigest)) + + let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64 + defaultLogger.appendNewLine("saving 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: archive, chunkSizeMb: 0, concurrency: concurrency, progress: progress)) + + defaultLogger.appendNewLine("saving NVRAM...") + let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()! + let nvramDigest = try await archive.pushBlob(fromData: nvram, chunkSizeMb: 0, digest: nil) + layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest)) + + let ociConfigContainer = OCIConfig.ConfigContainer(Labels: labels) + let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os, config: ociConfigContainer).toJSON() + let ociConfigDigest = try await archive.pushBlob(fromData: ociConfigJSON, chunkSizeMb: 0, digest: nil) + let manifest = OCIManifest( + config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest), + layers: layers, + uncompressedDiskSize: UInt64(diskSize), + uploadDate: Date() + ) + + let tagRef = tag ?? "latest" + defaultLogger.appendNewLine("saving manifest...") + _ = try await archive.pushManifest(reference: tagRef, manifest: manifest) + + try archive.finalize(path: path, tag: tagRef) + + defaultLogger.appendNewLine("saved to \(path)") + } +} From b5253d5e89c36331055f81194b5a36ffea2236b7 Mon Sep 17 00:00:00 2001 From: Ben Boeckel Date: Tue, 7 Jul 2026 12:10:11 -0400 Subject: [PATCH 5/9] feat(save): add tart save command for OCI archive output --- Sources/tart/Commands/Save.swift | 63 ++++++++++++++++++++++++++++++++ Sources/tart/Root.swift | 1 + 2 files changed, 64 insertions(+) create mode 100644 Sources/tart/Commands/Save.swift diff --git a/Sources/tart/Commands/Save.swift b/Sources/tart/Commands/Save.swift new file mode 100644 index 00000000..0f25bfa4 --- /dev/null +++ b/Sources/tart/Commands/Save.swift @@ -0,0 +1,63 @@ +import ArgumentParser +import Foundation + +struct Save: AsyncParsableCommand { + static var configuration = CommandConfiguration(abstract: "Save a VM to an OCI archive file") + + @Argument(help: "local VM name", completion: .custom(completeMachines)) + var localName: String + + @Argument(help: "output archive path", completion: .file()) + var path: String + + @Option(help: "concurrency for disk layer compression") + var concurrency: UInt = 4 + + @Option(name: [.customLong("label")], help: ArgumentHelp("additional metadata to attach to the OCI image configuration in key=value format", + discussion: "Can be specified multiple times to attach multiple labels.")) + var labels: [String] = [] + + @Option(help: "tag to assign to the saved image (default: latest)") + var tag: String? + + func run() async throws { + let localVMDir = try VMStorageHelper.open(localName) + let lock = try localVMDir.lock() + if try !lock.trylock() { + throw RuntimeError.VMIsRunning(localName) + } + + let resolvedPath: String + if path.hasPrefix("/") { + resolvedPath = path + } else { + resolvedPath = FileManager.default.currentDirectoryPath + "/" + path + } + + try await localVMDir.saveToArchive( + path: resolvedPath, + concurrency: concurrency, + labels: parseLabels(), + tag: tag + ) + } + + func parseLabels() -> [String: String] { + var result = [String: String]() + + for label in labels { + let parts = label.trimmingCharacters(in: .whitespaces).split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + + let key = parts.count > 0 ? String(parts[0]) : "" + let value = parts.count > 1 ? String(parts[1]) : "" + + if key.isEmpty { + continue + } + + result[key] = value + } + + return result + } +} diff --git a/Sources/tart/Root.swift b/Sources/tart/Root.swift index bb2ed3f5..45f95719 100644 --- a/Sources/tart/Root.swift +++ b/Sources/tart/Root.swift @@ -27,6 +27,7 @@ struct Root: AsyncParsableCommand { Export.self, Prune.self, Rename.self, + Save.self, Stop.self, Delete.self, FQN.self, From 6231f9502145deee46fdaea7fe3187c31370572f Mon Sep 17 00:00:00 2001 From: Ben Boeckel Date: Tue, 7 Jul 2026 12:36:36 -0400 Subject: [PATCH 6/9] fix(save): use Docker v2s2 manifest format for skopeo compatibility --- Sources/tart/OCI/Manifest.swift | 4 ++++ Sources/tart/OCI/OCIArchiveWriter.swift | 2 +- Sources/tart/VMDirectory+OCIArchive.swift | 9 +++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Sources/tart/OCI/Manifest.swift b/Sources/tart/OCI/Manifest.swift index 897bd973..c4664355 100644 --- a/Sources/tart/OCI/Manifest.swift +++ b/Sources/tart/OCI/Manifest.swift @@ -4,6 +4,10 @@ import Foundation let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json" let ociConfigMediaType = "application/vnd.oci.image.config.v1+json" +// Docker manifest and config media types (schema v2) +let dockerManifestMediaType = "application/vnd.docker.distribution.manifest.v2+json" +let dockerConfigMediaType = "application/vnd.docker.container.image.v1+json" + // Layer media types let configMediaType = "application/vnd.cirruslabs.tart.config.v1" let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2" diff --git a/Sources/tart/OCI/OCIArchiveWriter.swift b/Sources/tart/OCI/OCIArchiveWriter.swift index 80021bab..b5738664 100644 --- a/Sources/tart/OCI/OCIArchiveWriter.swift +++ b/Sources/tart/OCI/OCIArchiveWriter.swift @@ -66,7 +66,7 @@ extension OCIArchiveWriter: BlobStorage { var manifests: [[String: Any]] = [] let baseDescriptor: [String: Any] = [ - "mediaType": ociManifestMediaType, + "mediaType": dockerManifestMediaType, "digest": manifestDigest, "size": manifestSize, ] diff --git a/Sources/tart/VMDirectory+OCIArchive.swift b/Sources/tart/VMDirectory+OCIArchive.swift index 0f51e985..00336bda 100644 --- a/Sources/tart/VMDirectory+OCIArchive.swift +++ b/Sources/tart/VMDirectory+OCIArchive.swift @@ -29,12 +29,17 @@ 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 archive.pushBlob(fromData: ociConfigJSON, chunkSizeMb: 0, digest: nil) - let manifest = OCIManifest( - config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest), + + var manifestConfig = OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest) + manifestConfig.mediaType = dockerConfigMediaType + + var manifest = OCIManifest( + config: manifestConfig, layers: layers, uncompressedDiskSize: UInt64(diskSize), uploadDate: Date() ) + manifest.mediaType = dockerManifestMediaType let tagRef = tag ?? "latest" defaultLogger.appendNewLine("saving manifest...") From 392462ec101c61fed006014f646a53cb391d0cbd Mon Sep 17 00:00:00 2001 From: Ben Boeckel Date: Tue, 7 Jul 2026 12:38:30 -0400 Subject: [PATCH 7/9] fix(save): lock staging directory against concurrent gc --- Sources/tart/OCI/OCIArchiveWriter.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/tart/OCI/OCIArchiveWriter.swift b/Sources/tart/OCI/OCIArchiveWriter.swift index b5738664..c66833da 100644 --- a/Sources/tart/OCI/OCIArchiveWriter.swift +++ b/Sources/tart/OCI/OCIArchiveWriter.swift @@ -3,6 +3,7 @@ import Foundation class OCIArchiveWriter { private let tmpDir: URL private let blobsDir: URL + private let lock: FileLock private var manifestDigest: String? private var manifestSize: Int? private var manifestReferences: [String] = [] @@ -12,9 +13,14 @@ class OCIArchiveWriter { tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString) blobsDir = tmpDir.appendingPathComponent("blobs/sha256") try FileManager.default.createDirectory(at: blobsDir, withIntermediateDirectories: true) + lock = try FileLock(lockURL: tmpDir) + if try !lock.trylock() { + throw RuntimeError.Generic("failed to lock archive staging directory") + } } deinit { + try? lock.unlock() try? FileManager.default.removeItem(at: tmpDir) } } From 9207c5ec6b456e3f37745a31f3803baa905402bb Mon Sep 17 00:00:00 2001 From: Ben Boeckel Date: Tue, 7 Jul 2026 12:46:16 -0400 Subject: [PATCH 8/9] fix(save): use standard OCI layer format for skopeo compatibility Replace custom Cirrus Labs layer media types (tart.config.v1, tart.disk.v2, tart.nvram.v1) with a single standard application/vnd.oci.image.layer.v1.tar+gzip layer containing the VM files as a tar+gzip archive. This makes the archive fully compatible with skopeo, Docker, and any OCI-compliant tool, since all layers use standard media types. The OCI config labels still carry tart-specific metadata (disk format, etc.) for round-tripping. --- Sources/tart/OCI/Manifest.swift | 3 ++ Sources/tart/OCI/OCIArchiveWriter.swift | 4 +- Sources/tart/VMDirectory+OCIArchive.swift | 57 ++++++++++++++--------- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/Sources/tart/OCI/Manifest.swift b/Sources/tart/OCI/Manifest.swift index c4664355..77a6bdd9 100644 --- a/Sources/tart/OCI/Manifest.swift +++ b/Sources/tart/OCI/Manifest.swift @@ -8,6 +8,9 @@ let ociConfigMediaType = "application/vnd.oci.image.config.v1+json" let dockerManifestMediaType = "application/vnd.docker.distribution.manifest.v2+json" let dockerConfigMediaType = "application/vnd.docker.container.image.v1+json" +// Standard OCI layer media type +let ociLayerMediaType = "application/vnd.oci.image.layer.v1.tar+gzip" + // Layer media types let configMediaType = "application/vnd.cirruslabs.tart.config.v1" let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2" diff --git a/Sources/tart/OCI/OCIArchiveWriter.swift b/Sources/tart/OCI/OCIArchiveWriter.swift index c66833da..49ab36a3 100644 --- a/Sources/tart/OCI/OCIArchiveWriter.swift +++ b/Sources/tart/OCI/OCIArchiveWriter.swift @@ -1,7 +1,7 @@ import Foundation class OCIArchiveWriter { - private let tmpDir: URL + let tmpDir: URL private let blobsDir: URL private let lock: FileLock private var manifestDigest: String? @@ -72,7 +72,7 @@ extension OCIArchiveWriter: BlobStorage { var manifests: [[String: Any]] = [] let baseDescriptor: [String: Any] = [ - "mediaType": dockerManifestMediaType, + "mediaType": ociManifestMediaType, "digest": manifestDigest, "size": manifestSize, ] diff --git a/Sources/tart/VMDirectory+OCIArchive.swift b/Sources/tart/VMDirectory+OCIArchive.swift index 00336bda..6d3c63a5 100644 --- a/Sources/tart/VMDirectory+OCIArchive.swift +++ b/Sources/tart/VMDirectory+OCIArchive.swift @@ -4,42 +4,53 @@ extension VMDirectory { func saveToArchive(path: String, concurrency: UInt, labels: [String: String] = [:], tag: String? = nil) async throws { let archive = try OCIArchiveWriter() - var layers = [OCIManifestLayer]() - - let config = try VMConfig(fromURL: configURL) - var labels = labels - labels[diskFormatLabel] = config.diskFormat.rawValue - let configJSON = try JSONEncoder().encode(config) - defaultLogger.appendNewLine("saving config...") - let configDigest = try await archive.pushBlob(fromData: configJSON, chunkSizeMb: 0, digest: nil) - layers.append(OCIManifestLayer(mediaType: configMediaType, size: configJSON.count, digest: configDigest)) - let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64 - defaultLogger.appendNewLine("saving disk... this will take a while...") + + // Create a standard tar+gzip layer containing VM files + defaultLogger.appendNewLine("archiving 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: archive, chunkSizeMb: 0, concurrency: concurrency, progress: progress)) + let layerTarGz = archive.tmpDir.appendingPathComponent("layer.tar.gz") + + let tarProcess = Process() + tarProcess.executableURL = URL(fileURLWithPath: "/usr/bin/tar") + tarProcess.arguments = ["-czf", layerTarGz.path, "-C", baseURL.path, + "disk.img", "nvram.bin", "config.json"] + + let tarPipe = Pipe() + tarProcess.standardError = tarPipe - defaultLogger.appendNewLine("saving NVRAM...") - let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()! - let nvramDigest = try await archive.pushBlob(fromData: nvram, chunkSizeMb: 0, digest: nil) - layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest)) + try tarProcess.run() + tarProcess.waitUntilExit() + + if tarProcess.terminationStatus != 0 { + let errorData = tarPipe.fileHandleForReading.readDataToEndOfFile() + throw RuntimeError.Generic( + "creating archive layer failed: \(String(data: errorData, encoding: .utf8) ?? "unknown error")" + ) + } + + let layerData = try Data(contentsOf: layerTarGz, options: .alwaysMapped) + let layerDigest = try await archive.pushBlob(fromData: layerData, chunkSizeMb: 0, digest: nil) + progress.completedUnitCount = diskSize + + let config = try VMConfig(fromURL: configURL) + var labels = labels + labels[diskFormatLabel] = config.diskFormat.rawValue let ociConfigContainer = OCIConfig.ConfigContainer(Labels: labels) let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os, config: ociConfigContainer).toJSON() let ociConfigDigest = try await archive.pushBlob(fromData: ociConfigJSON, chunkSizeMb: 0, digest: nil) - var manifestConfig = OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest) - manifestConfig.mediaType = dockerConfigMediaType - - var manifest = OCIManifest( - config: manifestConfig, - layers: layers, + let manifest = OCIManifest( + config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest), + layers: [ + OCIManifestLayer(mediaType: ociLayerMediaType, size: layerData.count, digest: layerDigest) + ], uncompressedDiskSize: UInt64(diskSize), uploadDate: Date() ) - manifest.mediaType = dockerManifestMediaType let tagRef = tag ?? "latest" defaultLogger.appendNewLine("saving manifest...") From 92b64f6b9a9a6c94e872932ff63bcbb7d0dbce12 Mon Sep 17 00:00:00 2001 From: Ben Boeckel Date: Tue, 7 Jul 2026 12:54:01 -0400 Subject: [PATCH 9/9] fix(save): match tart push format exactly for skopeo compatibility Use the same OCI manifest format and custom Cirrus Labs layer media types (config.v1, disk.v2, nvram.v1) that tart push produces. skopeo copy --format oci already works with images pushed by tart push, so the archive now matches that format exactly. --- Sources/tart/OCI/Manifest.swift | 3 -- Sources/tart/OCI/OCIArchiveWriter.swift | 2 +- Sources/tart/VMDirectory+OCIArchive.swift | 49 ++++++++--------------- 3 files changed, 18 insertions(+), 36 deletions(-) diff --git a/Sources/tart/OCI/Manifest.swift b/Sources/tart/OCI/Manifest.swift index 77a6bdd9..c4664355 100644 --- a/Sources/tart/OCI/Manifest.swift +++ b/Sources/tart/OCI/Manifest.swift @@ -8,9 +8,6 @@ let ociConfigMediaType = "application/vnd.oci.image.config.v1+json" let dockerManifestMediaType = "application/vnd.docker.distribution.manifest.v2+json" let dockerConfigMediaType = "application/vnd.docker.container.image.v1+json" -// Standard OCI layer media type -let ociLayerMediaType = "application/vnd.oci.image.layer.v1.tar+gzip" - // Layer media types let configMediaType = "application/vnd.cirruslabs.tart.config.v1" let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2" diff --git a/Sources/tart/OCI/OCIArchiveWriter.swift b/Sources/tart/OCI/OCIArchiveWriter.swift index 49ab36a3..6c814d47 100644 --- a/Sources/tart/OCI/OCIArchiveWriter.swift +++ b/Sources/tart/OCI/OCIArchiveWriter.swift @@ -1,7 +1,7 @@ import Foundation class OCIArchiveWriter { - let tmpDir: URL + private let tmpDir: URL private let blobsDir: URL private let lock: FileLock private var manifestDigest: String? diff --git a/Sources/tart/VMDirectory+OCIArchive.swift b/Sources/tart/VMDirectory+OCIArchive.swift index 6d3c63a5..c3369891 100644 --- a/Sources/tart/VMDirectory+OCIArchive.swift +++ b/Sources/tart/VMDirectory+OCIArchive.swift @@ -4,40 +4,27 @@ extension VMDirectory { func saveToArchive(path: String, concurrency: UInt, labels: [String: String] = [:], tag: String? = nil) async throws { let archive = try OCIArchiveWriter() - let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64 + var layers = [OCIManifestLayer]() - // Create a standard tar+gzip layer containing VM files - defaultLogger.appendNewLine("archiving disk... this will take a while...") + let config = try VMConfig(fromURL: configURL) + var labels = labels + labels[diskFormatLabel] = config.diskFormat.rawValue + let configJSON = try JSONEncoder().encode(config) + defaultLogger.appendNewLine("saving config...") + let configDigest = try await archive.pushBlob(fromData: configJSON, chunkSizeMb: 0, digest: nil) + layers.append(OCIManifestLayer(mediaType: configMediaType, size: configJSON.count, digest: configDigest)) + + let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64 + defaultLogger.appendNewLine("saving disk... this will take a while...") let progress = Progress(totalUnitCount: diskSize) ProgressObserver(progress).log(defaultLogger) - let layerTarGz = archive.tmpDir.appendingPathComponent("layer.tar.gz") - - let tarProcess = Process() - tarProcess.executableURL = URL(fileURLWithPath: "/usr/bin/tar") - tarProcess.arguments = ["-czf", layerTarGz.path, "-C", baseURL.path, - "disk.img", "nvram.bin", "config.json"] + layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: archive, chunkSizeMb: 0, concurrency: concurrency, progress: progress)) - let tarPipe = Pipe() - tarProcess.standardError = tarPipe - - try tarProcess.run() - tarProcess.waitUntilExit() - - if tarProcess.terminationStatus != 0 { - let errorData = tarPipe.fileHandleForReading.readDataToEndOfFile() - throw RuntimeError.Generic( - "creating archive layer failed: \(String(data: errorData, encoding: .utf8) ?? "unknown error")" - ) - } - - let layerData = try Data(contentsOf: layerTarGz, options: .alwaysMapped) - let layerDigest = try await archive.pushBlob(fromData: layerData, chunkSizeMb: 0, digest: nil) - progress.completedUnitCount = diskSize - - let config = try VMConfig(fromURL: configURL) - var labels = labels - labels[diskFormatLabel] = config.diskFormat.rawValue + defaultLogger.appendNewLine("saving NVRAM...") + let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()! + let nvramDigest = try await archive.pushBlob(fromData: nvram, chunkSizeMb: 0, digest: nil) + layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest)) let ociConfigContainer = OCIConfig.ConfigContainer(Labels: labels) let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os, config: ociConfigContainer).toJSON() @@ -45,9 +32,7 @@ extension VMDirectory { let manifest = OCIManifest( config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest), - layers: [ - OCIManifestLayer(mediaType: ociLayerMediaType, size: layerData.count, digest: layerDigest) - ], + layers: layers, uncompressedDiskSize: UInt64(diskSize), uploadDate: Date() )