Skip to content
Open
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
33 changes: 30 additions & 3 deletions Sources/Services/ContainerAPIService/Client/ClientImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,39 @@ 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)")
}
return try content.decode()
}

package static func availablePlatforms(in index: Index) -> [Platform] {
var seen: Set<Platform> = []
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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
36 changes: 28 additions & 8 deletions Sources/Services/ContainerAPIService/Client/Utility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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([
Expand Down
63 changes: 63 additions & 0 deletions Tests/ContainerAPIClientTests/ClientImagePlatformTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}