diff --git a/Sources/Services/ContainerAPIService/Client/ClientImage.swift b/Sources/Services/ContainerAPIService/Client/ClientImage.swift index 8b3a4d966..56e0c2683 100644 --- a/Sources/Services/ContainerAPIService/Client/ClientImage.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientImage.swift @@ -54,7 +54,11 @@ public struct ClientImage: Sendable { desc.platform == platform } guard let desc else { - throw ContainerizationError(.unsupported, message: "platform \(platform.description)") + throw ContainerizationError( + .unsupported, + message: Self.unsupportedPlatformMessage( + reference: reference, requested: platform, available: Self.availablePlatforms(in: index)) + ) } guard let content: Content = try await contentStore.get(digest: desc.digest) else { throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)") @@ -62,6 +66,27 @@ public struct ClientImage: Sendable { return try content.decode() } + package static func availablePlatforms(in index: Index) -> [Platform] { + var seen: Set = [] + return index.manifests.compactMap { desc in + guard let platform = desc.platform else { + return nil + } + guard desc.annotations?[Self.referenceTypeAnnotation] != Self.attestationManifestReferenceType else { + return nil + } + guard seen.insert(platform).inserted else { + return nil + } + return platform + } + } + + package static func unsupportedPlatformMessage(reference: String, requested: Platform, available: [Platform]) -> String { + let list = available.isEmpty ? "none" : available.map(\.description).joined(separator: ", ") + return "image \(reference) has no \(requested.description) variant (available: \(list))" + } + /// Returns the OCI config for the specified platform. public func config(for platform: Platform) async throws -> ContainerizationOCI.Image { let manifest = try await self.manifest(for: platform) @@ -109,6 +134,8 @@ extension ClientImage { // MARK: Static methods extension ClientImage { + private static let referenceTypeAnnotation = "vnd.docker.reference.type" + private static let attestationManifestReferenceType = "attestation-manifest" private static let legacyDockerRegistryHost = "docker.io" private static let dockerRegistryHost = "registry-1.docker.io" private static let defaultDockerRegistryRepo = "library" @@ -196,8 +223,8 @@ extension ClientImage { /// - Throws: An error if the image cannot be retrieved. public static func getFullImageSize(image: ClientImage) async throws -> Int64 { for descriptor in try await image.index().manifests { - if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], - referenceType == "attestation-manifest" + if let referenceType = descriptor.annotations?[Self.referenceTypeAnnotation], + referenceType == Self.attestationManifestReferenceType { continue } diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index f6329c35a..9a3a9e4bf 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -64,6 +64,16 @@ public struct Utility { } } + package static func platformHint(requested: Platform, available: [Platform]) -> String? { + if requested.architecture != "amd64", let amd64 = available.first(where: { $0.os == requested.os && $0.architecture == "amd64" }) { + return "Use --arch amd64 or --platform \(amd64.description) to run it with Rosetta." + } + guard let first = available.first else { + return nil + } + return "Use --platform \(first.description) to select one of the available variants." + } + public static func containerConfigFromFlags( id: String, image: String, @@ -91,14 +101,24 @@ public struct Utility { ]) let taskManager = ProgressTaskCoordinator() let fetchTask = await taskManager.startTask() - let img = try await ClientImage.fetch( - reference: image, - platform: requestedPlatform, - scheme: scheme, - containerSystemConfig: containerSystemConfig, - progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate), - maxConcurrentDownloads: imageFetch.maxConcurrentDownloads - ) + let img: ClientImage + do { + img = try await ClientImage.fetch( + reference: image, + platform: requestedPlatform, + scheme: scheme, + containerSystemConfig: containerSystemConfig, + progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate), + maxConcurrentDownloads: imageFetch.maxConcurrentDownloads + ) + } catch let error as ContainerizationError where error.isCode(.unsupported) { + let index = try? await ClientImage.get(reference: image, containerSystemConfig: containerSystemConfig).index() + let available = index.map { ClientImage.availablePlatforms(in: $0) } ?? [] + guard let hint = Self.platformHint(requested: requestedPlatform, available: available) else { + throw error + } + throw ContainerizationError(.unsupported, message: "\(error.message). \(hint)", cause: error.cause) + } // Unpack a fetched image before use await progressUpdate([ diff --git a/Tests/ContainerAPIClientTests/ClientImagePlatformTests.swift b/Tests/ContainerAPIClientTests/ClientImagePlatformTests.swift new file mode 100644 index 000000000..257a2e8d7 --- /dev/null +++ b/Tests/ContainerAPIClientTests/ClientImagePlatformTests.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationOCI +import Testing + +@testable import ContainerAPIClient + +struct ClientImagePlatformTests { + private let amd64 = Platform(arch: "amd64", os: "linux") + private let arm64 = Platform(arch: "arm64", os: "linux") + + private func descriptor(_ platform: Platform?, digest: String, annotations: [String: String]? = nil) -> Descriptor { + Descriptor(mediaType: "application/vnd.oci.image.manifest.v1+json", digest: digest, size: 1, annotations: annotations, platform: platform) + } + + @Test + func availablePlatformsSkipsAttestationsMissingPlatformsAndDuplicates() { + let index = Index(manifests: [ + descriptor(amd64, digest: "sha256:a"), + descriptor(Platform(arch: "unknown", os: "unknown"), digest: "sha256:b", annotations: ["vnd.docker.reference.type": "attestation-manifest"]), + descriptor(nil, digest: "sha256:c"), + descriptor(amd64, digest: "sha256:d"), + ]) + #expect(ClientImage.availablePlatforms(in: index) == [amd64]) + } + + @Test + func unsupportedPlatformMessageListsAvailablePlatforms() { + let message = ClientImage.unsupportedPlatformMessage(reference: "docker.io/mailhog/mailhog:v1.0.1", requested: arm64, available: [amd64]) + #expect(message == "image docker.io/mailhog/mailhog:v1.0.1 has no linux/arm64 variant (available: linux/amd64)") + } + + @Test + func unsupportedPlatformMessageWithoutPlatforms() { + let message = ClientImage.unsupportedPlatformMessage(reference: "example/empty:latest", requested: arm64, available: []) + #expect(message == "image example/empty:latest has no linux/arm64 variant (available: none)") + } + + @Test + func platformHintSuggestsRosettaWhenAmd64IsAvailable() { + #expect(Utility.platformHint(requested: arm64, available: [amd64]) == "Use --arch amd64 or --platform linux/amd64 to run it with Rosetta.") + } + + @Test + func platformHintSuggestsFirstAvailableVariantOtherwise() { + #expect(Utility.platformHint(requested: amd64, available: [arm64]) == "Use --platform linux/arm64 to select one of the available variants.") + #expect(Utility.platformHint(requested: arm64, available: []) == nil) + } +}