Skip to content

Save image to archive #152

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
142 changes: 142 additions & 0 deletions Sources/ContainerRegistry/TarImageDestination.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftContainerPlugin open source project
//
// Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import struct Foundation.Data
import class Foundation.OutputStream
import class Foundation.JSONDecoder
import class Foundation.JSONEncoder
import Tar

/// A tar file on disk, to which a container image can be saved.
public class TarImageDestination {
public var decoder: JSONDecoder
var encoder: JSONEncoder

var archive: Archive

/// Creates a new TarImageDestination
/// - Parameter stream: OutputStream to which the archive should be written
/// - Throws: If an error occurs when serializing archive.
public init(toStream stream: OutputStream) throws {
self.archive = Archive(toStream: stream)
self.decoder = JSONDecoder()
self.encoder = containerJSONEncoder()

try archive.appendFile(name: "oci-layout", data: [UInt8](encoder.encode(ImageLayoutHeader())))
try archive.appendDirectory(name: "blobs")
try archive.appendDirectory(name: "blobs/sha256")
}
}

extension TarImageDestination: ImageDestination {
/// Saves a blob of unstructured data to the destination.
/// - Parameters:
/// - repository: Name of the destination repository.
/// - mediaType: mediaType field for returned ContentDescriptor.
/// - data: Object to be saved.
/// - Returns: An ContentDescriptor object representing the
/// saved blob.
/// - Throws: If the blob cannot be encoded or the save fails.
public func putBlob(
repository: ImageReference.Repository,
mediaType: String,
data: Data
) async throws -> ContentDescriptor {
let digest = ImageReference.Digest(of: Data(data))
try archive.appendFile(name: "\(digest.value)", prefix: "blobs/\(digest.algorithm)", data: [UInt8](data))
return .init(mediaType: mediaType, digest: "\(digest)", size: Int64(data.count))
}

/// Saves a JSON object to the destination, serialized as an unstructured blob.
/// - Parameters:
/// - repository: Name of the destination repository.
/// - mediaType: mediaType field for returned ContentDescriptor.
/// - data: Object to be saved.
/// - Returns: An ContentDescriptor object representing the
/// saved blob.
/// - Throws: If the blob cannot be encoded or the save fails.
public func putBlob<Body: Encodable>(
repository: ImageReference.Repository,
mediaType: String,
data: Body
) async throws -> ContentDescriptor {
let encoded = try encoder.encode(data)
return try await putBlob(repository: repository, mediaType: mediaType, data: encoded)
}

/// Checks whether a blob exists.
///
/// - Parameters:
/// - repository: Name of the destination repository.
/// - digest: Digest of the requested blob.
/// - Returns: Always returns False.
/// - Throws: If the destination encounters an error.
public func blobExists(
repository: ImageReference.Repository,
digest: ImageReference.Digest
) async throws -> Bool {
false
}

/// Encodes and saves an image manifest.
///
/// - Parameters:
/// - repository: Name of the destination repository.
/// - reference: Optional tag to apply to this manifest.
/// - manifest: Manifest to be saved.
/// - Returns: An ContentDescriptor object representing the
/// saved blob.
/// - Throws: If the blob cannot be encoded or saved.
public func putManifest(
repository: ImageReference.Repository,
reference: (any ImageReference.Reference)?,
manifest: ImageManifest
) async throws -> ContentDescriptor {
// Manifests are not special in the on-disk representation - they are just stored as blobs
try await self.putBlob(
repository: repository,
mediaType: "application/vnd.oci.image.manifest.v1+json",
data: manifest
)
}

/// Encodes and saves an image index.
///
/// - Parameters:
/// - repository: Name of the destination repository.
/// - reference: Optional tag to apply to this index.
/// - index: Index to be saved.
/// - Returns: An ContentDescriptor object representing the
/// saved index.
/// - Throws: If the index cannot be encoded or saving fails.
public func putIndex(
repository: ImageReference.Repository,
reference: (any ImageReference.Reference)?,
index: ImageIndex
) async throws -> ContentDescriptor {
// Unlike Manifest, Index is not written as a blob
let encoded = try encoder.encode(index)
let digest = ImageReference.Digest(of: encoded)
let mediaType = index.mediaType ?? "application/vnd.oci.image.index.v1+json"

try archive.appendFile(name: "index.json", data: [UInt8](encoded))

try archive.appendFile(name: "\(digest.value)", prefix: "blobs/\(digest.algorithm)", data: [UInt8](encoded))
return .init(mediaType: mediaType, digest: "\(digest)", size: Int64(encoded.count))
}
}

