tart {clone,pull}: support --proxy, --ca-cert and --max-retries

This commit is contained in:
Nikolay Edigaryev 2025-03-07 18:54:20 +01:00
parent a4edc6af50
commit 36bf7116c7
11 changed files with 174 additions and 38 deletions

View File

@ -31,6 +31,15 @@ struct Clone: AsyncParsableCommand {
@Flag(help: .hidden) @Flag(help: .hidden)
var deduplicate: Bool = false var deduplicate: Bool = false
@Option(help: .hidden)
var proxy: String?
@Option(help: .hidden)
var caCert: String?
@Option(help: .hidden)
var maxRetries: UInt = 5
func validate() throws { func validate() throws {
if newName.contains("/") { if newName.contains("/") {
throw ValidationError("<new-name> should be a local name") throw ValidationError("<new-name> should be a local name")
@ -47,8 +56,8 @@ struct Clone: AsyncParsableCommand {
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) { if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
// Pull the VM in case it's OCI-based and doesn't exist locally yet // Pull the VM in case it's OCI-based and doesn't exist locally yet
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure) let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure, proxy: proxy, caCert: caCert)
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate) try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate, maxRetries: maxRetries)
} }
let sourceVM = try VMStorageHelper.open(sourceName) let sourceVM = try VMStorageHelper.open(sourceName)

View File

@ -26,6 +26,15 @@ struct Pull: AsyncParsableCommand {
@Flag(help: .hidden) @Flag(help: .hidden)
var deduplicate: Bool = false var deduplicate: Bool = false
@Option(help: .hidden)
var proxy: String?
@Option(help: .hidden)
var caCert: String?
@Option(help: .hidden)
var maxRetries: UInt = 5
func validate() throws { func validate() throws {
if concurrency < 1 { if concurrency < 1 {
throw ValidationError("network concurrency cannot be less than 1") throw ValidationError("network concurrency cannot be less than 1")
@ -42,10 +51,10 @@ struct Pull: AsyncParsableCommand {
} }
let remoteName = try RemoteName(remoteName) let remoteName = try RemoteName(remoteName)
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure) let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure, proxy: proxy, caCert: caCert)
defaultLogger.appendNewLine("pulling \(remoteName)...") defaultLogger.appendNewLine("pulling \(remoteName)...")
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate) try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate, maxRetries: maxRetries)
} }
} }

View File

