Skip to content

Commit 6367155

Browse files
authored
Add multi-reference push to ImageStore (apple#661)
1 parent 997d5a4 commit 6367155

2 files changed

Lines changed: 103 additions & 5 deletions

File tree

Sources/Containerization/Image/ImageStore/ImageStore.swift

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -279,18 +279,100 @@ extension ImageStore {
279279
///
280280
public func push(reference: String, platform: Platform? = nil, insecure: Bool = false, auth: Authentication? = nil, progress: ProgressHandler? = nil) async throws {
281281
let matcher = createPlatformMatcher(for: platform)
282-
let img = try await self.get(reference: reference)
282+
let client = try RegistryClient(reference: reference, insecure: insecure, auth: auth, tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration())
283+
try await self.pushSingle(reference: reference, client: client, matcher: matcher, progress: progress)
284+
}
285+
286+
/// Push multiple image references to a remote registry, sharing a single ``RegistryClient``.
287+
///
288+
/// All references must resolve to the same registry host. Passing references that target
289+
/// different hosts throws a ``ContainerizationError`` with code ``invalidArgument``.
290+
///
291+
/// - Parameters:
292+
/// - references: An array of fully qualified image reference strings to push.
293+
/// Each must include a host (e.g., `"ghcr.io/myrepo/myimage:v1"`).
294+
/// - platform: An optional parameter to indicate the platform to be pushed for each image.
295+
/// Defaults to `nil` signifying that layers for all supported platforms will be pushed.
296+
/// - insecure: A boolean indicating if the connection to the remote registry should be made via plain-text http or not.
297+
/// Defaults to false, meaning the connection to the registry will be over https.
298+
/// - auth: An object that implements the `Authentication` protocol,
299+
/// used to add any credentials to the HTTP requests that are made to the registry.
300+
/// Defaults to `nil` meaning no additional credentials are added to any HTTP requests made to the registry.
301+
/// - maxConcurrentUploads: Maximum number of concurrent tag pushes. Defaults to 3.
302+
/// - progress: An optional handler over which progress update events about the push operations can be received.
303+
///
304+
public func push(
305+
references: [String], platform: Platform? = nil, insecure: Bool = false,
306+
auth: Authentication? = nil, maxConcurrentUploads: Int = 3, progress: ProgressHandler? = nil
307+
) async throws {
308+
guard let firstReference = references.first else {
309+
return
310+
}
311+
312+
// Parse all references upfront: validate hosts and avoid re-parsing inside tasks.
313+
let parsed = try references.map { ref in try Reference.parse(ref) }
314+
let hosts = parsed.compactMap { $0.resolvedDomain }
315+
guard hosts.count == references.count else {
316+
throw ContainerizationError(.invalidArgument, message: "all references must include a host")
317+
}
318+
let uniqueHosts = Set(hosts)
319+
guard uniqueHosts.count == 1 else {
320+
throw ContainerizationError(
321+
.invalidArgument,
322+
message: "all references must target the same registry host, got: \(uniqueHosts.sorted().joined(separator: ", "))")
323+
}
324+
325+
let matcher = createPlatformMatcher(for: platform)
326+
let client = try RegistryClient(
327+
reference: firstReference, insecure: insecure, auth: auth,
328+
tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration())
329+
330+
let pushOne: @Sendable (String) async -> (String, String?) = { reference in
331+
do {
332+
try await self.pushSingle(reference: reference, client: client, matcher: matcher, progress: progress)
333+
return (reference, nil)
334+
} catch {
335+
return (reference, String(describing: error))
336+
}
337+
}
338+
339+
var iterator = references.makeIterator()
340+
var failures: [(reference: String, message: String)] = []
341+
342+
await withTaskGroup(of: (String, String?).self) { group in
343+
for _ in 0..<maxConcurrentUploads {
344+
guard let reference = iterator.next() else { break }
345+
group.addTask { await pushOne(reference) }
346+
}
347+
for await (ref, error) in group {
348+
if let error {
349+
failures.append((ref, error))
350+
}
351+
if let reference = iterator.next() {
352+
group.addTask { await pushOne(reference) }
353+
}
354+
}
355+
}
356+
357+
if !failures.isEmpty {
358+
let details = failures.map { "\($0.reference): \($0.message)" }.joined(separator: "\n")
359+
throw ContainerizationError(.internalError, message: "failed to push one or more images:\n\(details)")
360+
}
361+
}
362+
363+
private func pushSingle(
364+
reference: String, client: ContentClient, matcher: @Sendable (Platform) -> Bool, progress: ProgressHandler?
365+
) async throws {
283366
let allowedMediaTypes = [MediaTypes.dockerManifestList, MediaTypes.index]
367+
let img = try await self.get(reference: reference)
284368
guard allowedMediaTypes.contains(img.mediaType) else {
285-
throw ContainerizationError(.internalError, message: "cannot push image \(reference) with Index media type \(img.mediaType)")
369+
throw ContainerizationError(.internalError, message: "cannot push image \(reference): unsupported media type \(img.mediaType), expected an index or manifest list")
286370
}
287371
let ref = try Reference.parse(reference)
288-
let name = ref.path
289372
guard let tag = ref.tag ?? ref.digest else {
290373
throw ContainerizationError(.invalidArgument, message: "invalid tag/digest for image reference \(reference)")
291374
}
292-
let client = try RegistryClient(reference: reference, insecure: insecure, auth: auth, tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration())
293-
let operation = ExportOperation(name: name, tag: tag, contentStore: self.contentStore, client: client, progress: progress)
375+
let operation = ExportOperation(name: ref.path, tag: tag, contentStore: self.contentStore, client: client, progress: progress)
294376
try await operation.export(index: img.descriptor, platforms: matcher)
295377
}
296378
}

Tests/ContainerizationTests/ImageTests/ImageStoreTests.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,22 @@ public class ImageStoreTests: ContainsAuth {
8989
try await self.store.push(reference: upstreamTag, auth: authentication)
9090
}
9191

92+
@Test(.disabled("External users cannot push images, disable while we find a better solution"))
93+
func testImageStorePushMultipleReferences() async throws {
94+
guard let authentication = Self.authentication else {
95+
return
96+
}
97+
let imageReference = "ghcr.io/apple/containerization/dockermanifestimage:0.0.2"
98+
99+
let remoteImageName = "ghcr.io/apple/test-images/image-push"
100+
let epoch = Int(Date().timeIntervalSince1970)
101+
let tags = ["\(remoteImageName):\(epoch)-a", "\(remoteImageName):\(epoch)-b", "\(remoteImageName):\(epoch)-c"]
102+
for tag in tags {
103+
let _ = try await self.store.tag(existing: imageReference, new: tag)
104+
}
105+
try await self.store.push(references: tags, auth: authentication, maxConcurrentUploads: 2)
106+
}
107+
92108
@Test func testLoadImageWithoutAnnotations() async throws {
93109
let fileManager = FileManager.default
94110
let tempDir = fileManager.uniqueTemporaryDirectory()

0 commit comments

Comments
 (0)