chore: import upstream snapshot with attribution
Linux build / Determine Swift version (push) Waiting to run
Linux build / Linux compile check (push) Blocked by required conditions
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:30 +08:00
commit 680845cb1c
445 changed files with 103779 additions and 0 deletions
@@ -0,0 +1,306 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOS
import Foundation
import Logging
import NIO
import NIOHTTP1
import NIOSSL
#if os(macOS)
import Network
#endif
/// Data used to control retry behavior for `RegistryClient`.
public struct RetryOptions: Sendable {
/// The maximum number of retries to attempt before failing.
public var maxRetries: Int
/// The retry interval in nanoseconds.
public var retryInterval: UInt64
/// A provided closure to handle if a given HTTP response should be
/// retried.
public var shouldRetry: (@Sendable (HTTPClientResponse) -> Bool)?
public init(maxRetries: Int, retryInterval: UInt64, shouldRetry: (@Sendable (HTTPClientResponse) -> Bool)? = nil) {
self.maxRetries = maxRetries
self.retryInterval = retryInterval
self.shouldRetry = shouldRetry
}
}
/// A client for interacting with OCI compliant container registries.
public final class RegistryClient: ContentClient {
private static let defaultRetryOptions = RetryOptions(
maxRetries: 3,
retryInterval: 1_000_000_000,
shouldRetry: ({ response in
response.status.code >= 500
})
)
let client: HTTPClient
let proxyURL: URL?
let base: URLComponents
let clientID: String
let authentication: Authentication?
let retryOptions: RetryOptions?
let bufferSize: Int
public convenience init(
reference: String,
insecure: Bool = false,
auth: Authentication? = nil,
tlsConfiguration: TLSConfiguration? = nil,
logger: Logger? = nil,
) throws {
let ref = try Reference.parse(reference)
guard let domain = ref.resolvedDomain else {
throw ContainerizationError(.invalidArgument, message: "invalid domain for image reference \(reference)")
}
let scheme = insecure ? "http" : "https"
let _url = "\(scheme)://\(domain)"
guard let url = URL(string: _url) else {
throw ContainerizationError(.invalidArgument, message: "cannot convert \(_url) to URL")
}
guard let host = url.host else {
throw ContainerizationError(.invalidArgument, message: "invalid host \(domain)")
}
let port = url.port
self.init(
host: host,
scheme: scheme,
port: port,
authentication: auth,
retryOptions: Self.defaultRetryOptions,
tlsConfiguration: tlsConfiguration,
)
}
public init(
host: String,
scheme: String? = "https",
port: Int? = nil,
authentication: Authentication? = nil,
clientID: String? = nil,
retryOptions: RetryOptions? = nil,
bufferSize: Int = Int(4.mib()),
tlsConfiguration: TLSConfiguration? = nil,
logger: Logger? = nil,
) {
var components = URLComponents()
components.scheme = scheme
components.host = host
components.port = port
self.base = components
self.clientID = clientID ?? "containerization-registry-client"
self.authentication = authentication
self.retryOptions = retryOptions
self.bufferSize = bufferSize
var httpConfiguration = HTTPClient.Configuration()
// proxy configuration assumes all client requests will go to `base` URL
self.proxyURL = ProxyUtils.proxyFromEnvironment(scheme: scheme, host: host)
if let proxyURL = self.proxyURL, let proxyHost = proxyURL.host {
let proxyPort = proxyURL.port ?? (proxyURL.scheme == "https" ? 443 : 80)
httpConfiguration.proxy = HTTPClient.Configuration.Proxy.server(host: proxyHost, port: proxyPort)
}
if tlsConfiguration != nil {
httpConfiguration.tlsConfiguration = tlsConfiguration
}
if let logger {
self.client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration, backgroundActivityLogger: logger)
} else {
self.client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration)
}
}
deinit {
_ = client.shutdown()
}
func host() -> String {
base.host ?? ""
}
internal func request<T>(
components: URLComponents,
method: HTTPMethod = .GET,
bodyClosure: () throws -> HTTPClientRequest.Body? = { nil },
headers: [(String, String)]? = nil,
closure: (HTTPClientResponse) async throws -> T
) async throws -> T {
guard let path = components.url?.absoluteString else {
throw ContainerizationError(.invalidArgument, message: "invalid url \(components.path)")
}
var request = HTTPClientRequest(url: path)
request.method = method
var currentToken: TokenResponse?
let token: String? = try await {
if let basicAuth = authentication {
return try await basicAuth.token()
}
return nil
}()
if let token {
request.headers.add(name: "Authorization", value: "\(token)")
}
// Add any arbitrary headers
headers?.forEach { (k, v) in request.headers.add(name: k, value: v) }
var retryCount = 0
var response: HTTPClientResponse?
while true {
request.body = try bodyClosure()
do {
let _response = try await client.execute(request, deadline: .distantFuture)
response = _response
if _response.status == .unauthorized || _response.status == .forbidden {
let authHeader = _response.headers[TokenRequest.authenticateHeaderName]
let tokenRequest: TokenRequest
do {
tokenRequest = try self.createTokenRequest(parsing: authHeader)
} catch {
// The server did not tell us how to authenticate our requests,
// Or we do not support scheme the server is requesting for.
// Throw the 401/403 to the caller, and let them decide how to proceed.
throw RegistryClient.Error.invalidStatus(url: path, _response.status, reason: String(describing: error))
}
if let ct = currentToken, ct.isValid(scope: tokenRequest.scope) {
break
}
do {
let _currentToken = try await fetchToken(request: tokenRequest)
guard let token = _currentToken.getToken() else {
throw ContainerizationError(.internalError, message: "failed to fetch Bearer token")
}
currentToken = _currentToken
request.headers.replaceOrAdd(name: "Authorization", value: token)
retryCount += 1
} catch let err as RegistryClient.Error {
guard case .invalidStatus(_, let status, _) = err else {
throw err
}
if status == .unauthorized || status == .forbidden {
throw RegistryClient.Error.invalidStatus(url: path, _response.status, reason: "access denied or wrong credentials")
}
throw err
}
continue
} else if _response.status == .badRequest && request.headers.contains(name: "Authorization") {
// Retry without basic auth
request.headers.remove(name: "Authorization")
retryCount += 1
continue
}
guard let retryOptions = self.retryOptions else {
break
}
guard retryCount < retryOptions.maxRetries else {
break
}
guard let shouldRetry = retryOptions.shouldRetry, shouldRetry(_response) else {
break
}
retryCount += 1
try await Task.sleep(nanoseconds: retryOptions.retryInterval)
continue
} catch let err as RegistryClient.Error {
throw err
} catch {
#if os(macOS)
if let err = error as? NWError {
if err.errorCode == kDNSServiceErr_NoSuchRecord {
let message: String
if let proxyURL = self.proxyURL, let proxyHost = proxyURL.host {
message = "failed to resolve either repository hostname \(host()) or proxy hostname \(proxyHost)"
} else {
message = "failed to resolve either repository hostname \(host())"
}
throw ContainerizationError(.internalError, message: message)
}
}
#endif
guard let retryOptions = self.retryOptions, retryCount < retryOptions.maxRetries else {
throw error
}
retryCount += 1
try await Task.sleep(nanoseconds: retryOptions.retryInterval)
}
}
guard let response else {
throw ContainerizationError(.internalError, message: "invalid response")
}
return try await closure(response)
}
internal func requestData(
components: URLComponents,
headers: [(String, String)]? = nil
) async throws -> Data {
let bytes: ByteBuffer = try await requestBuffer(components: components, headers: headers)
return Data(buffer: bytes)
}
internal func requestBuffer(
components: URLComponents,
headers: [(String, String)]? = nil
) async throws -> ByteBuffer {
try await request(components: components, method: .GET, 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)
}
return try await response.body.collect(upTo: self.bufferSize)
}
}
internal func requestJSON<T: Decodable>(
components: URLComponents,
headers: [(String, String)]? = nil
) async throws -> T {
let buffer = try await self.requestBuffer(components: components, headers: headers)
return try JSONDecoder().decode(T.self, from: buffer)
}
/// A minimal endpoint, mounted at /v2/ will provide version support information based on its response statuses.
/// See https://distribution.github.io/distribution/spec/api/#api-version-check
public func ping() async throws {
var components = base
components.path = "/v2/"
try await request(components: components) { 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)
}
}
}
}