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.
This commit is contained in:
Ben Boeckel 2026-07-07 12:46:16 -04:00
parent 392462ec10
commit 9207c5ec6b
3 changed files with 40 additions and 26 deletions

View File

@ -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"

View File

@ -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,
]

View File

@ -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 diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64
// 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)
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
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 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)
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...")