@ -1,26 +1,61 @@
import Foundation import Foundation
fileprivate var urlSession: URLSession = {
let config = URLSessionConfiguration.default
// Harbor expects a CSRF token to be present if the HTTP client
// carries a session cookie between its requests[1] and fails if
// it was not present[2].
//
// To fix that, we disable the automatic cookies carry in URLSession.
//
// [1]: https://github.com/goharbor/harbor/blob/a4c577f9ec4f18396207a5e686433a6ba203d4ef/src/server/middleware/csrf/csrf.go#L78
// [2]: https://github.com/cirruslabs/tart/issues/295
config.httpShouldSetCookies = false
return URLSession(configuration: config)
}()
class Fetcher { class Fetcher {
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) { let urlSession: URLSession
let task = urlSession.dataTask(with: request) let caCert: SecCertificate?
let delegate = Delegate() init(proxy: String? = nil, caCert: String? = nil) throws {
// Configure URLSession
let config = URLSessionConfiguration.default
// Harbor expects a CSRF token to be present if the HTTP client
// carries a session cookie between its requests[1] and fails if
// it was not present[2].
//
// To fix that, we disable the automatic cookies carry in URLSession.
//
// [1]: https://github.com/goharbor/harbor/blob/a4c577f9ec4f18396207a5e686433a6ba203d4ef/src/server/middleware/csrf/csrf.go#L78
// [2]: https://github.com/cirruslabs/tart/issues/295
config.httpShouldSetCookies = false
if let proxy {
let (host, port) = try Self.parseProxy(proxy)
config.connectionProxyDictionary = [
kCFNetworkProxiesHTTPEnable: true,
kCFNetworkProxiesHTTPProxy: host,
kCFNetworkProxiesHTTPPort: port,
kCFNetworkProxiesHTTPSEnable: true,
kCFNetworkProxiesHTTPSProxy: host,
kCFNetworkProxiesHTTPSPort: port,
]
}
self.urlSession = URLSession(configuration: config)
// Load CA certificate, if any
if let caCert {
let caCertString = try String(contentsOf: URL(filePath: caCert), encoding:. utf8)
let caCertBase64Lines = caCertString.components(separatedBy: .newlines).filter { line in
!line.hasPrefix("-----BEGIN") && !line.hasPrefix("-----END")
}
guard let caCertData = Data(base64Encoded: caCertBase64Lines.joined()) else {
throw RuntimeError.FailedToLoadCACertificate("failed to parse Base64-encoded PEM data")
}
self.caCert = SecCertificateCreateWithData(nil, caCertData as CFData)!
} else {
self.caCert = nil
}
}
func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
let task = self.urlSession.dataTask(with: request)
let delegate = Delegate(caCert: self.caCert)
task.delegate = delegate task.delegate = delegate
let stream = AsyncThrowingStream<Data, Error> { continuation in let stream = AsyncThrowingStream<Data, Error> { continuation in
@ -34,15 +69,78 @@ class Fetcher {
return (stream, response as! HTTPURLResponse) return (stream, response as! HTTPURLResponse)
} }
private static func parseProxy(_ proxy: String) throws -> (String, Int) {
// Assume that the scheme is specified
var url = URL(string: proxy)
// Fall back to HTTP scheme when not specified
if url?.scheme == nil {
url = URL(string: "http://\(proxy)")
}
guard let url else {
throw RuntimeError.InvalidProxyString
}
guard let host = url.host() else {
throw RuntimeError.InvalidProxyString
}
guard let port = url.port else {
throw RuntimeError.InvalidProxyString
}
return (host, port)
}
} }
fileprivate class Delegate: NSObject, URLSessionDataDelegate { fileprivate class Delegate: NSObject, URLSessionDelegate, URLSessionDataDelegate {
let caCert: SecCertificate?
var responseContinuation: CheckedContinuation<URLResponse, Error>? var responseContinuation: CheckedContinuation<URLResponse, Error>?
var streamContinuation: AsyncThrowingStream<Data, Error>.Continuation? var streamContinuation: AsyncThrowingStream<Data, Error>.Continuation?
private var buffer: Data = Data() private var buffer: Data = Data()
private let bufferFlushSize = 16 * 1024 * 1024 private let bufferFlushSize = 16 * 1024 * 1024
init(caCert: SecCertificate?) {
self.caCert = caCert
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
if let caCert {
// Ensure that we're performing server trust authentication
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
// Set the provided CA certificate as the only anchor
if SecTrustSetAnchorCertificates(serverTrust, [caCert] as CFArray) != errSecSuccess {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Evaluate the trust
if SecTrustEvaluateWithError(serverTrust, nil) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.rejectProtectionSpace, nil)
}
return
}
completionHandler(.performDefaultHandling, nil)
}
func urlSession( func urlSession(
_ session: URLSession, _ session: URLSession,
dataTask: URLSessionDataTask, dataTask: URLSessionDataTask,

View File

@ -2,5 +2,5 @@ import Foundation
protocol Disk { protocol Disk {
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer]
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?, deduplicate: Bool, maxRetries: UInt) async throws
} }

View File

@ -45,7 +45,7 @@ class DiskV1: Disk {
return pushedLayers return pushedLayers
} }
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws { static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false, maxRetries: UInt) async throws {
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) { if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
throw OCIError.FailedToCreateVmFile throw OCIError.FailedToCreateVmFile
} }

View File

@ -84,7 +84,7 @@ class DiskV2: Disk {
} }
} }
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws { static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false, maxRetries: UInt) async throws {
// Support resumable pulls // Support resumable pulls
let pullResumed = FileManager.default.fileExists(atPath: diskURL.path) let pullResumed = FileManager.default.fileExists(atPath: diskURL.path)
@ -210,7 +210,7 @@ class DiskV2: Disk {
var rangeStart: Int64 = 0 var rangeStart: Int64 = 0
try await retry(maxAttempts: 5) { try await retry(maxAttempts: Int(maxRetries)) {
try await registry.pullBlob(diskLayer.digest, rangeStart: rangeStart) { data in try await registry.pullBlob(diskLayer.digest, rangeStart: rangeStart) { data in
try filter.write(data) try filter.write(data)

View File

@ -115,6 +115,7 @@ class Registry {
let namespace: String let namespace: String
let credentialsProviders: [CredentialsProvider] let credentialsProviders: [CredentialsProvider]
let authenticationKeeper = AuthenticationKeeper() let authenticationKeeper = AuthenticationKeeper()
let fetcher: Fetcher
var host: String? { var host: String? {
guard let host = baseURL.host else { return nil } guard let host = baseURL.host else { return nil }
@ -128,17 +129,22 @@ class Registry {
init(baseURL: URL, init(baseURL: URL,
namespace: String, namespace: String,
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()] credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()],
proxy: String? = nil,
caCert: String? = nil
) throws { ) throws {
self.baseURL = baseURL self.baseURL = baseURL
self.namespace = namespace self.namespace = namespace
self.credentialsProviders = credentialsProviders self.credentialsProviders = credentialsProviders
self.fetcher = try Fetcher(proxy: proxy, caCert: caCert)
} }
convenience init( convenience init(
host: String, host: String,
namespace: String, namespace: String,
insecure: Bool = false, insecure: Bool = false,
proxy: String? = nil,
caCert: String? = nil,
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()] credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws { ) throws {
let proto = insecure ? "http" : "https" let proto = insecure ? "http" : "https"
@ -154,7 +160,7 @@ class Registry {
throw RuntimeError.ImproperlyFormattedHost(host, hint) throw RuntimeError.ImproperlyFormattedHost(host, hint)
} }
try self.init(baseURL: baseURL, namespace: namespace, credentialsProviders: credentialsProviders) try self.init(baseURL: baseURL, namespace: namespace, credentialsProviders: credentialsProviders, proxy: proxy, caCert: caCert)
} }
func ping() async throws { func ping() async throws {
@ -448,6 +454,6 @@ class Registry {
request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))", request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))",
forHTTPHeaderField: "User-Agent") forHTTPHeaderField: "User-Agent")
return try await Fetcher.fetch(request, viaFile: viaFile) return try await self.fetcher.fetch(request, viaFile: viaFile)
} }
} }

View File

@ -83,7 +83,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Check if we already have this IPSW in cache // Check if we already have this IPSW in cache
var headRequest = URLRequest(url: remoteURL) var headRequest = URLRequest(url: remoteURL)
headRequest.httpMethod = "HEAD" headRequest.httpMethod = "HEAD"
let (_, headResponse) = try await Fetcher.fetch(headRequest, viaFile: false) let (_, headResponse) = try await Fetcher().fetch(headRequest, viaFile: false)
if let hash = headResponse.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") { if let hash = headResponse.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw") let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
@ -100,7 +100,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...") defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
let request = URLRequest(url: remoteURL) let request = URLRequest(url: remoteURL)
let (channel, response) = try await Fetcher.fetch(request, viaFile: true) let (channel, response) = try await Fetcher().fetch(request, viaFile: true)
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw") let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw")

