chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 ContainerizationIO
|
||||
import Crypto
|
||||
import Foundation
|
||||
import NIO
|
||||
import Synchronization
|
||||
import Testing
|
||||
|
||||
@testable import ContainerizationOCI
|
||||
|
||||
struct OCIClientTests: ~Copyable {
|
||||
private var contentPath: URL
|
||||
private let fileManager = FileManager.default
|
||||
private var encoder = JSONEncoder()
|
||||
|
||||
init() async throws {
|
||||
let testDir = fileManager.uniqueTemporaryDirectory()
|
||||
let contentPath = testDir.appendingPathComponent("content")
|
||||
try fileManager.createDirectory(at: contentPath, withIntermediateDirectories: true)
|
||||
self.contentPath = contentPath
|
||||
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
}
|
||||
|
||||
deinit {
|
||||
try? fileManager.removeItem(at: contentPath)
|
||||
}
|
||||
|
||||
private static var arch: String? {
|
||||
var uts = utsname()
|
||||
let result = uname(&uts)
|
||||
guard result == EXIT_SUCCESS else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let machine = Data(bytes: &uts.machine, count: 256)
|
||||
guard let arch = String(bytes: machine, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch arch.lowercased().trimmingCharacters(in: .controlCharacters) {
|
||||
case "arm64":
|
||||
return "arm64"
|
||||
default:
|
||||
return "amd64"
|
||||
}
|
||||
}
|
||||
|
||||
@Test(.enabled(if: hasRegistryCredentials))
|
||||
func fetchToken() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io", authentication: Self.authentication)
|
||||
let request = TokenRequest(realm: "https://ghcr.io/token", service: "ghcr.io", clientId: "tests", scope: nil)
|
||||
let response = try await client.fetchToken(request: request)
|
||||
#expect(response.getToken() != nil)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
"registry-1.docker.io",
|
||||
"public.ecr.aws",
|
||||
"registry.k8s.io",
|
||||
"mcr.microsoft.com",
|
||||
])
|
||||
func ping(host: String) async throws {
|
||||
let client = RegistryClient(host: host)
|
||||
try await client.ping()
|
||||
}
|
||||
|
||||
@Test func pingWithInvalidCredentials() async throws {
|
||||
let authentication = BasicAuthentication(username: "foo", password: "bar")
|
||||
let client = RegistryClient(host: "ghcr.io", authentication: authentication)
|
||||
let error = await #expect(throws: RegistryClient.Error.self) { try await client.ping() }
|
||||
guard case .invalidStatus(_, let status, let reason) = error else {
|
||||
throw error!
|
||||
}
|
||||
#expect(status == .unauthorized)
|
||||
#expect(reason == "access denied or wrong credentials")
|
||||
}
|
||||
|
||||
@Test(.enabled(if: hasRegistryCredentials))
|
||||
func pingWithCredentials() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io", authentication: Self.authentication)
|
||||
try await client.ping()
|
||||
}
|
||||
|
||||
@Test func resolve() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io")
|
||||
let descriptor = try await client.resolve(name: "apple/containerization/dockermanifestimage", tag: "0.0.2")
|
||||
#expect(descriptor.mediaType == MediaTypes.dockerManifest)
|
||||
#expect(descriptor.size != 0)
|
||||
#expect(!descriptor.digest.isEmpty)
|
||||
}
|
||||
|
||||
@Test func resolveSha() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io")
|
||||
let descriptor = try await client.resolve(
|
||||
name: "apple/containerization/dockermanifestimage", tag: "sha256:c8d344d228b7d9a702a95227438ec0d71f953a9a483e28ffabc5704f70d2b61e")
|
||||
let namedDescriptor = try await client.resolve(name: "apple/containerization/dockermanifestimage", tag: "0.0.2")
|
||||
#expect(descriptor == namedDescriptor)
|
||||
#expect(descriptor.mediaType == MediaTypes.dockerManifest)
|
||||
#expect(descriptor.size != 0)
|
||||
#expect(!descriptor.digest.isEmpty)
|
||||
}
|
||||
|
||||
@Test func fetchManifest() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io")
|
||||
let descriptor = try await client.resolve(name: "apple/containerization/dockermanifestimage", tag: "0.0.2")
|
||||
let manifest: Manifest = try await client.fetch(name: "apple/containerization/dockermanifestimage", descriptor: descriptor)
|
||||
#expect(manifest.schemaVersion == 2)
|
||||
#expect(manifest.layers.count == 1)
|
||||
}
|
||||
|
||||
@Test func fetchManifestAsData() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io")
|
||||
let descriptor = try await client.resolve(name: "apple/containerization/dockermanifestimage", tag: "0.0.2")
|
||||
let manifestData = try await client.fetchData(name: "apple/containerization/dockermanifestimage", descriptor: descriptor)
|
||||
let checksum = SHA256.hash(data: manifestData)
|
||||
#expect(descriptor.digest == checksum.digest)
|
||||
}
|
||||
|
||||
@Test func fetchConfig() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io")
|
||||
let descriptor = try await client.resolve(name: "apple/containerization/dockermanifestimage", tag: "0.0.2")
|
||||
let manifest: Manifest = try await client.fetch(name: "apple/containerization/dockermanifestimage", descriptor: descriptor)
|
||||
let image: Image = try await client.fetch(name: "apple/containerization/dockermanifestimage", descriptor: manifest.config)
|
||||
// This is an empty image -- check that the image label is present in the image config
|
||||
#expect(image.config?.labels?["org.opencontainers.image.source"] == "https://github.com/apple/containerization")
|
||||
#expect(image.rootfs.diffIDs.count == 1)
|
||||
}
|
||||
|
||||
@Test func fetchBlob() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io")
|
||||
let descriptor = try await client.resolve(name: "apple/containerization/dockermanifestimage", tag: "0.0.2")
|
||||
let manifest: Manifest = try await client.fetch(name: "apple/containerization/dockermanifestimage", descriptor: descriptor)
|
||||
var called = false
|
||||
var done = false
|
||||
try await client.fetchBlob(name: "apple/containerization/dockermanifestimage", descriptor: manifest.layers.first!) { (expected, body) in
|
||||
called = true
|
||||
#expect(expected != 0)
|
||||
var received = 0
|
||||
for try await buffer in body {
|
||||
received += buffer.readableBytes
|
||||
if received == expected {
|
||||
done = true
|
||||
}
|
||||
}
|
||||
}
|
||||
#expect(called)
|
||||
#expect(done)
|
||||
}
|
||||
|
||||
@Test(.disabled("External users cannot push images, disable while we find a better solution"))
|
||||
func pushIndex() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io", authentication: Self.authentication)
|
||||
let indexDescriptor = try await client.resolve(name: "apple/containerization/emptyimage", tag: "0.0.1")
|
||||
let index: Index = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: indexDescriptor)
|
||||
|
||||
let platform = Platform(arch: "amd64", os: "linux")
|
||||
|
||||
var manifestDescriptor: Descriptor?
|
||||
for m in index.manifests where m.platform == platform {
|
||||
manifestDescriptor = m
|
||||
break
|
||||
}
|
||||
|
||||
#expect(manifestDescriptor != nil)
|
||||
|
||||
let manifest: Manifest = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: manifestDescriptor!)
|
||||
let imgConfig: Image = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: manifest.config)
|
||||
|
||||
let layer = try #require(manifest.layers.first)
|
||||
let blobPath = contentPath.appendingPathComponent(layer.digest)
|
||||
let outputStream = OutputStream(toFileAtPath: blobPath.path, append: false)
|
||||
#expect(outputStream != nil)
|
||||
|
||||
try await outputStream!.withThrowingOpeningStream {
|
||||
try await client.fetchBlob(name: "apple/containerization/emptyimage", descriptor: layer) { (expected, body) in
|
||||
var received: Int64 = 0
|
||||
for try await buffer in body {
|
||||
received += Int64(buffer.readableBytes)
|
||||
|
||||
buffer.withUnsafeReadableBytes { pointer in
|
||||
let unsafeBufferPointer = pointer.bindMemory(to: UInt8.self)
|
||||
if let addr = unsafeBufferPointer.baseAddress {
|
||||
_ = outputStream!.write(addr, maxLength: buffer.readableBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(received == expected)
|
||||
}
|
||||
}
|
||||
|
||||
let name = "apple/test-images/image-push"
|
||||
let ref = "latest"
|
||||
|
||||
// Push the layer first.
|
||||
do {
|
||||
let content = try LocalContent(path: blobPath)
|
||||
let generator = {
|
||||
let stream = try ReadStream(url: content.path)
|
||||
try stream.reset()
|
||||
return stream.stream
|
||||
}
|
||||
try await client.push(name: name, ref: ref, descriptor: layer, streamGenerator: generator, progress: nil)
|
||||
} catch let err as ContainerizationError {
|
||||
guard err.code == .exists else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Push the image configuration.
|
||||
var imgConfigDesc: Descriptor?
|
||||
do {
|
||||
imgConfigDesc = try await self.pushDescriptor(
|
||||
client: client,
|
||||
name: name,
|
||||
ref: ref,
|
||||
content: imgConfig,
|
||||
baseDescriptor: manifest.config
|
||||
)
|
||||
} catch let err as ContainerizationError {
|
||||
guard err.code != .exists else {
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
// Push the image manifest.
|
||||
let newManifest = Manifest(
|
||||
schemaVersion: manifest.schemaVersion,
|
||||
mediaType: manifest.mediaType!,
|
||||
config: imgConfigDesc!,
|
||||
layers: manifest.layers,
|
||||
annotations: manifest.annotations
|
||||
)
|
||||
let manifestDesc = try await self.pushDescriptor(
|
||||
client: client,
|
||||
name: name,
|
||||
ref: ref,
|
||||
content: newManifest,
|
||||
baseDescriptor: manifestDescriptor!
|
||||
)
|
||||
|
||||
// Push the index.
|
||||
let newIndex = Index(
|
||||
schemaVersion: index.schemaVersion,
|
||||
mediaType: index.mediaType,
|
||||
manifests: [manifestDesc],
|
||||
annotations: index.annotations
|
||||
)
|
||||
try await self.pushDescriptor(
|
||||
client: client,
|
||||
name: name,
|
||||
ref: ref,
|
||||
content: newIndex,
|
||||
baseDescriptor: indexDescriptor
|
||||
)
|
||||
}
|
||||
|
||||
@Test func resolveWithRetry() async throws {
|
||||
let counter = Mutex(0)
|
||||
let client = RegistryClient(
|
||||
host: "ghcr.io",
|
||||
retryOptions: RetryOptions(
|
||||
maxRetries: 3,
|
||||
retryInterval: 500_000_000,
|
||||
shouldRetry: ({ response in
|
||||
if response.status == .notFound {
|
||||
counter.withLock { $0 += 1 }
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
)
|
||||
)
|
||||
do {
|
||||
_ = try await client.resolve(name: "containerization/not-exists", tag: "foo")
|
||||
} catch {
|
||||
#expect(counter.withLock { $0 } <= 3)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: private functions
|
||||
|
||||
static var hasRegistryCredentials: Bool {
|
||||
authentication != nil
|
||||
}
|
||||
|
||||
static var authentication: Authentication? {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard let password = env["REGISTRY_TOKEN"],
|
||||
let username = env["REGISTRY_USERNAME"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return BasicAuthentication(username: username, password: password)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func pushDescriptor<T: Encodable>(
|
||||
client: RegistryClient,
|
||||
name: String,
|
||||
ref: String,
|
||||
content: T,
|
||||
baseDescriptor: Descriptor
|
||||
) async throws -> Descriptor {
|
||||
let encoded = try self.encoder.encode(content)
|
||||
let digest = SHA256.hash(data: encoded)
|
||||
let descriptor = Descriptor(
|
||||
mediaType: baseDescriptor.mediaType,
|
||||
digest: digest.digest,
|
||||
size: Int64(encoded.count),
|
||||
urls: baseDescriptor.urls,
|
||||
annotations: baseDescriptor.annotations,
|
||||
platform: baseDescriptor.platform
|
||||
)
|
||||
let generator = {
|
||||
let stream = ReadStream(data: encoded)
|
||||
try stream.reset()
|
||||
return stream.stream
|
||||
}
|
||||
|
||||
try await client.push(
|
||||
name: name,
|
||||
ref: ref,
|
||||
descriptor: descriptor,
|
||||
streamGenerator: generator,
|
||||
progress: nil
|
||||
)
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
|
||||
extension OutputStream {
|
||||
fileprivate func withThrowingOpeningStream(_ closure: () async throws -> Void) async throws {
|
||||
self.open()
|
||||
defer { self.close() }
|
||||
|
||||
try await closure()
|
||||
}
|
||||
}
|
||||
|
||||
extension SHA256.Digest {
|
||||
fileprivate var digest: String {
|
||||
let parts = self.description.split(separator: ": ")
|
||||
return "sha256:\(parts[1])"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user