chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 AsyncHTTPClient
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import Crypto
|
||||
import Foundation
|
||||
import NIOFoundationCompat
|
||||
|
||||
#if os(macOS)
|
||||
import _NIOFileSystem
|
||||
#endif
|
||||
|
||||
extension RegistryClient {
|
||||
/// Resolve sends a HEAD request to the registry to find root manifest descriptor.
|
||||
/// This descriptor serves as an entry point to retrieve resources from the registry.
|
||||
public func resolve(name: String, tag: String) async throws -> Descriptor {
|
||||
var components = base
|
||||
|
||||
// Make HEAD request to retrieve the digest header
|
||||
components.path = "/v2/\(name)/manifests/\(tag)"
|
||||
|
||||
// The client should include an Accept header indicating which manifest content types it supports.
|
||||
let mediaTypes = [
|
||||
MediaTypes.dockerManifest,
|
||||
MediaTypes.dockerManifestList,
|
||||
MediaTypes.imageManifest,
|
||||
MediaTypes.index,
|
||||
"*/*",
|
||||
]
|
||||
|
||||
let headers = [
|
||||
("Accept", mediaTypes.joined(separator: ", "))
|
||||
]
|
||||
|
||||
return try await request(components: components, method: .HEAD, headers: headers) { response in
|
||||
guard response.status == .ok else {
|
||||
let url = components.url?.absoluteString ?? "unknown"
|
||||
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
|
||||
throw Error.invalidStatus(url: url, response.status, reason: reason)
|
||||
}
|
||||
|
||||
guard let digest = response.headers.first(name: "Docker-Content-Digest") else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing required header Docker-Content-Digest")
|
||||
}
|
||||
|
||||
guard let type = response.headers.first(name: "Content-Type") else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing required header Content-Type")
|
||||
}
|
||||
|
||||
guard let sizeStr = response.headers.first(name: "Content-Length") else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing required header Content-Length")
|
||||
}
|
||||
|
||||
guard let size = Int64(sizeStr) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "cannot convert \(sizeStr) to Int64")
|
||||
}
|
||||
|
||||
return Descriptor(mediaType: type, digest: digest, size: size)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch resource (either manifest or blob) to memory with JSON decoding.
|
||||
public func fetch<T: Codable>(name: String, descriptor: Descriptor) async throws -> T {
|
||||
var components = base
|
||||
|
||||
let manifestTypes = [
|
||||
MediaTypes.dockerManifest,
|
||||
MediaTypes.dockerManifestList,
|
||||
MediaTypes.imageManifest,
|
||||
MediaTypes.index,
|
||||
]
|
||||
|
||||
let isManifest = manifestTypes.contains(where: { $0 == descriptor.mediaType })
|
||||
let resource = isManifest ? "manifests" : "blobs"
|
||||
|
||||
components.path = "/v2/\(name)/\(resource)/\(descriptor.digest)"
|
||||
|
||||
let mediaType = descriptor.mediaType
|
||||
if mediaType.isEmpty {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing media type for descriptor \(descriptor.digest)")
|
||||
}
|
||||
|
||||
let headers = [
|
||||
("Accept", mediaType)
|
||||
]
|
||||
|
||||
return try await requestJSON(components: components, headers: headers)
|
||||
}
|
||||
|
||||
/// Fetch resource (either manifest or blob) to memory as raw `Data`.
|
||||
public func fetchData(name: String, descriptor: Descriptor) async throws -> Data {
|
||||
var components = base
|
||||
|
||||
let manifestTypes = [
|
||||
MediaTypes.dockerManifest,
|
||||
MediaTypes.dockerManifestList,
|
||||
MediaTypes.imageManifest,
|
||||
MediaTypes.index,
|
||||
]
|
||||
|
||||
let isManifest = manifestTypes.contains(where: { $0 == descriptor.mediaType })
|
||||
let resource = isManifest ? "manifests" : "blobs"
|
||||
|
||||
components.path = "/v2/\(name)/\(resource)/\(descriptor.digest)"
|
||||
|
||||
let mediaType = descriptor.mediaType
|
||||
if mediaType.isEmpty {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing media type for descriptor \(descriptor.digest)")
|
||||
}
|
||||
|
||||
let headers = [
|
||||
("Accept", mediaType)
|
||||
]
|
||||
|
||||
return try await requestData(components: components, headers: headers)
|
||||
}
|
||||
|
||||
/// Fetch a blob from remote registry.
|
||||
/// This method is suitable for streaming data.
|
||||
public func fetchBlob(
|
||||
name: String,
|
||||
descriptor: Descriptor,
|
||||
closure: (Int64, HTTPClientResponse.Body) async throws -> Void
|
||||
) async throws {
|
||||
var components = base
|
||||
components.path = "/v2/\(name)/blobs/\(descriptor.digest)"
|
||||
|
||||
let mediaType = descriptor.mediaType
|
||||
if mediaType.isEmpty {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing media type for descriptor \(descriptor.digest)")
|
||||
}
|
||||
|
||||
let headers = [
|
||||
("Accept", mediaType)
|
||||
]
|
||||
|
||||
try await request(components: components, headers: headers) { response in
|
||||
guard response.status == .ok else {
|
||||
let url = components.url?.absoluteString ?? "unknown"
|
||||
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
|
||||
throw Error.invalidStatus(url: url, response.status, reason: reason)
|
||||
}
|
||||
|
||||
// How many bytes to expect
|
||||
guard let expectedBytes = response.headers.first(name: "Content-Length").flatMap(Int64.init) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing required header Content-Length")
|
||||
}
|
||||
|
||||
try await closure(expectedBytes, response.body)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Fetch a blob from remote registry and write the contents into a file in the provided directory.
|
||||
public func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest) {
|
||||
var hasher = SHA256()
|
||||
var received: Int64 = 0
|
||||
let fs = _NIOFileSystem.FileSystem.shared
|
||||
let handle = try await fs.openFile(forWritingAt: FilePath(file.absolutePath()), options: .newFile(replaceExisting: true))
|
||||
var writer = handle.bufferedWriter()
|
||||
do {
|
||||
try await self.fetchBlob(name: name, descriptor: descriptor) { (size, body) in
|
||||
var itr = body.makeAsyncIterator()
|
||||
while let buf = try await itr.next() {
|
||||
let readBytes = Int64(buf.readableBytes)
|
||||
received += readBytes
|
||||
let written = try await writer.write(contentsOf: buf)
|
||||
await progress?([
|
||||
.addSize(written)
|
||||
])
|
||||
guard written == readBytes else {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "could not write \(readBytes) bytes to file \(file)"
|
||||
)
|
||||
}
|
||||
hasher.update(data: buf.readableBytesView)
|
||||
}
|
||||
}
|
||||
try await writer.flush()
|
||||
try await handle.close()
|
||||
} catch {
|
||||
do {
|
||||
try await handle.close()
|
||||
} catch {
|
||||
// Use `detachUnsafeFileDescriptor()` as suggested by the error message to prevent a leak detection crash when `close()` fails.
|
||||
_ = try handle.detachUnsafeFileDescriptor()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
let computedDigest = hasher.finalize()
|
||||
return (received, computedDigest)
|
||||
}
|
||||
#else
|
||||
/// Fetch a blob from remote registry and write the contents into a file in the provided directory.
|
||||
public func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest) {
|
||||
var hasher = SHA256()
|
||||
var received: Int64 = 0
|
||||
guard FileManager.default.createFile(atPath: file.path, contents: nil) else {
|
||||
throw ContainerizationError(.internalError, message: "cannot create file at path \(file.path)")
|
||||
}
|
||||
try await self.fetchBlob(name: name, descriptor: descriptor) { (size, body) in
|
||||
let fd = try FileHandle(forWritingTo: file)
|
||||
defer {
|
||||
try? fd.close()
|
||||
}
|
||||
var itr = body.makeAsyncIterator()
|
||||
while let buf = try await itr.next() {
|
||||
let readBytes = Int64(buf.readableBytes)
|
||||
received += readBytes
|
||||
await progress?([
|
||||
.addSize(readBytes)
|
||||
])
|
||||
try fd.write(contentsOf: buf.readableBytesView)
|
||||
hasher.update(data: buf.readableBytesView)
|
||||
}
|
||||
}
|
||||
let computedDigest = hasher.finalize()
|
||||
return (received, computedDigest)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user