View File

@ -11,7 +11,14 @@ enum OCIError: Error {
} }
extension VMDirectory { extension VMDirectory {
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws { func pullFromRegistry(
registry: Registry,
manifest: OCIManifest,
concurrency: UInt,
localLayerCache: LocalLayerCache?,
deduplicate: Bool,
maxRetries: UInt
) async throws {
// Pull VM's config file layer and re-serialize it into a config file // Pull VM's config file layer and re-serialize it into a config file
let configLayers = manifest.layers.filter { let configLayers = manifest.layers.filter {
$0.mediaType == configMediaType $0.mediaType == configMediaType
@ -55,7 +62,8 @@ extension VMDirectory {
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL, try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
concurrency: concurrency, progress: progress, concurrency: concurrency, progress: progress,
localLayerCache: localLayerCache, localLayerCache: localLayerCache,
deduplicate: deduplicate) deduplicate: deduplicate,
maxRetries: maxRetries)
} catch let error where error is FilterError { } catch let error where error is FilterError {
throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)") throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)")
} }

View File

@ -75,6 +75,8 @@ enum RuntimeError : Error {
case SuspendFailed(_ message: String) case SuspendFailed(_ message: String)
case PullFailed(_ message: String) case PullFailed(_ message: String)
case VirtualMachineLimitExceeded(_ hint: String) case VirtualMachineLimitExceeded(_ hint: String)
case InvalidProxyString
case FailedToLoadCACertificate(_ message: String)
} }
protocol HasExitCode { protocol HasExitCode {
@ -136,6 +138,10 @@ extension RuntimeError : CustomStringConvertible {
return message return message
case .VirtualMachineLimitExceeded(let hint): case .VirtualMachineLimitExceeded(let hint):
return "The number of VMs exceeds the system limit\(hint)" return "The number of VMs exceeds the system limit\(hint)"
case .InvalidProxyString:
return "Invalid proxy string, should be in the form of host:port"
case .FailedToLoadCACertificate(let message):
return "Failed to load CA certificate: \(message)"
} }
} }
} }

View File

@ -140,7 +140,7 @@ class VMStorageOCI: PrunableStorage {
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir } try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
} }
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws { func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool, maxRetries: UInt) async throws {
SentrySDK.configureScope { scope in SentrySDK.configureScope { scope in
scope.setContext(value: ["imageName": name.description], key: "OCI") scope.setContext(value: ["imageName": name.description], key: "OCI")
} }
@ -196,7 +196,7 @@ class VMStorageOCI: PrunableStorage {
} }
try await withTaskCancellationHandler(operation: { try await withTaskCancellationHandler(operation: {
try await retry(maxAttempts: 5) { try await retry(maxAttempts: Int(maxRetries)) {
// Choose the best base image which has the most deduplication ratio // Choose the best base image which has the most deduplication ratio
let localLayerCache = try await chooseLocalLayerCache(name, manifest, registry) let localLayerCache = try await chooseLocalLayerCache(name, manifest, registry)
@ -210,7 +210,7 @@ class VMStorageOCI: PrunableStorage {
} }
} }
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache, deduplicate: deduplicate) try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache, deduplicate: deduplicate, maxRetries: maxRetries)
} recoverFromFailure: { error in } recoverFromFailure: { error in
if error is URLError { if error is URLError {
print("Error pulling image: \"\(error.localizedDescription)\", attempting to re-try...") print("Error pulling image: \"\(error.localizedDescription)\", attempting to re-try...")