From 8b86545a079daf596d50d09f3f42ae5ecce2de4d Mon Sep 17 00:00:00 2001 From: Ben Boeckel Date: Tue, 7 Jul 2026 12:10:05 -0400 Subject: [PATCH] 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 0000000..80021ba --- /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")" + ) + } + } +}