680845cb1c
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
307 lines
12 KiB
Swift
307 lines
12 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|