struct ImageLayoutHeader: Codable {
var imageLayoutVersion: String = "1.0.0"
}
65 changes: 23 additions & 42 deletions Sources/Tar/tar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
//
//===----------------------------------------------------------------------===//

import Foundation

// This file defines a basic tar writer which produces POSIX tar files.
// This avoids the need to depend on a system-provided tar binary.
//
Expand Down Expand Up @@ -342,44 +344,40 @@ public func tar(_ bytes: [UInt8], filename: String = "app") throws -> [UInt8] {
}

/// Represents a tar archive
public struct Archive {
public struct Archive: ~Copyable {
/// The files, directories and other members of the archive
var members: [ArchiveMember]
var output: OutputStream

/// Creates an empty Archive
public init() {
members = []
public init(toStream: OutputStream = .toMemory()) {
output = toStream
output.open()
output.schedule(in: .current, forMode: .default) // is this needed?
}

/// Appends a member to the archive
/// Parameters:
/// - member: The member to append
public mutating func append(_ member: ArchiveMember) {
self.members.append(member)
deinit {
output.close()
}

/// Returns a new archive made by appending a member to the receiver
/// Appends a member to the archive
/// Parameters:
/// - member: The member to append
/// Returns: A new archive made by appending `member` to the receiver.
public func appending(_ member: ArchiveMember) -> Self {
var ret = self
ret.members += [member]
return ret
public func append(_ member: ArchiveMember) {
let written = output.write(member.bytes, maxLength: member.bytes.count)
if written != member.bytes.count {
fatalError("count: \(member.bytes.count), written: \(written)")
}
}

/// The serialized byte representation of the archive, including padding and end-of-archive marker.
public var bytes: [UInt8] {
var ret: [UInt8] = []
for member in members {
ret.append(contentsOf: member.bytes)
guard let data = output.property(forKey: .dataWrittenToMemoryStreamKey) as? Data else {
fatalError("retrieving memory stream contents")
}

// Append the end of file marker
let marker = [UInt8](repeating: 0, count: 2 * blockSize)
ret.append(contentsOf: marker)

return ret
return [UInt8](data) + marker
}
}

Expand Down Expand Up @@ -416,32 +414,15 @@ extension Archive {
/// - name: File name
/// - prefix: Path prefix
/// - data: File contents
public mutating func appendFile(name: String, prefix: String = "", data: [UInt8]) throws {
try append(.init(header: .init(name: name, size: data.count, prefix: prefix), data: data))
}

/// Adds a new file member at the end of the archive
/// parameters:
/// - name: File name
/// - prefix: Path prefix
/// - data: File contents
public func appendingFile(name: String, prefix: String = "", data: [UInt8]) throws -> Self {
try appending(.init(header: .init(name: name, size: data.count, prefix: prefix), data: data))
}

/// Adds a new directory member at the end of the archive
/// parameters:
/// - name: Directory name
/// - prefix: Path prefix
public mutating func appendDirectory(name: String, prefix: String = "") throws {
try append(.init(header: .init(name: name, typeflag: .DIRTYPE, prefix: prefix)))
public func appendFile(name: String, prefix: String = "", data: [UInt8]) throws {
try append(.init(header: .init(name: name, mode: 0o755, size: data.count, prefix: prefix), data: data))
}

/// Adds a new directory member at the end of the archive
/// parameters:
/// - name: Directory name
/// - prefix: Path prefix
public func appendingDirectory(name: String, prefix: String = "") throws -> Self {
try self.appending(.init(header: .init(name: name, typeflag: .DIRTYPE, prefix: prefix)))
public func appendDirectory(name: String, prefix: String = "") throws {
try append(.init(header: .init(name: name, mode: 0o755, typeflag: .DIRTYPE, prefix: prefix)))
}
}
20 changes: 8 additions & 12 deletions Sources/containertool/Extensions/Archive+appending.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,30 +30,28 @@ extension Archive {
/// Parameters:
/// - root: The path to the file or directory to add.
/// Returns: A new archive made by appending `root` to the receiver.
public func appendingRecursively(atPath root: String) throws -> Self {
public func appendRecursively(atPath root: String) throws {
let url = URL(fileURLWithPath: root)
if url.isDirectory {
return try self.appendingDirectoryTree(at: url)
try self.appendDirectoryTree(at: url)
} else {
return try self.appendingFile(at: url)
try self.appendFile(at: url)
}
}

/// Append a single file to the archive.
/// Parameters:
/// - path: The path to the file to add.
/// Returns: A new archive made by appending `path` to the receiver.
func appendingFile(at path: URL) throws -> Self {
try self.appendingFile(name: path.lastPathComponent, data: try [UInt8](Data(contentsOf: path)))
func appendFile(at path: URL) throws {
try self.appendFile(name: path.lastPathComponent, data: try [UInt8](Data(contentsOf: path)))
}

/// Recursively append a single directory tree to the archive.
/// Parameters:
/// - root: The path to the directory to add.
/// Returns: A new archive made by appending `root` to the receiver.
func appendingDirectoryTree(at root: URL) throws -> Self {
var ret = self

func appendDirectoryTree(at root: URL) throws {
guard let enumerator = FileManager.default.enumerator(atPath: root.path) else {
throw ("Unable to read \(root.path)")
}
Expand All @@ -69,16 +67,14 @@ extension Archive {
switch filetype {
case .typeRegular:
let resource = try [UInt8](Data(contentsOf: root.appending(path: subpath)))
try ret.appendFile(name: subpath, prefix: root.lastPathComponent, data: resource)
try self.appendFile(name: subpath, prefix: root.lastPathComponent, data: resource)

case .typeDirectory:
try ret.appendDirectory(name: subpath, prefix: root.lastPathComponent)
try self.appendDirectory(name: subpath, prefix: root.lastPathComponent)

default:
throw "Resource file \(subpath) of type \(filetype) is not supported"
}
}

return ret
}
}
15 changes: 11 additions & 4 deletions Sources/containertool/Extensions/RegistryClient+publish.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,11 @@ func publishContainerImage<Source: ImageSource, Destination: ImageDestination>(

var resourceLayers: [(descriptor: ContentDescriptor, diffID: ImageReference.Digest)] = []
for resourceDir in resources {
let resourceTardiff = try Archive().appendingRecursively(atPath: resourceDir).bytes
let resourceTardiff = Archive()
try resourceTardiff.appendRecursively(atPath: resourceDir)
let resourceLayer = try await destination.uploadLayer(
repository: destinationImage.repository,
contents: resourceTardiff
contents: resourceTardiff.bytes
)

if verbose {
Expand All @@ -64,9 +65,12 @@ func publishContainerImage<Source: ImageSource, Destination: ImageDestination>(

// MARK: Upload the application layer

let applicationTardiff = Archive()
try applicationTardiff.appendFile(at: executableURL)

let applicationLayer = try await destination.uploadLayer(
repository: destinationImage.repository,
contents: try Archive().appendingFile(at: executableURL).bytes
contents: applicationTardiff.bytes
)
if verbose {
log("application layer: \(applicationLayer.descriptor.digest) (\(applicationLayer.descriptor.size) bytes)")
Expand Down Expand Up @@ -156,7 +160,10 @@ func publishContainerImage<Source: ImageSource, Destination: ImageDestination>(
mediaType: manifestDescriptor.mediaType,
digest: manifestDescriptor.digest,
size: Int64(manifestDescriptor.size),
platform: .init(architecture: architecture, os: os)
platform: .init(architecture: architecture, os: os),
annotations: [
"org.opencontainers.image.ref.name": "\(destinationImage)"
]
)
]
)
Expand Down
Loading