diff --git a/Sources/ContainerCommands/Image/ImagePull.swift b/Sources/ContainerCommands/Image/ImagePull.swift index b0299c35fc..51fcaf3da5 100644 --- a/Sources/ContainerCommands/Image/ImagePull.swift +++ b/Sources/ContainerCommands/Image/ImagePull.swift @@ -19,7 +19,10 @@ import ContainerAPIClient import ContainerPersistence import ContainerPlugin import Containerization +import ContainerizationError import ContainerizationOCI +import Foundation +import Logging import TerminalProgress extension Application { @@ -50,7 +53,7 @@ extension Application { var os: String? @Option( - help: "Limit the pull to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]" + help: "Limit the pull to a platform (os/arch[/variant]; 'all' unpacks every platform; takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]" ) var platform: String? @@ -70,7 +73,7 @@ extension Application { public func run() async throws { let containerSystemConfig: ContainerSystemConfig = try await ClientHealthCheck.loadContainerSystemConfig() - let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log) + let selection = try Self.resolvePlatforms(platform: platform, os: os, arch: arch, log: log) let scheme = try RequestScheme(registry.scheme) @@ -94,7 +97,7 @@ extension Application { let taskManager = ProgressTaskCoordinator() let fetchTask = await taskManager.startTask() let image = try await ClientImage.pull( - reference: processedReference, platform: p, scheme: scheme, containerSystemConfig: containerSystemConfig, + reference: processedReference, platform: selection.fetch, scheme: scheme, containerSystemConfig: containerSystemConfig, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler), maxConcurrentDownloads: self.imageFetchFlags.maxConcurrentDownloads ) @@ -102,9 +105,41 @@ extension Application { progress.set(description: "Unpacking image") progress.set(itemsName: "entries") let unpackTask = await taskManager.startTask() - try await image.unpack(platform: p, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) + try await image.unpack(platform: selection.unpack, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) await taskManager.finish() progress.finish() } + + struct PlatformSelection { + let fetch: ContainerizationOCI.Platform? + let unpack: ContainerizationOCI.Platform? + } + + /// Resolves the platforms used for fetching and unpacking. + /// + /// `--platform all` fetches every platform and unpacks every platform, + /// restoring the previous behavior. Otherwise the fetch uses the + /// requested platform (or all platforms when none is requested, so + /// blobs remain available offline), and unpacking defaults to the host + /// platform to avoid one snapshot per architecture. + static func resolvePlatforms( + platform: String?, + os: String?, + arch: String?, + environment: [String: String] = ProcessInfo.processInfo.environment, + log: Logger? = nil + ) throws -> PlatformSelection { + if platform?.lowercased() == "all" { + guard os == nil, arch == nil else { + throw ContainerizationError( + .invalidArgument, + message: "--platform all cannot be combined with --os or --arch" + ) + } + return PlatformSelection(fetch: nil, unpack: nil) + } + let resolved = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, environment: environment, log: log) + return PlatformSelection(fetch: resolved, unpack: resolved ?? .current) + } } } diff --git a/Tests/ContainerCommandsTests/ImagePullPlatformTests.swift b/Tests/ContainerCommandsTests/ImagePullPlatformTests.swift new file mode 100644 index 0000000000..f88cda0e66 --- /dev/null +++ b/Tests/ContainerCommandsTests/ImagePullPlatformTests.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOCI +import Testing + +@testable import ContainerCommands + +struct ImagePullPlatformTests { + @Test func noFlagsFetchesAllAndUnpacksHost() throws { + let selection = try Application.ImagePull.resolvePlatforms( + platform: nil, + os: nil, + arch: nil, + environment: [:] + ) + #expect(selection.fetch == nil) + #expect(selection.unpack?.os == Platform.current.os) + #expect(selection.unpack?.architecture == Platform.current.architecture) + } + + @Test func allFetchesAndUnpacksEveryPlatform() throws { + let selection = try Application.ImagePull.resolvePlatforms( + platform: "all", + os: nil, + arch: nil, + environment: [:] + ) + #expect(selection.fetch == nil) + #expect(selection.unpack == nil) + } + + @Test func allIsCaseInsensitive() throws { + let selection = try Application.ImagePull.resolvePlatforms( + platform: "ALL", + os: nil, + arch: nil, + environment: [:] + ) + #expect(selection.fetch == nil) + #expect(selection.unpack == nil) + } + + @Test func allRejectsOSOrArchOverrides() { + #expect { + _ = try Application.ImagePull.resolvePlatforms( + platform: "all", + os: "linux", + arch: nil, + environment: [:] + ) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("--platform all") + } + #expect { + _ = try Application.ImagePull.resolvePlatforms( + platform: "all", + os: nil, + arch: "arm64", + environment: [:] + ) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("--platform all") + } + } + + @Test func explicitPlatformIsUsedForFetchAndUnpack() throws { + let selection = try Application.ImagePull.resolvePlatforms( + platform: "linux/amd64", + os: nil, + arch: nil, + environment: [:] + ) + #expect(selection.fetch?.os == "linux") + #expect(selection.fetch?.architecture == "amd64") + #expect(selection.unpack?.os == "linux") + #expect(selection.unpack?.architecture == "amd64") + } + + @Test func environmentPlatformIsUsedForFetchAndUnpack() throws { + let selection = try Application.ImagePull.resolvePlatforms( + platform: nil, + os: nil, + arch: nil, + environment: ["CONTAINER_DEFAULT_PLATFORM": "linux/amd64"] + ) + #expect(selection.fetch?.architecture == "amd64") + #expect(selection.unpack?.architecture == "amd64") + } + + @Test func osAndArchFlagsAreUsedForFetchAndUnpack() throws { + let selection = try Application.ImagePull.resolvePlatforms( + platform: nil, + os: "linux", + arch: "arm64", + environment: [:] + ) + #expect(selection.fetch?.os == "linux") + #expect(selection.fetch?.architecture == "arm64") + #expect(selection.unpack?.os == "linux") + #expect(selection.unpack?.architecture == "arm64") + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 58edd7147b..e21e81fcd5 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -615,7 +615,7 @@ container image pull [--scheme ] [--progress ] [--max-concurrent-d * `--max-concurrent-downloads `: Maximum number of concurrent downloads (default: 3) * `-a, --arch `: Limit the pull to the specified architecture * `--os `: Limit the pull to the specified OS -* `--platform `: Limit the pull to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch) +* `--platform `: Limit the pull to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch). Use `--platform all` to unpack every platform in the image. By default only the host platform is unpacked, to avoid storing a snapshot for each architecture; other platforms are unpacked on demand when run. ### `container image push`