Skip to content
Closed
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
2 changes: 1 addition & 1 deletion Sources/ContainerCommands/BuildCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ extension Application {
}
for image in result.images {
try Task.checkCancellation()
try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler))
try await image.unpackPreferringHost(progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler))

// Tag the unpacked image with all requested tags
for tagName in imageNames {
Expand Down
2 changes: 1 addition & 1 deletion Sources/ContainerCommands/Image/ImageLoad.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ extension Application {
progress.set(description: "Unpacking image")
progress.set(itemsName: "entries")
for image in result.images {
try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler))
try await image.unpackPreferringHost(progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler))
}
await taskManager.finish()
progress.finish()
Expand Down
9 changes: 8 additions & 1 deletion Sources/ContainerCommands/Image/ImagePull.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,14 @@ 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))
// A pull with no platform keeps every platform in the content
// store; the snapshot unpacks for the platform the host runs,
// and any other platform unpacks on demand when it is used.
if let p {
try await image.unpack(platform: p, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler))
} else {
try await image.unpackPreferringHost(progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler))
}
await taskManager.finish()
progress.finish()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ extension ContainerFixture {
public let architecture: String
}
public let platform: Platform
public let digest: String
}
public let configuration: Configuration
public let variants: [Variant]
Expand Down
24 changes: 24 additions & 0 deletions Sources/Services/ContainerAPIService/Client/ClientImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,30 @@ extension ClientImage {

// MARK: Snapshot Methods

/// Unpack the platform this host runs, when the image carries it.
///
/// The content store can hold every platform of a reference while only
/// the host's platform is ever mounted here; the others unpack on
/// demand when something asks to use them. An image that does not
/// provide the host platform stays packed, the way a foreign-only
/// image arrives from a load or a cross-platform build.
///
/// The engine default a reference would give is one platform per pull,
/// the host's, which scopes what is fetched as well as what is unpacked:
/// https://docs.docker.com/reference/cli/docker/image/pull/
/// A pull here fetches every platform the index carries and narrows only
/// the unpacking, so a foreign platform is there to run under Rosetta or
/// to be pushed on without being fetched again. What that costs is
/// compressed blobs, against the ext4 snapshot per platform that
/// unpacking them all cost.
public func unpackPreferringHost(progressUpdate: ProgressUpdateHandler? = nil) async throws {
do {
try await self.unpack(platform: .current, progressUpdate: progressUpdate)
} catch let error as ContainerizationError where error.code == .notFound {
// The reference has no host-platform variant; leave it packed.
}
}

public func unpack(platform: Platform?, progressUpdate: ProgressUpdateHandler? = nil) async throws {
let client = Self.newXPCClient()
let request = Self.newRequest(.imageUnpack)
Expand Down
66 changes: 66 additions & 0 deletions Tests/IntegrationTests/Images/TestCLIImageUnpackSerial.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//===----------------------------------------------------------------------===//
// 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 ContainerPersistence
import ContainerTestSupport
import Foundation
import Testing

/// Serialized: asserts on the shared store's snapshot directories.
@Suite(.serialized)
struct TestCLIImageUnpackSerial {
private let alpine = WarmupImage.alpine320.rawValue

private func snapshotExists(digest: String) -> Bool {
let hex = digest.split(separator: ":").last.map(String.init) ?? digest
let dir =
PathUtils.BaseConfigPath.appRoot.basePath()
.appending("snapshots")
.appending(hex)
return FileManager.default.fileExists(atPath: dir.string)
}

@Test func testPullUnpacksTheHostPlatformAlone() async throws {
try await ContainerFixture.with { f in
try f.doPull(alpine)
let variants = try f.doInspectImages(alpine).flatMap { $0.variants }
#expect(variants.count > 1, "the assertion needs a multi-arch reference")

for variant in variants where variant.platform.os == "linux" {
let isHost = variant.platform.architecture == "arm64"
if isHost {
#expect(snapshotExists(digest: variant.digest), "the host platform unpacks on pull")
}
}
let foreign = variants.filter { $0.platform.os == "linux" && $0.platform.architecture != "arm64" }
let foreignUnpacked = foreign.filter { snapshotExists(digest: $0.digest) }
#expect(
foreignUnpacked.count < foreign.count || foreign.isEmpty,
"platforms the host cannot mount stay packed until something asks for them")
}
}

@Test func testExplicitPlatformUnpacksWhatItNames() async throws {
try await ContainerFixture.with { f in
try f.doPull(alpine, args: ["--platform", "linux/amd64"])
let variants = try f.doInspectImages(alpine).flatMap { $0.variants }
guard let amd64 = variants.first(where: { $0.platform.architecture == "amd64" && $0.platform.os == "linux" }) else {
throw CommandError.executionFailed("no amd64 variant in \(alpine)")
}
#expect(snapshotExists(digest: amd64.digest), "an explicit --platform unpacks exactly what it names")
}
}
}