Compare commits

..
18 Commits
Author SHA1 Message Date
Fedor KorotkovandNikolay Edigaryev a80954c888 Do not include Content-Range for monolithic uploads (#179)
* Do not include Content-Range for monolithic uploads

Some registries still assumes it's a chunked upload and verifies the "chunk" size which is too big.

* Update Sources/tart/OCI/Registry.swift

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2022-08-12 16:33:15 +03:00
Nikolay Edigaryev cc8201dee6 OCI: support insecure registries and custom ports (#174) 2022-08-12 16:24:14 +03:00
Fedor Korotkov 2cab49b3f1 Use Ventura APIs for recovery mode (#175) 2022-08-12 08:02:44 +03:00
Fedor Korotkov 131827802a Friendly error message on Intel (#177)
In case someone tries to install Tart. Fixes #176
2022-08-10 11:13:21 -04:00
Fedor Korotkov e2b7f12388 Support chunked uploads (#159)
* Support chunked uploads

* Rebase fixes

* Bump swift http client
2022-08-08 10:16:47 -04:00
Fedor Korotkov 617a5d02dc Fixed potential infinity auth (#173)
Follow up to an attempt in #169
2022-08-08 16:50:13 +03:00
Nikolay Edigaryev b83bce4544 tart login: read whole stdin contents instead of just a line (#170) 2022-08-02 16:52:35 +03:00
Nikolay Edigaryev 7d16516b6a OCI: gcr.io fixes (#169)
* Make sure we don't craft an URLComponents with an empty query items

* Prevent auth() infinite loop

* OCI: ignore invalid RFC 3339 formatted dates
2022-08-01 19:24:11 -04:00
Nikolay Edigaryev 56ddd8df75 OCI's HTTP client: prevent the usage of NIO Transport Services (#168) 2022-08-01 18:17:50 -04:00
Fedor Korotkov b1534a05d5 Configure audio devices to pass through the signal from/to the host (#163)
Fixes #161
2022-08-01 17:54:55 +03:00
Nikolay Edigaryev f54e7c2cab tart login: only store one credential per registry (#162) 2022-08-01 09:55:37 +03:00
Nikolay Edigaryev 85dfa961c9 tart run: introduce --disk option (#156)
* tart run: introduce --disk option

* Document how to create disks using Disk Utility
2022-07-26 09:25:23 -04:00
Nikolay Edigaryev 3a74fc35e6 Introduce TART_HOME (#157) 2022-07-25 17:01:46 +03:00
Nikolay Edigaryev 7bf7f890c4 tart run: return exit status 2 when VM is already running (#155) 2022-07-21 07:53:47 -04:00
Nikolay Edigaryev 48cd4b47e4 Move full-fledged VNC support to --experimental-vnc (#154) 2022-07-21 12:37:43 +03:00
Nikolay Edigaryev c1dee4f9b2 Credentials: update Keychain entry if it already exists (#149) 2022-07-13 12:35:44 -04:00
Fedor Korotkov 116dc01f55 Document how to retrieve artifacts (#146) 2022-07-08 18:45:14 +03:00
Nikolay Edigaryev 52abb7589c OCI: support Basic authentication scheme (#145)
* OCI: support Basic authentication scheme

* .isValid → .isValid()

* tart login: make --username optional
2022-07-08 16:36:00 +03:00
21 changed files with 421 additions and 137 deletions
+7 -1
View File
@@ -32,7 +32,7 @@ brews:
tap: tap:
owner: cirruslabs owner: cirruslabs
name: homebrew-cli name: homebrew-cli
caveats: See the Github repository for more information caveats: See the GitHub repository for more information
homepage: https://github.com/cirruslabs/tart homepage: https://github.com/cirruslabs/tart
description: Run macOS VMs on Apple Silicon description: Run macOS VMs on Apple Silicon
skip_upload: auto skip_upload: auto
@@ -40,3 +40,9 @@ brews:
- "cirruslabs/cli/softnet" - "cirruslabs/cli/softnet"
custom_block: | custom_block: |
depends_on :macos => :monterey depends_on :macos => :monterey
on_macos do
unless Hardware::CPU.arm?
odie "Tart only works on Apple Silicon!"
end
end
+31 -4
View File
@@ -5,8 +5,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/async-http-client", "location" : "https://github.com/swift-server/async-http-client",
"state" : { "state" : {
"revision" : "24425989dadab6d6e4167174791a23d4e2a6d0c3", "revision" : "df87a860fdc41a595d5ca67f74cde9adbccc099a",
"version" : "1.10.0" "version" : "1.11.4"
} }
}, },
{ {
@@ -18,6 +18,15 @@
"revision" : "772883073d044bc754d401cabb6574624eb3778f" "revision" : "772883073d044bc754d401cabb6574624eb3778f"
} }
}, },
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-algorithms",
"state" : {
"revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
"version" : "1.0.0"
}
},
{ {
"identity" : "swift-argument-parser", "identity" : "swift-argument-parser",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -27,6 +36,15 @@
"version" : "1.1.2" "version" : "1.1.2"
} }
}, },
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
"version" : "1.0.2"
}
},
{ {
"identity" : "swift-case-paths", "identity" : "swift-case-paths",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -68,8 +86,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git", "location" : "https://github.com/apple/swift-nio-http2.git",
"state" : { "state" : {
"revision" : "72bcaf607b40d7c51044f65b0f5ed8581a911832", "revision" : "108ac15087ea9b79abb6f6742699cf31de0e8772",
"version" : "1.21.0" "version" : "1.22.0"
} }
}, },
{ {
@@ -90,6 +108,15 @@
"version" : "1.12.0" "version" : "1.12.0"
} }
}, },
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics",
"state" : {
"revision" : "0a5bc04095a675662cf24757cc0640aa2204253b",
"version" : "1.0.2"
}
},
{ {
"identity" : "swift-parsing", "identity" : "swift-parsing",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
+4 -2
View File
@@ -1,4 +1,4 @@
// swift-tools-version:5.6 // swift-tools-version:5.7
import PackageDescription import PackageDescription
let package = Package( let package = Package(
@@ -13,10 +13,12 @@ let package = Package(
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"),
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"), .package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
.package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.9.2"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.9.2"),
.package(url: "https://github.com/swift-server/async-http-client", from: "1.10.0"), .package(url: "https://github.com/swift-server/async-http-client", from: "1.11.4"),
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
], ],
targets: [ targets: [
.executableTarget(name: "tart", dependencies: [ .executableTarget(name: "tart", dependencies: [
.product(name: "Algorithms", package: "swift-algorithms"),
.product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "AsyncHTTPClient", package: "async-http-client"), .product(name: "AsyncHTTPClient", package: "async-http-client"),
.product(name: "Dynamic", package: "Dynamic"), .product(name: "Dynamic", package: "Dynamic"),
+25
View File
@@ -55,6 +55,31 @@ config from above will just work in Cirrus CI and your tasks will be executed in
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos). **Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
### Retrieving artifacts from within Tart VMs
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tasks/#artifacts-instruction):
```yaml
task:
name: Build
macos_instance:
image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
build_script: swift build --product tart
binary_artifacts:
path: .build/debug/tart
```
Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
```bash
cirrus run --artifacts-dir artifacts
```
Note that all retrieved artifacts will be prefixed with the associated task name and `artifacts` instruction name.
For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/binary/.build/debug/tart`.
## Virtual Machine Management ## Virtual Machine Management
### Creating from scratch ### Creating from scratch
+11 -5
View File
@@ -9,13 +9,16 @@ struct Login: AsyncParsableCommand {
var host: String var host: String
@Option(help: "username") @Option(help: "username")
var username: String var username: String?
@Flag(help: "password-stdin") @Flag(help: "password-stdin")
var passwordStdin: Bool = false var passwordStdin: Bool = false
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
func validate() throws { func validate() throws {
let usernameProvided = !username.isEmpty let usernameProvided = username != nil
let passwordProvided = passwordStdin let passwordProvided = passwordStdin
if usernameProvided != passwordProvided { if usernameProvided != passwordProvided {
@@ -28,9 +31,11 @@ struct Login: AsyncParsableCommand {
var user: String var user: String
var password: String var password: String
if !username.isEmpty { if let username = username {
user = username user = username
password = readLine()!
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
password = String(decoding: passwordData, as: UTF8.self)
} else { } else {
(user, password) = try StdinCredentials.retrieve() (user, password) = try StdinCredentials.retrieve()
} }
@@ -39,7 +44,8 @@ struct Login: AsyncParsableCommand {
]) ])
do { do {
let registry = try Registry(host: host, namespace: "", credentialsProvider: credentialsProvider) let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProvider: credentialsProvider)
try await registry.ping() try await registry.ping()
} catch { } catch {
print("invalid credentials: \(error)") print("invalid credentials: \(error)")
+18 -2
View File
@@ -12,6 +12,17 @@ struct Push: AsyncParsableCommand {
@Argument(help: "remote VM name(s)") @Argument(help: "remote VM name(s)")
var remoteNames: [String] var remoteNames: [String]
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
discussion: """
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
For example, AWS Elastic Container Registry supports only chunks larger than 5MB but GitHub Container Registry supports only chunks smaller than 4MB. Google Container Registry on the other hand doesn't support chunked uploads at all.
Please refer to the documentation of your particular registry in order to see if this option is suitable for you and what's the recommended chunk size.
"""))
var chunkSize: Int = 0
@Flag(help: ArgumentHelp("cache pushed images locally", @Flag(help: ArgumentHelp("cache pushed images locally",
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later.")) discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
var populateCache: Bool = false var populateCache: Bool = false
@@ -37,12 +48,17 @@ struct Push: AsyncParsableCommand {
// Push VM // Push VM
for (registryIdentifier, remoteNamesForRegistry) in registryGroups { for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace) let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
insecure: insecure)
defaultLogger.appendNewLine("pushing \(localName) to " defaultLogger.appendNewLine("pushing \(localName) to "
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...") + "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
let pushedRemoteName = try await localVMDir.pushToRegistry(registry: registry, references: remoteNamesForRegistry.map{ $0.reference.value }) let pushedRemoteName = try await localVMDir.pushToRegistry(
registry: registry,
references: remoteNamesForRegistry.map{ $0.reference.value },
chunkSizeMb: chunkSize
)
// Populate the local cache (if requested) // Populate the local cache (if requested)
if populateCache { if populateCache {
+70 -21
View File
@@ -24,51 +24,79 @@ struct Run: AsyncParsableCommand {
@Flag(help: ArgumentHelp( @Flag(help: ArgumentHelp(
"Use screen sharing instead of the built-in UI.", "Use screen sharing instead of the built-in UI.",
discussion: "Useful since VNC supports copy/paste, drag and drop, etc.\nNote that Remote Login option should be enabled inside the VM.")) discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
+ "Note that Remote Login option should be enabled inside the VM."))
var vnc: Bool = false var vnc: Bool = false
@Flag(help: ArgumentHelp(
"Use Virtualization.Framework's VNC server instead of the build-in UI.",
discussion: "Useful since this type of VNC is available in recovery mode and in macOS installation.\n"
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
var vncExperimental: Bool = false
@Flag var withSoftnet: Bool = false @Flag var withSoftnet: Bool = false
@MainActor @Option(help: ArgumentHelp("""
func run() async throws { Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"disk.bin:ro\")
let vmDir = try VMStorageLocal().open(name) """, discussion: """
vm = try VM(vmDir: vmDir, withSoftnet: withSoftnet) Learn how to create a disk image using Disk Utility here:
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
"""))
var disk: [String] = []
var vncWrapper: VNCWrapper? func validate() throws {
if vnc && vncExperimental {
if vnc { throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
vncWrapper = VNCWrapper(virtualMachine: vm!.virtualMachine)
} }
}
@MainActor
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
vm = try VM(
vmDir: vmDir,
withSoftnet: withSoftnet,
additionalDiskAttachments: additionalDiskAttachments()
)
let vncImpl: VNC? = try {
if vnc {
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
return ScreenSharingVNC(vmConfig: vmConfig)
} else if vncExperimental {
return FullFledgedVNC(virtualMachine: vm!.virtualMachine)
} else {
return nil
}
}()
let task = Task { let task = Task {
do { do {
if let vncWrapper = vncWrapper { if let vncImpl = vncImpl {
let port = try await vncWrapper.waitForPort() let vncURL = try await vncImpl.waitForURL()
let url = URL(string: "vnc://:\(vncWrapper.password)@127.0.0.1:\(port)")!
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil { if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
print("VNC server is running at \(url)") print("VNC server is running at \(vncURL)")
} else { } else {
print("Opening \(url)...") print("Opening \(vncURL)...")
NSWorkspace.shared.open(url) NSWorkspace.shared.open(vncURL)
} }
} }
try await vm!.run(recovery) try await vm!.run(recovery)
if let vncWrapper = vncWrapper { if let vncImpl = vncImpl {
try vncWrapper.stop() try vncImpl.stop()
} }
Foundation.exit(0) Foundation.exit(0)
} catch { } catch {
if error.localizedDescription.contains("Failed to lock auxiliary storage.") { if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
print("Virtual machine \"\(name)\" is already running!") print("Virtual machine \"\(name)\" is already running!")
} else { Foundation.exit(2)
print(error)
} }
print(error)
Foundation.exit(1) Foundation.exit(1)
} }
} }
@@ -79,13 +107,34 @@ struct Run: AsyncParsableCommand {
} }
sigintSrc.activate() sigintSrc.activate()
if noGraphics || vnc { if noGraphics || vnc || vncExperimental {
dispatchMain() dispatchMain()
} else { } else {
runUI() runUI()
} }
} }
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
var result: [VZDiskImageStorageDeviceAttachment] = []
let readOnlySuffix = ":ro"
for rawDisk in disk {
if rawDisk.hasSuffix(readOnlySuffix) {
result.append(try VZDiskImageStorageDeviceAttachment(
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
readOnly: true
))
} else {
result.append(try VZDiskImageStorageDeviceAttachment(
url: URL(fileURLWithPath: rawDisk),
readOnly: false
))
}
}
return result
}
private func runUI() { private func runUI() {
let nsApp = NSApplication.shared let nsApp = NSApplication.shared
nsApp.setActivationPolicy(.regular) nsApp.setActivationPolicy(.regular)
+16 -4
View File
@@ -1,11 +1,23 @@
import Foundation import Foundation
struct Config { struct Config {
public static let tartHomeDir: URL = FileManager.default let tartHomeDir: URL
.homeDirectoryForCurrentUser let tartCacheDir: URL
.appendingPathComponent(".tart", isDirectory: true)
public static let tartCacheDir: URL = tartHomeDir.appendingPathComponent("cache", isDirectory: true) init() {
var tartHomeDir: URL
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
tartHomeDir = URL(fileURLWithPath: customTartHome)
} else {
tartHomeDir = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".tart", isDirectory: true)
}
self.tartHomeDir = tartHomeDir
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
}
static func jsonEncoder() -> JSONEncoder { static func jsonEncoder() -> JSONEncoder {
let encoder = JSONEncoder() let encoder = JSONEncoder()
@@ -34,21 +34,37 @@ class KeychainCredentialsProvider: CredentialsProvider {
} }
func store(host: String, user: String, password: String) throws { func store(host: String, user: String, password: String) throws {
let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword, let passwordData = password.data(using: .utf8)
kSecAttrAccount as String: user, let key: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS, kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host, kSecAttrServer as String: host,
kSecValueData as String: password, kSecAttrLabel as String: "Tart Credentials",
kSecAttrLabel as String: "Tart Credentials", ]
let value: [String: Any] = [kSecAttrAccount as String: user,
kSecValueData as String: passwordData,
] ]
let status = SecItemAdd(attributes as CFDictionary, nil) let status = SecItemCopyMatching(key as CFDictionary, nil)
switch status { switch status {
case errSecSuccess, errSecDuplicateItem: case errSecItemNotFound:
return let status = SecItemAdd(key.merging(value) { (current, _) in current } as CFDictionary, nil)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to add item: \(status.explanation())")
}
case errSecSuccess:
let status = SecItemUpdate(key as CFDictionary, value as CFDictionary)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
}
default: default:
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)") throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
} }
} }
} }
extension OSStatus {
func explanation() -> CFString {
SecCopyErrorMessageString(self, nil) ?? "Unknown status code \(self)." as CFString
}
}
+21
View File
@@ -0,0 +1,21 @@
import Foundation
protocol Authentication {
func header() -> (String, String)
func isValid() -> Bool
}
struct BasicAuthentication: Authentication {
let user: String
let password: String
func header() -> (String, String) {
let creds = Data("\(user):\(password)".utf8).base64EncodedString()
return ("Authorization", "Basic \(creds)")
}
func isValid() -> Bool {
true
}
}
+93 -48
View File
@@ -2,6 +2,8 @@ import Foundation
import NIOCore import NIOCore
import NIOHTTP1 import NIOHTTP1
import AsyncHTTPClient import AsyncHTTPClient
import Algorithms
import NIOPosix
enum RegistryError: Error { enum RegistryError: Error {
case UnexpectedHTTPStatusCode(when: String, code: UInt, details: String = "") case UnexpectedHTTPStatusCode(when: String, code: UInt, details: String = "")
@@ -25,7 +27,7 @@ extension HTTPClientResponse.Body {
} }
} }
struct TokenResponse: Decodable { struct TokenResponse: Decodable, Authentication {
let defaultIssuedAt = Date() let defaultIssuedAt = Date()
let defaultExpiresIn = 60 let defaultExpiresIn = 60
@@ -38,15 +40,16 @@ struct TokenResponse: Decodable {
decoder.keyDecodingStrategy = .convertFromSnakeCase decoder.keyDecodingStrategy = .convertFromSnakeCase
// RFC3339 date formatter from Apple's documentation[1] let dateFormatter = ISO8601DateFormatter()
// dateFormatter.formatOptions = [.withInternetDateTime]
// [1]: https://developer.apple.com/documentation/foundation/dateformatter
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
decoder.dateDecodingStrategy = .formatted(dateFormatter) decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
return dateFormatter.date(from: dateString) ?? Date()
}
return try decoder.decode(TokenResponse.self, from: fromData) return try decoder.decode(TokenResponse.self, from: fromData)
} }
@@ -65,25 +68,29 @@ struct TokenResponse: Decodable {
} }
} }
var isValid: Bool { func header() -> (String, String) {
get { ("Authorization", "Bearer \(token)")
Date() < tokenExpiresAt }
}
func isValid() -> Bool {
Date() < tokenExpiresAt
} }
} }
class Registry { class Registry {
private let httpClient = HTTPClient(eventLoopGroupProvider: .createNew) private let httpClient = HTTPClient(
eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup(numberOfThreads: 1))
)
deinit { deinit {
try! httpClient.syncShutdown() try! httpClient.syncShutdown()
} }
let baseURL: URL let baseURL: URL
let namespace: String let namespace: String
let credentialsProvider: CredentialsProvider let credentialsProvider: CredentialsProvider
var currentAuthToken: TokenResponse? = nil var currentAuthToken: Authentication? = nil
init(urlComponents: URLComponents, init(urlComponents: URLComponents,
namespace: String, namespace: String,
@@ -95,15 +102,13 @@ class Registry {
} }
convenience init( convenience init(
host: String, host: String,
namespace: String, namespace: String,
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider() insecure: Bool = false,
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider()
) throws { ) throws {
var baseURLComponents = URLComponents() let proto = insecure ? "http" : "https"
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
baseURLComponents.scheme = "https"
baseURLComponents.host = host
baseURLComponents.path = "/v2/"
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProvider: credentialsProvider) try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProvider: credentialsProvider)
} }
@@ -156,7 +161,7 @@ class Registry {
return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)! return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)!
} }
public func pushBlob(fromData: Data, chunkSize: Int = 5 * 1024 * 1024) async throws -> String { public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
// Initiate a blob upload // Initiate a blob upload
let postResponse = try await endpointRequest(.POST, "\(namespace)/blobs/uploads/", let postResponse = try await endpointRequest(.POST, "\(namespace)/blobs/uploads/",
headers: ["Content-Length": "0"]) headers: ["Content-Length": "0"])
@@ -167,27 +172,55 @@ class Registry {
} }
// Figure out where to upload the blob // Figure out where to upload the blob
let uploadLocation = try uploadLocationFromResponse(postResponse) var uploadLocation = try uploadLocationFromResponse(postResponse)
// Upload the blob
let headers = [
"Content-Length": "\(fromData.count)",
"Content-Type": "application/octet-stream",
]
let digest = Digest.hash(fromData) let digest = Digest.hash(fromData)
let parameters = [
"digest": digest, if chunkSizeMb == 0 {
] // monolithic upload
let response = try await rawRequest(
let putResponse = try await rawRequest(.PUT, uploadLocation, headers: headers, parameters: parameters, .PUT,
body: fromData) uploadLocation,
if putResponse.status != .created { headers: [
let body = try await postResponse.body.readTextResponse() "Content-Type": "application/octet-stream",
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)", ],
code: putResponse.status.code, details: body ?? "") parameters: ["digest": digest],
body: fromData
)
if response.status != .created {
let body = try await response.body.readTextResponse()
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
code: response.status.code, details: body ?? "")
}
return digest
} }
// chunked upload
var uploadedBytes = 0
let chunks = fromData.chunks(ofCount: chunkSizeMb == 0 ? fromData.count : chunkSizeMb * 1_000_000)
for (index, chunk) in chunks.enumerated() {
let lastChunk = index == (chunks.count - 1)
let response = try await rawRequest(
lastChunk ? .PUT : .PATCH,
uploadLocation,
headers: [
"Content-Type": "application/octet-stream",
"Content-Range": "\(uploadedBytes)-\(uploadedBytes + chunk.count - 1)",
],
parameters: lastChunk ? ["digest": digest] : [:],
body: chunk
)
let expectedStatus: HTTPResponseStatus = lastChunk ? .created : .accepted
if response.status != expectedStatus {
let body = try await response.body.readTextResponse()
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
code: response.status.code, details: body ?? "")
}
uploadedBytes += chunk.count
// Update location for the next chunk
uploadLocation = try uploadLocationFromResponse(response)
}
return digest return digest
} }
@@ -224,11 +257,12 @@ class Registry {
_ urlComponents: URLComponents, _ urlComponents: URLComponents,
headers: Dictionary<String, String> = Dictionary(), headers: Dictionary<String, String> = Dictionary(),
parameters: Dictionary<String, String> = Dictionary(), parameters: Dictionary<String, String> = Dictionary(),
body: Data? = nil body: Data? = nil,
doAuth: Bool = true
) async throws -> HTTPClientResponse { ) async throws -> HTTPClientResponse {
var urlComponents = urlComponents var urlComponents = urlComponents
if urlComponents.queryItems == nil { if urlComponents.queryItems == nil && !parameters.isEmpty {
urlComponents.queryItems = [] urlComponents.queryItems = []
} }
urlComponents.queryItems?.append(contentsOf: parameters.map { key, value -> URLQueryItem in urlComponents.queryItems?.append(contentsOf: parameters.map { key, value -> URLQueryItem in
@@ -241,17 +275,18 @@ class Registry {
request.headers.add(name: key, value: value) request.headers.add(name: key, value: value)
} }
if body != nil { if body != nil {
request.headers.add(name: "Content-Length", value: "\(body!.count)")
request.body = HTTPClientRequest.Body.bytes(body!) request.body = HTTPClientRequest.Body.bytes(body!)
} }
// Invalidate token if it has expired // Invalidate token if it has expired
if currentAuthToken?.isValid == false { if currentAuthToken?.isValid() == false {
currentAuthToken = nil currentAuthToken = nil
} }
var response = try await authAwareRequest(request: request) var response = try await authAwareRequest(request: request)
if response.status == .unauthorized { if doAuth && response.status == .unauthorized {
try await auth(response: response) try await auth(response: response)
response = try await authAwareRequest(request: request) response = try await authAwareRequest(request: request)
} }
@@ -266,6 +301,15 @@ class Registry {
} }
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw) let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
if wwwAuthenticate.scheme == "Basic" {
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) {
currentAuthToken = BasicAuthentication(user: user, password: password)
}
return
}
if wwwAuthenticate.scheme != "Bearer" { if wwwAuthenticate.scheme != "Bearer" {
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme " throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme") + "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
@@ -301,7 +345,7 @@ class Registry {
headers["Authorization"] = "Basic \(encodedCredentials!)" headers["Authorization"] = "Basic \(encodedCredentials!)"
} }
let response = try await rawRequest(.GET, authenticateURL, headers: headers) let response = try await rawRequest(.GET, authenticateURL, headers: headers, doAuth: false)
if response.status != .ok { if response.status != .ok {
let body = try await response.body.readTextResponse() ?? "" let body = try await response.body.readTextResponse() ?? ""
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.status.code) " throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.status.code) "
@@ -316,7 +360,8 @@ class Registry {
var request = request var request = request
if let token = currentAuthToken { if let token = currentAuthToken {
request.headers.add(name: "Authorization", value: "Bearer \(token.token)") let (name, value) = token.header()
request.headers.add(name: name, value: value)
} }
return try await httpClient.execute(request, deadline: .distantFuture) return try await httpClient.execute(request, deadline: .distantFuture)
+1
View File
@@ -5,6 +5,7 @@ import Dynamic
// Kudos to @saagarjha's VirtualApple for finding about _VZVirtualMachineStartOptions // Kudos to @saagarjha's VirtualApple for finding about _VZVirtualMachineStartOptions
extension VZVirtualMachine { extension VZVirtualMachine {
@available(macOS 12, *)
func start(_ recovery: Bool) async throws { func start(_ recovery: Bool) async throws {
if !recovery { if !recovery {
// just use the regular API // just use the regular API
+37 -11
View File
@@ -25,7 +25,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
var softnet: Softnet? = nil var softnet: Softnet? = nil
init(vmDir: VMDirectory, withSoftnet: Bool = false) throws { init(vmDir: VMDirectory,
withSoftnet: Bool = false,
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
) throws {
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL) let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
name = vmDir.name name = vmDir.name
@@ -37,7 +40,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config, let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
softnet: softnet) softnet: softnet, additionalDiskAttachments: additionalDiskAttachments)
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
super.init() super.init()
@@ -53,7 +56,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
let ipswCacheFolder = Config.tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true) let ipswCacheFolder = Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
try FileManager.default.createDirectory(at: ipswCacheFolder, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: ipswCacheFolder, withIntermediateDirectories: true)
let expectedIPSWLocation = ipswCacheFolder.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false) let expectedIPSWLocation = ipswCacheFolder.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false)
@@ -94,7 +97,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
} }
init(vmDir: VMDirectory, ipswURL: URL?, diskSizeGB: UInt16, withSoftnet: Bool = false) async throws { init(
vmDir: VMDirectory,
ipswURL: URL?,
diskSizeGB: UInt16,
withSoftnet: Bool = false,
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
) async throws {
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW(); let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
// Load the restore image and try to get the requirements // Load the restore image and try to get the requirements
@@ -132,7 +141,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config, let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
softnet: softnet) softnet: softnet, additionalDiskAttachments: additionalDiskAttachments)
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
super.init() super.init()
@@ -158,7 +167,19 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
try softnet.run() try softnet.run()
} }
try await virtualMachine.start(recovery) DispatchQueue.main.sync {
Task {
if #available(macOS 13, *) {
// new API introduced in Ventura
let startOptions = VZMacOSVirtualMachineStartOptions()
startOptions.startUpFromMacOSRecovery = recovery
try await virtualMachine.start(options: startOptions)
} else {
// use method that also available on Monterey
try await virtualMachine.start(recovery)
}
}
}
await withTaskCancellationHandler(operation: { await withTaskCancellationHandler(operation: {
sema.wait() sema.wait()
@@ -183,7 +204,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
diskURL: URL, diskURL: URL,
auxStorage: VZMacAuxiliaryStorage, auxStorage: VZMacAuxiliaryStorage,
vmConfig: VMConfig, vmConfig: VMConfig,
softnet: Softnet? = nil softnet: Softnet? = nil,
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment]
) throws -> VZVirtualMachineConfiguration { ) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration() let configuration = VZVirtualMachineConfiguration()
@@ -227,7 +249,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Audio // Audio
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration() let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
soundDeviceConfiguration.streams = [VZVirtioSoundDeviceInputStreamConfiguration(), VZVirtioSoundDeviceOutputStreamConfiguration()] let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
configuration.audioDevices = [soundDeviceConfiguration] configuration.audioDevices = [soundDeviceConfiguration]
// Keyboard and mouse // Keyboard and mouse
@@ -247,9 +273,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
configuration.networkDevices = [vio] configuration.networkDevices = [vio]
// Storage // Storage
let attachment = try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false) var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
let storage = VZVirtioBlockDeviceConfiguration(attachment: attachment) attachments.append(contentsOf: additionalDiskAttachments)
configuration.storageDevices = [storage] configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
// Entropy // Entropy
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()] configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
+8 -7
View File
@@ -98,13 +98,14 @@ extension VMDirectory {
try nvram.close() try nvram.close()
} }
func pushToRegistry(registry: Registry, references: [String]) async throws -> RemoteName { func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int) async throws -> RemoteName {
var layers = Array<OCIManifestLayer>() var layers = Array<OCIManifestLayer>()
// Read VM's config and push it as blob // Read VM's config and push it as blob
let config = try VMConfig(fromURL: configURL) let config = try VMConfig(fromURL: configURL)
let configJSON = try JSONEncoder().encode(config) let configJSON = try JSONEncoder().encode(config)
let configDigest = try await registry.pushBlob(fromData: configJSON) defaultLogger.appendNewLine("pushing config...")
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
layers.append(OCIManifestLayer(mediaType: Self.configMediaType, size: configJSON.count, digest: configDigest)) layers.append(OCIManifestLayer(mediaType: Self.configMediaType, size: configJSON.count, digest: configDigest))
// Progress // Progress
@@ -126,21 +127,21 @@ extension VMDirectory {
return data return data
} }
while let chunk = try compressingFilter.readData(ofLength: Self.layerLimitBytes) { while let compressedLayerData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
let chunkDigest = try await registry.pushBlob(fromData: chunk) let layerDigest = try await registry.pushBlob(fromData: compressedLayerData, chunkSizeMb: chunkSizeMb)
layers.append(OCIManifestLayer(mediaType: Self.diskMediaType, size: chunk.count, digest: chunkDigest)) layers.append(OCIManifestLayer(mediaType: Self.diskMediaType, size: compressedLayerData.count, digest: layerDigest))
} }
// Read VM's NVRAM and push it as blob // Read VM's NVRAM and push it as blob
defaultLogger.appendNewLine("pushing NVRAM...") defaultLogger.appendNewLine("pushing NVRAM...")
let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()! let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()!
let nvramDigest = try await registry.pushBlob(fromData: nvram) let nvramDigest = try await registry.pushBlob(fromData: nvram, chunkSizeMb: chunkSizeMb)
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest)) layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
// Craft a stub OCI config for Docker Hub compatibility // Craft a stub OCI config for Docker Hub compatibility
let ociConfigJSON = try OCIConfig().toJSON() let ociConfigJSON = try OCIConfig().toJSON()
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON) let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
let manifest = OCIManifest( let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest), config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
layers: layers, layers: layers,
+1 -1
View File
@@ -1,7 +1,7 @@
import Foundation import Foundation
class VMStorageLocal { class VMStorageLocal {
let baseURL: URL = Config.tartHomeDir.appendingPathComponent("vms", isDirectory: true) let baseURL: URL = Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
private func vmURL(_ name: String) -> URL { private func vmURL(_ name: String) -> URL {
baseURL.appendingPathComponent(name, isDirectory: true) baseURL.appendingPathComponent(name, isDirectory: true)
+1 -1
View File
@@ -1,7 +1,7 @@
import Foundation import Foundation
class VMStorageOCI { class VMStorageOCI {
let baseURL = Config.tartCacheDir.appendingPathComponent("OCIs", isDirectory: true) let baseURL = Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
private func vmURL(_ name: RemoteName) -> URL { private func vmURL(_ name: RemoteName) -> URL {
baseURL.appendingRemoteName(name) baseURL.appendingRemoteName(name)
@@ -2,7 +2,7 @@ import Foundation
import Dynamic import Dynamic
import Virtualization import Virtualization
class VNCWrapper { class FullFledgedVNC: VNC {
let password: String let password: String
private let vnc: Dynamic private let vnc: Dynamic
@@ -15,6 +15,19 @@ class VNCWrapper {
vnc.start() vnc.start()
} }
func waitForURL() async throws -> URL {
while true {
// Port is 0 shortly after start(),
// but will be initialized later
if let port = vnc.port.asUInt16, port != 0 {
return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
}
// Wait 50 ms.
try await Task.sleep(nanoseconds: 50_000_000)
}
}
func stop() throws { func stop() throws {
vnc.stop() vnc.stop()
} }
@@ -22,17 +35,4 @@ class VNCWrapper {
deinit { deinit {
try? stop() try? stop()
} }
func waitForPort() async throws -> UInt16 {
while true {
// Port is 0 shortly after start(),
// but will be initialized later
if let port = vnc.port.asUInt16, port != 0 {
return port
}
// Wait 50 ms.
try await Task.sleep(nanoseconds: 50_000_000)
}
}
} }
+25
View File
@@ -0,0 +1,25 @@
import Foundation
import Dynamic
import Virtualization
class ScreenSharingVNC: VNC {
let vmConfig: VMConfig
init(vmConfig: VMConfig) {
self.vmConfig = vmConfig
}
func waitForURL() async throws -> URL {
let ip = try await IP.resolveIP(vmConfig, secondsToWait: 60)
if let ip = ip {
return URL(string: "vnc://\(ip)")!
}
throw IPNotFound()
}
func stop() throws {
// nothing to do
}
}
+6
View File
@@ -0,0 +1,6 @@
import Foundation
protocol VNC {
func waitForURL() async throws -> URL
func stop() throws
}
+2 -2
View File
@@ -42,13 +42,13 @@ final class RegistryTests: XCTestCase {
XCTAssertEqual(pushedBlob, pulledBlob) XCTAssertEqual(pushedBlob, pulledBlob)
} }
func testPushPullBlobHuge() async throws { func testPushPullBlobHugeInChunks() async throws {
// Generate a large enough blob // Generate a large enough blob
let fh = FileHandle(forReadingAtPath: "/dev/urandom")! let fh = FileHandle(forReadingAtPath: "/dev/urandom")!
let largeBlobToPush = try fh.read(upToCount: 768 * 1024 * 1024)! let largeBlobToPush = try fh.read(upToCount: 768 * 1024 * 1024)!
// Push it // Push it
let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush) let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush, chunkSizeMb: 10)
// Pull it // Pull it
var pulledLargeBlob = Data() var pulledLargeBlob = Data()
+4 -4
View File
@@ -11,7 +11,7 @@ final class TokenResponseTests: XCTestCase {
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60) let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt)) XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
XCTAssertTrue(tokenResponse.isValid) XCTAssertTrue(tokenResponse.isValid())
} }
func testExpirationBasic() throws { func testExpirationBasic() throws {
@@ -23,9 +23,9 @@ final class TokenResponseTests: XCTestCase {
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2) let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt)) XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
XCTAssertTrue(tokenResponse.isValid) XCTAssertTrue(tokenResponse.isValid())
_ = XCTWaiter.wait(for: [expectation(description: "Wait 3 seconds for the token to become invalid")], timeout: 2) _ = XCTWaiter.wait(for: [expectation(description: "Wait 3 seconds for the token to become invalid")], timeout: 2)
XCTAssertFalse(tokenResponse.isValid) XCTAssertFalse(tokenResponse.isValid())
} }
func testExpirationWithIssuedAt() throws { func testExpirationWithIssuedAt() throws {
@@ -33,6 +33,6 @@ final class TokenResponseTests: XCTestCase {
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw) let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
XCTAssertEqual(Date(timeIntervalSince1970: 3600), tokenResponse.tokenExpiresAt) XCTAssertEqual(Date(timeIntervalSince1970: 3600), tokenResponse.tokenExpiresAt)
XCTAssertFalse(tokenResponse.isValid) XCTAssertFalse(tokenResponse.isValid())
} }
} }