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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user