Skip to content

Commit 0a99dc4

Browse files
committed
ContainerRegistry: Add TarImageDestination
1 parent b2726ee commit 0a99dc4

File tree

3 files changed

+165
-8
lines changed

3 files changed

+165
-8
lines changed
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftContainerPlugin open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import struct Foundation.Data
16+
import class Foundation.OutputStream
17+
import class Foundation.JSONDecoder
18+
import class Foundation.JSONEncoder
19+
import Tar
20+
21+
/// A tar file on disk, to which a container image can be saved.
22+
public class TarImageDestination {
23+
public var decoder: JSONDecoder
24+
var encoder: JSONEncoder
25+
26+
var archive: Archive
27+
28+
/// Creates a new TarImageDestination
29+
/// - Parameters stream: OutputStream to which the archive should be written
30+
/// - Throws: If an error occurs when serializing archive.
31+
public init(toStream stream: OutputStream) throws {
32+
self.archive = Archive(toStream: stream)
33+
self.decoder = JSONDecoder()
34+
self.encoder = containerJSONEncoder()
35+
36+
try archive.appendFile(name: "oci-layout", data: [UInt8](encoder.encode(ImageLayoutHeader())))
37+
try archive.appendDirectory(name: "blobs")
38+
try archive.appendDirectory(name: "blobs/sha256")
39+
}
40+
}
41+
42+
extension TarImageDestination: ImageDestination {
43+
/// Saves a blob of unstructured data to the destination.
44+
/// - Parameters:
45+
/// - repository: Name of the destination repository.
46+
/// - mediaType: mediaType field for returned ContentDescriptor.
47+
/// - data: Object to be saved.
48+
/// - Returns: An ContentDescriptor object representing the
49+
/// saved blob.
50+
/// - Throws: If the blob cannot be encoded or the save fails.
51+
public func putBlob(
52+
repository: ImageReference.Repository,
53+
mediaType: String,
54+
data: Data
55+
) async throws -> ContentDescriptor {
56+
let digest = ImageReference.Digest(of: Data(data))
57+
try archive.appendFile(name: "\(digest.value)", prefix: "blobs/\(digest.algorithm)", data: [UInt8](data))
58+
return .init(mediaType: mediaType, digest: "\(digest)", size: Int64(data.count))
59+
}
60+
61+
/// Saves a JSON object to the destination, serialized as an unstructured blob.
62+
/// - Parameters:
63+
/// - repository: Name of the destination repository.
64+
/// - mediaType: mediaType field for returned ContentDescriptor.
65+
/// - data: Object to be saved.
66+
/// - Returns: An ContentDescriptor object representing the
67+
/// saved blob.
68+
/// - Throws: If the blob cannot be encoded or the save fails.
69+
public func putBlob<Body: Encodable>(
70+
repository: ImageReference.Repository,
71+
mediaType: String,
72+
data: Body
73+
) async throws -> ContentDescriptor {
74+
let encoded = try encoder.encode(data)
75+
return try await putBlob(repository: repository, mediaType: mediaType, data: encoded)
76+
}
77+
78+
/// Checks whether a blob exists.
79+
///
80+
/// - Parameters:
81+
/// - repository: Name of the destination repository.
82+
/// - digest: Digest of the requested blob.
83+
/// - Returns: Always returns False.
84+
/// - Throws: If the destination encounters an error.
85+
public func blobExists(
86+
repository: ImageReference.Repository,
87+
digest: ImageReference.Digest
88+
) async throws -> Bool {
89+
false
90+
}
91+
92+
/// Encodes and saves an image manifest.
93+
///
94+
/// - Parameters:
95+
/// - repository: Name of the destination repository.
96+
/// - reference: Optional tag to apply to this manifest.
97+
/// - manifest: Manifest to be saved.
98+
/// - Returns: An ContentDescriptor object representing the
99+
/// saved blob.
100+
/// - Throws: If the blob cannot be encoded or saved.
101+
public func putManifest(
102+
repository: ImageReference.Repository,
103+
reference: (any ImageReference.Reference)?,
104+
manifest: ImageManifest
105+
) async throws -> ContentDescriptor {
106+
// Manifests are not special in the on-disk representation - they are just stored as blobs
107+
try await self.putBlob(
108+
repository: repository,
109+
mediaType: "application/vnd.oci.image.manifest.v1+json",
110+
data: manifest
111+
)
112+
}
113+
114+
/// Encodes and saves an image index.
115+
///
116+
/// - Parameters:
117+
/// - repository: Name of the destination repository.
118+
/// - reference: Optional tag to apply to this index.
119+
/// - index: Index to be saved.
120+
/// - Returns: An ContentDescriptor object representing the
121+
/// saved index.
122+
/// - Throws: If the index cannot be encoded or saving fails.
123+
public func putIndex(
124+
repository: ImageReference.Repository,
125+
reference: (any ImageReference.Reference)?,
126+
index: ImageIndex
127+
) async throws -> ContentDescriptor {
128+
// Unlike Manifest, Index is not written as a blob
129+
let encoded = try encoder.encode(index)
130+
let digest = ImageReference.Digest(of: encoded)
131+
let mediaType = index.mediaType ?? "application/vnd.oci.image.index.v1+json"
132+
133+
try archive.appendFile(name: "index.json", data: [UInt8](encoded))
134+
135+
try archive.appendFile(name: "\(digest.value)", prefix: "blobs/\(digest.algorithm)", data: [UInt8](encoded))
136+
return .init(mediaType: mediaType, digest: "\(digest)", size: Int64(encoded.count))
137+
}
138+
}
139+
140+
struct ImageLayoutHeader: Codable {
141+
var imageLayoutVersion: String = "1.0.0"
142+
}

