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 4 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
115 changes: 115 additions & 0 deletions Sources/ContainerRegistry/TarImageDestination.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//===----------------------------------------------------------------------===//
//
// 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

public class TarImageDestination {
public var decoder: JSONDecoder
var encoder: JSONEncoder

var archive: 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.
/// On the wire, all blob uploads are `application/octet-stream'.
/// - data: Object to be uploaded.
/// - 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.
/// On the wire, all blob uploads are `application/octet-stream'.
/// - data: Object to be uploaded.
/// - 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)
}

public func blobExists(
repository: ImageReference.Repository,
digest: ImageReference.Digest
) async throws -> Bool {
false
}

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
)
}

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"
}
69 changes: 27 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,44 @@ 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) {
print("append: \(member.bytes.count)")
let written = output.write(member.bytes, maxLength: member.bytes.count)
print("append wrote: \(written)")
if written != member.bytes.count {
print("count: \(member.bytes.count), written: \(written)")
fatalError()
}
}

/// 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")
}
print("bytes returned: \(data.count)")

// 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 +418,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": "containertool-save"
]
)
]
)
Expand Down
27 changes: 21 additions & 6 deletions Sources/containertool/containertool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti

@Option(help: "The base container image name and optional tag")
var from: String?

@Option(name: [.long, .short], help: "File in which the container image should be saved")
var output: URL
}

@OptionGroup(title: "Source and destination repository options")
Expand Down Expand Up @@ -203,12 +206,17 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
if verbose { log("Connected to source registry: \(baseImage.registry)") }
}

let destination = try await RegistryClient(
registry: destinationImage.registry,
insecure: authenticationOptions.allowInsecureHttp == .destination
|| authenticationOptions.allowInsecureHttp == .both,
auth: .init(username: username, password: password, auth: authProvider)
)
// let destination = try await RegistryClient(
// registry: destinationImage.registry,
// insecure: authenticationOptions.allowInsecureHttp == .destination
// || authenticationOptions.allowInsecureHttp == .both,
// auth: .init(username: username, password: password, auth: authProvider)
// )

guard let saveStream = OutputStream(url: repositoryOptions.output, append: false) else {
fatalError("failed to create tarball")
}
let destination = try TarImageDestination(toStream: saveStream)

if verbose { log("Connected to destination registry: \(destinationImage.registry)") }
if verbose { log("Using base image: \(baseImage)") }
Expand All @@ -231,3 +239,10 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
print(finalImage)
}
}

// Parse URL path arguments
extension Foundation.URL: ArgumentParser.ExpressibleByArgument {
public init?(argument: String) {
self.init(fileURLWithPath: argument)
}
}
Loading
Loading