From 5520fd36cc4c7e63f18f4525beb9ba37fdece9d6 Mon Sep 17 00:00:00 2001 From: Raj Date: Tue, 7 Apr 2026 10:29:23 -0700 Subject: [PATCH 1/3] Add multi-reference push to ImageStore --- .../Image/ImageStore/ImageStore.swift | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/Sources/Containerization/Image/ImageStore/ImageStore.swift b/Sources/Containerization/Image/ImageStore/ImageStore.swift index 3106c690..ff692d5b 100644 --- a/Sources/Containerization/Image/ImageStore/ImageStore.swift +++ b/Sources/Containerization/Image/ImageStore/ImageStore.swift @@ -293,6 +293,79 @@ extension ImageStore { let operation = ExportOperation(name: name, tag: tag, contentStore: self.contentStore, client: client, progress: progress) try await operation.export(index: img.descriptor, platforms: matcher) } + + /// Push multiple image references to a remote registry, sharing a single registry client connection. + /// + /// All references must target the same registry host since they share a single ``RegistryClient``. + /// + /// - Parameters: + /// - references: An array of image reference strings to push. + /// - platform: An optional parameter to indicate the platform to be pushed for each image. + /// Defaults to `nil` signifying that layers for all supported platforms will be pushed. + /// - insecure: A boolean indicating if the connection to the remote registry should be made via plain-text http or not. + /// Defaults to false, meaning the connection to the registry will be over https. + /// - auth: An object that implements the `Authentication` protocol, + /// used to add any credentials to the HTTP requests that are made to the registry. + /// Defaults to `nil` meaning no additional credentials are added to any HTTP requests made to the registry. + /// - maxConcurrentUploads: Maximum number of concurrent tag pushes. Defaults to 3. + /// - progress: An optional handler over which progress update events about the push operations can be received. + /// + public func push( + references: [String], platform: Platform? = nil, insecure: Bool = false, + auth: Authentication? = nil, maxConcurrentUploads: Int = 3, progress: ProgressHandler? = nil + ) async throws { + guard !references.isEmpty else { + return + } + + let matcher = createPlatformMatcher(for: platform) + let allowedMediaTypes = [MediaTypes.dockerManifestList, MediaTypes.index] + let client = try RegistryClient( + reference: references[0], insecure: insecure, auth: auth, + tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration()) + + let pushOne: @Sendable (String) async -> (String, String?) = { reference in + do { + let img = try await self.get(reference: reference) + guard allowedMediaTypes.contains(img.mediaType) else { + throw ContainerizationError(.internalError, message: "cannot push image \(reference) with Index media type \(img.mediaType)") + } + let ref = try Reference.parse(reference) + let name = ref.path + guard let tag = ref.tag ?? ref.digest else { + throw ContainerizationError(.invalidArgument, message: "invalid tag/digest for image reference \(reference)") + } + let operation = ExportOperation(name: name, tag: tag, contentStore: self.contentStore, client: client, progress: progress) + try await operation.export(index: img.descriptor, platforms: matcher) + return (reference, nil) + } catch { + return (reference, String(describing: error)) + } + } + + var iterator = references.makeIterator() + var failures: [(reference: String, message: String)] = [] + + await withTaskGroup(of: (String, String?).self) { group in + for _ in 0.. Date: Tue, 7 Apr 2026 14:07:44 -0700 Subject: [PATCH 2/3] Extract pushSingle helper, validate same-host, fix error message --- .../Image/ImageStore/ImageStore.swift | 63 ++++++++++--------- .../ImageTests/ImageStoreTests.swift | 16 +++++ 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/Sources/Containerization/Image/ImageStore/ImageStore.swift b/Sources/Containerization/Image/ImageStore/ImageStore.swift index ff692d5b..6adeffa6 100644 --- a/Sources/Containerization/Image/ImageStore/ImageStore.swift +++ b/Sources/Containerization/Image/ImageStore/ImageStore.swift @@ -279,24 +279,14 @@ extension ImageStore { /// public func push(reference: String, platform: Platform? = nil, insecure: Bool = false, auth: Authentication? = nil, progress: ProgressHandler? = nil) async throws { let matcher = createPlatformMatcher(for: platform) - let img = try await self.get(reference: reference) - let allowedMediaTypes = [MediaTypes.dockerManifestList, MediaTypes.index] - guard allowedMediaTypes.contains(img.mediaType) else { - throw ContainerizationError(.internalError, message: "cannot push image \(reference) with Index media type \(img.mediaType)") - } - let ref = try Reference.parse(reference) - let name = ref.path - guard let tag = ref.tag ?? ref.digest else { - throw ContainerizationError(.invalidArgument, message: "invalid tag/digest for image reference \(reference)") - } let client = try RegistryClient(reference: reference, insecure: insecure, auth: auth, tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration()) - let operation = ExportOperation(name: name, tag: tag, contentStore: self.contentStore, client: client, progress: progress) - try await operation.export(index: img.descriptor, platforms: matcher) + try await self.pushSingle(reference: reference, client: client, matcher: matcher, progress: progress) } - /// Push multiple image references to a remote registry, sharing a single registry client connection. + /// Push multiple image references to a remote registry, sharing a single ``RegistryClient``. /// - /// All references must target the same registry host since they share a single ``RegistryClient``. + /// All references must resolve to the same registry host. Passing references that target + /// different hosts throws a ``ContainerizationError`` with code ``invalidArgument``. /// /// - Parameters: /// - references: An array of image reference strings to push. @@ -314,29 +304,30 @@ extension ImageStore { references: [String], platform: Platform? = nil, insecure: Bool = false, auth: Authentication? = nil, maxConcurrentUploads: Int = 3, progress: ProgressHandler? = nil ) async throws { - guard !references.isEmpty else { + guard let firstReference = references.first else { return } + // Parse all references upfront: validate hosts and avoid re-parsing inside tasks. + let parsed = try references.map { ref in try Reference.parse(ref) } + let hosts = Set(parsed.compactMap { $0.resolvedDomain }) + guard hosts.count == 1 else { + let hostList = hosts.sorted().joined(separator: ", ") + throw ContainerizationError( + .invalidArgument, + message: hosts.isEmpty + ? "could not extract host from references" + : "all references must target the same registry host, got: \(hostList)") + } + let matcher = createPlatformMatcher(for: platform) - let allowedMediaTypes = [MediaTypes.dockerManifestList, MediaTypes.index] let client = try RegistryClient( - reference: references[0], insecure: insecure, auth: auth, + reference: firstReference, insecure: insecure, auth: auth, tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration()) let pushOne: @Sendable (String) async -> (String, String?) = { reference in do { - let img = try await self.get(reference: reference) - guard allowedMediaTypes.contains(img.mediaType) else { - throw ContainerizationError(.internalError, message: "cannot push image \(reference) with Index media type \(img.mediaType)") - } - let ref = try Reference.parse(reference) - let name = ref.path - guard let tag = ref.tag ?? ref.digest else { - throw ContainerizationError(.invalidArgument, message: "invalid tag/digest for image reference \(reference)") - } - let operation = ExportOperation(name: name, tag: tag, contentStore: self.contentStore, client: client, progress: progress) - try await operation.export(index: img.descriptor, platforms: matcher) + try await self.pushSingle(reference: reference, client: client, matcher: matcher, progress: progress) return (reference, nil) } catch { return (reference, String(describing: error)) @@ -366,6 +357,22 @@ extension ImageStore { throw ContainerizationError(.internalError, message: "failed to push one or more images:\n\(details)") } } + + private func pushSingle( + reference: String, client: ContentClient, matcher: @Sendable (Platform) -> Bool, progress: ProgressHandler? + ) async throws { + let allowedMediaTypes = [MediaTypes.dockerManifestList, MediaTypes.index] + let img = try await self.get(reference: reference) + guard allowedMediaTypes.contains(img.mediaType) else { + throw ContainerizationError(.internalError, message: "cannot push image \(reference): unsupported media type \(img.mediaType), expected an index or manifest list") + } + let ref = try Reference.parse(reference) + guard let tag = ref.tag ?? ref.digest else { + throw ContainerizationError(.invalidArgument, message: "invalid tag/digest for image reference \(reference)") + } + let operation = ExportOperation(name: ref.path, tag: tag, contentStore: self.contentStore, client: client, progress: progress) + try await operation.export(index: img.descriptor, platforms: matcher) + } } extension ImageStore { diff --git a/Tests/ContainerizationTests/ImageTests/ImageStoreTests.swift b/Tests/ContainerizationTests/ImageTests/ImageStoreTests.swift index a7debd0c..37eda3e4 100644 --- a/Tests/ContainerizationTests/ImageTests/ImageStoreTests.swift +++ b/Tests/ContainerizationTests/ImageTests/ImageStoreTests.swift @@ -89,6 +89,22 @@ public class ImageStoreTests: ContainsAuth { try await self.store.push(reference: upstreamTag, auth: authentication) } + @Test(.disabled("External users cannot push images, disable while we find a better solution")) + func testImageStorePushMultipleReferences() async throws { + guard let authentication = Self.authentication else { + return + } + let imageReference = "ghcr.io/apple/containerization/dockermanifestimage:0.0.2" + + let remoteImageName = "ghcr.io/apple/test-images/image-push" + let epoch = Int(Date().timeIntervalSince1970) + let tags = ["\(remoteImageName):\(epoch)-a", "\(remoteImageName):\(epoch)-b", "\(remoteImageName):\(epoch)-c"] + for tag in tags { + let _ = try await self.store.tag(existing: imageReference, new: tag) + } + try await self.store.push(references: tags, auth: authentication, maxConcurrentUploads: 2) + } + @Test func testLoadImageWithoutAnnotations() async throws { let fileManager = FileManager.default let tempDir = fileManager.uniqueTemporaryDirectory() From b03f1209d15e0dabe90377ad8561b16d7b5773ab Mon Sep 17 00:00:00 2001 From: Raj Date: Tue, 7 Apr 2026 16:03:54 -0700 Subject: [PATCH 3/3] Validate all references include a host before pushing --- .../Image/ImageStore/ImageStore.swift | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Sources/Containerization/Image/ImageStore/ImageStore.swift b/Sources/Containerization/Image/ImageStore/ImageStore.swift index 6adeffa6..dc83616e 100644 --- a/Sources/Containerization/Image/ImageStore/ImageStore.swift +++ b/Sources/Containerization/Image/ImageStore/ImageStore.swift @@ -289,7 +289,8 @@ extension ImageStore { /// different hosts throws a ``ContainerizationError`` with code ``invalidArgument``. /// /// - Parameters: - /// - references: An array of image reference strings to push. + /// - references: An array of fully qualified image reference strings to push. + /// Each must include a host (e.g., `"ghcr.io/myrepo/myimage:v1"`). /// - platform: An optional parameter to indicate the platform to be pushed for each image. /// Defaults to `nil` signifying that layers for all supported platforms will be pushed. /// - insecure: A boolean indicating if the connection to the remote registry should be made via plain-text http or not. @@ -310,14 +311,15 @@ extension ImageStore { // Parse all references upfront: validate hosts and avoid re-parsing inside tasks. let parsed = try references.map { ref in try Reference.parse(ref) } - let hosts = Set(parsed.compactMap { $0.resolvedDomain }) - guard hosts.count == 1 else { - let hostList = hosts.sorted().joined(separator: ", ") + let hosts = parsed.compactMap { $0.resolvedDomain } + guard hosts.count == references.count else { + throw ContainerizationError(.invalidArgument, message: "all references must include a host") + } + let uniqueHosts = Set(hosts) + guard uniqueHosts.count == 1 else { throw ContainerizationError( .invalidArgument, - message: hosts.isEmpty - ? "could not extract host from references" - : "all references must target the same registry host, got: \(hostList)") + message: "all references must target the same registry host, got: \(uniqueHosts.sorted().joined(separator: ", "))") } let matcher = createPlatformMatcher(for: platform)