Sources/Tar/tar.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,8 +349,8 @@ public struct Archive: ~Copyable {
349349
var output: OutputStream
350350

351351
/// Creates an empty Archive
352-
public init() {
353-
output = OutputStream.toMemory()
352+
public init(toStream: OutputStream = .toMemory()) {
353+
output = toStream
354354
output.open()
355355
output.schedule(in: .current, forMode: .default) // is this needed?
356356
}

Sources/containertool/containertool.swift

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
4343

4444
@Option(help: "The base container image name and optional tag")
4545
var from: String?
46+
47+
@Option(name: [.long, .short], help: "File in which the container image should be saved")
48+
var output: URL
4649
}
4750

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

206-
let destination = try await RegistryClient(
207-
registry: destinationImage.registry,
208-
insecure: authenticationOptions.allowInsecureHttp == .destination
209-
|| authenticationOptions.allowInsecureHttp == .both,
210-
auth: .init(username: username, password: password, auth: authProvider)
211-
)
209+
// let destination = try await RegistryClient(
210+
// registry: destinationImage.registry,
211+
// insecure: authenticationOptions.allowInsecureHttp == .destination
212+
// || authenticationOptions.allowInsecureHttp == .both,
213+
// auth: .init(username: username, password: password, auth: authProvider)
214+
// )
215+
216+
guard let saveStream = OutputStream(url: repositoryOptions.output, append: false) else {
217+
fatalError("failed to create tarball")
218+
}
219+
let destination = try TarImageDestination(toStream: saveStream)
212220

213221
if verbose { log("Connected to destination registry: \(destinationImage.registry)") }
214222
if verbose { log("Using base image: \(baseImage)") }
@@ -231,3 +239,10 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
231239
print(finalImage)
232240
}
233241
}
242+
243+
// Parse URL path arguments
244+
extension Foundation.URL: ArgumentParser.ExpressibleByArgument {
245+
public init?(argument: String) {
246+
self.init(fileURLWithPath: argument)
247+
}
248+
}

0 commit comments

Comments
 (0)