Files
container/Tests/IntegrationTests/Images/TestCLIImageLoadSparse.swift
T
J Logan 7f3697804f Fix container image load for sparse images. (#2385)
- Fixes #2383.
- Bumps containerization to 0.50.0 to pick up
  apple/container#961 upon which this fix depends.
2026-10-12 16:31:01 -07:00

175 lines
9.3 KiB
Swift

//===----------------------------------------------------------------------===//
// 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 ContainerTestSupport
import ContainerizationArchive
import ContainerizationOCI
import CryptoKit
import Foundation
import SystemPackage
import Testing
/// Tests for loading image archives whose index lists platforms that the archive does not carry,
/// such as an archive written by an engine that keeps the full index of an image it pulled for one
/// platform. Loading one should leave the image the same way pulling one platform does: the index
/// is kept as it is, and only the content that is present is stored and usable.
@Suite
struct TestCLIImageLoadSparse {
private var hostArchitecture: String {
#if arch(arm64)
return "arm64"
#else
return "amd64"
#endif
}
/// The platforms that the archives made by makeSparseArchive list but do not carry.
private let absentPlatforms = [
ContainerizationOCI.Platform(arch: "s390x", os: "linux"),
ContainerizationOCI.Platform(arch: "ppc64le", os: "linux"),
]
private func sha256(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
/// Makes an OCI image layout archive for `reference` whose index lists platforms that the archive
/// does not carry. The archive is built from a single-platform archive of a warmup image, and
/// the absent platforms are made up, so no store can already have their content.
///
/// - Parameter includeNative: Whether the archive carries the image for the host platform.
private func makeSparseArchive(_ f: ContainerFixture, reference: String, includeNative: Bool) throws -> FilePath {
let source = try f.copyWarmupImage(.alpine320)
let base = f.testDir.appending("base-\(UUID().uuidString).tar")
try f.run(["image", "save", "--platform", "linux/\(hostArchitecture)", "--output", base.string, source]).check()
let layout = f.testDir.appending("layout-\(UUID().uuidString)")
try FileManager.default.createDirectory(atPath: layout.string, withIntermediateDirectories: true)
let reader = try ArchiveReader(file: URL(filePath: base.string))
let rejected = try reader.extractContents(to: URL(filePath: layout.string))
#expect(rejected.isEmpty, "unexpected rejected members \(rejected)")
let decoder = JSONDecoder()
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes]
let indexFile = URL(filePath: layout.appending("index.json").string)
var top = try decoder.decode(Index.self, from: Data(contentsOf: indexFile))
let saved = try #require(top.manifests.first, "the saved archive has no image")
let blobs = layout.appending("blobs/sha256")
let rootBlob = URL(filePath: blobs.appending(try #require(saved.digest.split(separator: ":").last).description).string)
var root = try decoder.decode(Index.self, from: Data(contentsOf: rootBlob))
let native = root.manifests.filter { $0.platform?.architecture == hostArchitecture && $0.platform?.os == "linux" }
#expect(native.count == 1, "expected one native platform in the saved index, got \(root.manifests)")
root.manifests = includeNative ? native : []
for platform in absentPlatforms {
let digest = "sha256:" + sha256(Data("\(f.testID)-\(platform.description)".utf8))
root.manifests.append(
Descriptor(mediaType: MediaTypes.imageManifest, digest: digest, size: 482, platform: platform))
// An attestation manifest for the absent platform, which has the platform unknown/unknown.
root.manifests.append(
Descriptor(
mediaType: MediaTypes.imageManifest,
digest: "sha256:" + sha256(Data("\(f.testID)-attestation-\(platform.description)".utf8)),
size: 838,
annotations: ["vnd.docker.reference.type": "attestation-manifest", "vnd.docker.reference.digest": digest],
platform: ContainerizationOCI.Platform(arch: "unknown", os: "unknown")))
}
let rootData = try encoder.encode(root)
let rootDigest = sha256(rootData)
try FileManager.default.removeItem(at: rootBlob)
try rootData.write(to: URL(filePath: blobs.appending(rootDigest).string))
var annotations = saved.annotations ?? [:]
for key in ["com.apple.containerization.image.name", "io.containerd.image.name", "org.opencontainers.image.ref.name"] {
annotations[key] = reference
}
top.manifests = [
Descriptor(mediaType: saved.mediaType, digest: "sha256:\(rootDigest)", size: Int64(rootData.count), annotations: annotations)
]
try encoder.encode(top).write(to: indexFile)
let archive = f.testDir.appending("sparse-\(UUID().uuidString).tar")
let writer = try ArchiveWriter(format: .pax, filter: .none, file: URL(filePath: archive.string))
try writer.archiveDirectory(URL(filePath: layout.string))
try writer.finishEncoding()
return archive
}
@Test func testLoadSparseArchive() async throws {
try await ContainerFixture.with { f in
let reference = "registry.local/\(f.testID)/sparse:latest"
let archive = try makeSparseArchive(f, reference: reference, includeNative: true)
f.addCleanup { try? f.doRemoveImages([reference]) }
try f.run(["image", "load", "--input", archive.string]).check("loading an archive with absent platforms failed")
#expect(try f.isImagePresent(reference))
// Only the platform with content is a variant of the image.
let inspected = try f.doInspectImages(reference)
#expect(inspected.count == 1)
let platforms = inspected[0].variants.map { "\($0.platform.os)/\($0.platform.architecture)" }
#expect(platforms == ["linux/\(hostArchitecture)"], "variants \(platforms)")
// The platform with content was unpacked and can run.
let marker = "sparse-\(f.testID)"
let run = try f.run(["run", "--rm", reference, "echo", marker]).check()
#expect(run.output.contains(marker))
}
}
@Test func testLoadArchiveWithNoPlatformPresentFails() async throws {
try await ContainerFixture.with { f in
let reference = "registry.local/\(f.testID)/none:latest"
let archive = try makeSparseArchive(f, reference: reference, includeNative: false)
f.addCleanup { try? f.doRemoveImages([reference]) }
let result = try f.run(["image", "load", "--input", archive.string])
#expect(result.status != 0, "expected the load to fail when no listed platform is in the archive")
// The error names the platforms, and not only a digest.
#expect(result.error.contains("none of the platforms"), "\(result.error)")
for platform in absentPlatforms {
#expect(result.error.contains(platform.description), "\(result.error)")
}
#expect(!(try f.isImagePresent(reference)), "a failed load must not leave the image behind")
}
}
@Test func testSaveReportsOnlyPlatformsWithContent() async throws {
try await ContainerFixture.with { f in
let reference = "registry.local/\(f.testID)/save:latest"
let archive = try makeSparseArchive(f, reference: reference, includeNative: true)
f.addCleanup { try? f.doRemoveImages([reference]) }
try f.run(["image", "load", "--input", archive.string]).check()
// The index lists s390x, but the image has no content for it.
let absent = absentPlatforms[0].description
let result = try f.run(["image", "save", "--platform", absent, "--output", f.testDir.appending("save.tar").string, reference])
#expect(result.status != 0)
#expect(result.error.contains("has no content for platform \(absent)"), "\(result.error)")
let listed = try #require(result.error.components(separatedBy: "available platforms:").last, "\(result.error)")
#expect(listed.contains("linux/\(hostArchitecture)"), "\(result.error)")
for platform in absentPlatforms {
#expect(!listed.contains(platform.description), "an absent platform was reported as available: \(result.error)")
}
// The platform with content can be saved.
try f.run(["image", "save", "--platform", "linux/\(hostArchitecture)", "--output", f.testDir.appending("native.tar").string, reference]).check()
}
}
}