From 6b626848118f4f6081baec09dadff73023f49959 Mon Sep 17 00:00:00 2001 From: CHE3MZ Date: Sun, 11 Oct 2026 14:21:47 +0300 Subject: [PATCH] fix: pull unpacks host platform only by default Pulling a multi-arch image unpacked a snapshot for every platform in the index, even though run only needs the requested platform. Unpack only the resolved platform and fall back to the host platform when none is specified, so a default pull creates one snapshot. Blobs for every platform are still fetched, preserving offline use. Add --platform all to restore unpacking every platform. This replaces the earlier --all-platforms flag, which did not work when CONTAINER_DEFAULT_PLATFORM was set. --- .../ContainerCommands/Image/ImagePull.swift | 43 +++++- .../ImagePullPlatformTests.swift | 123 ++++++++++++++++++ docs/command-reference.md | 2 +- 3 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 Tests/ContainerCommandsTests/ImagePullPlatformTests.swift diff --git a/Sources/ContainerCommands/Image/ImagePull.swift b/Sources/ContainerCommands/Image/ImagePull.swift index b0299c35f..51fcaf3da 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 000000000..f88cda0e6 --- /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 58edd7147..e21e81fcd 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`