mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-10 16:05:35 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a80954c888 | ||
|
|
cc8201dee6 | ||
|
|
2cab49b3f1 | ||
|
|
131827802a | ||
|
|
e2b7f12388 | ||
|
|
617a5d02dc | ||
|
|
b83bce4544 | ||
|
|
7d16516b6a | ||
|
|
56ddd8df75 | ||
|
|
b1534a05d5 | ||
|
|
f54e7c2cab | ||
|
|
85dfa961c9 | ||
|
|
3a74fc35e6 | ||
|
|
7bf7f890c4 |
+7
-1
@@ -32,7 +32,7 @@ brews:
|
||||
tap:
|
||||
owner: cirruslabs
|
||||
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
|
||||
description: Run macOS VMs on Apple Silicon
|
||||
skip_upload: auto
|
||||
@@ -40,3 +40,9 @@ brews:
|
||||
- "cirruslabs/cli/softnet"
|
||||
custom_block: |
|
||||
depends_on :macos => :monterey
|
||||
|
||||
on_macos do
|
||||
unless Hardware::CPU.arm?
|
||||
odie "Tart only works on Apple Silicon!"
|
||||
end
|
||||
end
|
||||
|
||||
+31
-4
@@ -5,8 +5,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swift-server/async-http-client",
|
||||
"state" : {
|
||||
"revision" : "24425989dadab6d6e4167174791a23d4e2a6d0c3",
|
||||
"version" : "1.10.0"
|
||||
"revision" : "df87a860fdc41a595d5ca67f74cde9adbccc099a",
|
||||
"version" : "1.11.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -18,6 +18,15 @@
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -27,6 +36,15 @@
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -68,8 +86,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-http2.git",
|
||||
"state" : {
|
||||
"revision" : "72bcaf607b40d7c51044f65b0f5ed8581a911832",
|
||||
"version" : "1.21.0"
|
||||
"revision" : "108ac15087ea9b79abb6f6742699cf31de0e8772",
|
||||
"version" : "1.22.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -90,6 +108,15 @@
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
+4
-2
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version:5.6
|
||||
// swift-tools-version:5.7
|
||||
|
||||
import PackageDescription
|
||||
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/mhdhejazi/Dynamic", branch: "master"),
|
||||
.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: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
.product(name: "Algorithms", package: "swift-algorithms"),
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "AsyncHTTPClient", package: "async-http-client"),
|
||||
.product(name: "Dynamic", package: "Dynamic"),
|
||||
|
||||
@@ -14,6 +14,9 @@ struct Login: AsyncParsableCommand {
|
||||
@Flag(help: "password-stdin")
|
||||
var passwordStdin: Bool = false
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
let usernameProvided = username != nil
|
||||
let passwordProvided = passwordStdin
|
||||
@@ -30,7 +33,9 @@ struct Login: AsyncParsableCommand {
|
||||
|
||||
if let username = username {
|
||||
user = username
|
||||
password = readLine()!
|
||||
|
||||
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
||||
password = String(decoding: passwordData, as: UTF8.self)
|
||||
} else {
|
||||
(user, password) = try StdinCredentials.retrieve()
|
||||
}
|
||||
@@ -39,7 +44,8 @@ struct Login: AsyncParsableCommand {
|
||||
])
|
||||
|
||||
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()
|
||||
} catch {
|
||||
print("invalid credentials: \(error)")
|
||||
|
||||
@@ -12,6 +12,17 @@ struct Push: AsyncParsableCommand {
|
||||
@Argument(help: "remote VM name(s)")
|
||||
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",
|
||||
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
|
||||
var populateCache: Bool = false
|
||||
@@ -37,12 +48,17 @@ struct Push: AsyncParsableCommand {
|
||||
|
||||
// Push VM
|
||||
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 "
|
||||
+ "\(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)
|
||||
if populateCache {
|
||||
|
||||
@@ -36,6 +36,14 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
@Flag var withSoftnet: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"disk.bin:ro\")
|
||||
""", discussion: """
|
||||
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] = []
|
||||
|
||||
func validate() throws {
|
||||
if vnc && vncExperimental {
|
||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||
@@ -43,9 +51,13 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
vm = try VM(vmDir: vmDir, withSoftnet: withSoftnet)
|
||||
vm = try VM(
|
||||
vmDir: vmDir,
|
||||
withSoftnet: withSoftnet,
|
||||
additionalDiskAttachments: additionalDiskAttachments()
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
if vnc {
|
||||
@@ -81,10 +93,10 @@ struct Run: AsyncParsableCommand {
|
||||
} catch {
|
||||
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
|
||||
print("Virtual machine \"\(name)\" is already running!")
|
||||
} else {
|
||||
print(error)
|
||||
Foundation.exit(2)
|
||||
}
|
||||
|
||||
print(error)
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -102,6 +114,27 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
struct Config {
|
||||
public static let tartHomeDir: URL = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".tart", isDirectory: true)
|
||||
let tartHomeDir: URL
|
||||
let tartCacheDir: URL
|
||||
|
||||
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 {
|
||||
let encoder = JSONEncoder()
|
||||
|
||||
@@ -35,27 +35,30 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
||||
|
||||
func store(host: String, user: String, password: String) throws {
|
||||
let passwordData = password.data(using: .utf8)
|
||||
let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||
kSecAttrAccount as String: user,
|
||||
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
||||
kSecAttrServer as String: host,
|
||||
kSecValueData as String: passwordData,
|
||||
kSecAttrLabel as String: "Tart Credentials",
|
||||
let key: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
||||
kSecAttrServer as String: host,
|
||||
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 {
|
||||
case errSecItemNotFound:
|
||||
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:
|
||||
return
|
||||
case errSecDuplicateItem:
|
||||
let status = SecItemUpdate(attributes as CFDictionary,
|
||||
[kSecValueData as String : passwordData] as CFDictionary)
|
||||
let status = SecItemUpdate(key as CFDictionary, value as CFDictionary)
|
||||
if status != errSecSuccess {
|
||||
throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
|
||||
}
|
||||
default:
|
||||
throw CredentialsProviderError.Failed(message: "Keychain failed to add item: \(status.explanation())")
|
||||
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import Foundation
|
||||
import NIOCore
|
||||
import NIOHTTP1
|
||||
import AsyncHTTPClient
|
||||
import Algorithms
|
||||
import NIOPosix
|
||||
|
||||
enum RegistryError: Error {
|
||||
case UnexpectedHTTPStatusCode(when: String, code: UInt, details: String = "")
|
||||
@@ -38,15 +40,16 @@ struct TokenResponse: Decodable, Authentication {
|
||||
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
// RFC3339 date formatter from Apple's documentation[1]
|
||||
//
|
||||
// [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"
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
dateFormatter.formatOptions = [.withInternetDateTime]
|
||||
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)
|
||||
}
|
||||
@@ -75,12 +78,14 @@ struct TokenResponse: Decodable, Authentication {
|
||||
}
|
||||
|
||||
class Registry {
|
||||
private let httpClient = HTTPClient(eventLoopGroupProvider: .createNew)
|
||||
|
||||
private let httpClient = HTTPClient(
|
||||
eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup(numberOfThreads: 1))
|
||||
)
|
||||
|
||||
deinit {
|
||||
try! httpClient.syncShutdown()
|
||||
}
|
||||
|
||||
|
||||
let baseURL: URL
|
||||
let namespace: String
|
||||
let credentialsProvider: CredentialsProvider
|
||||
@@ -97,15 +102,13 @@ class Registry {
|
||||
}
|
||||
|
||||
convenience init(
|
||||
host: String,
|
||||
namespace: String,
|
||||
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider()
|
||||
host: String,
|
||||
namespace: String,
|
||||
insecure: Bool = false,
|
||||
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider()
|
||||
) throws {
|
||||
var baseURLComponents = URLComponents()
|
||||
|
||||
baseURLComponents.scheme = "https"
|
||||
baseURLComponents.host = host
|
||||
baseURLComponents.path = "/v2/"
|
||||
let proto = insecure ? "http" : "https"
|
||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||
|
||||
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProvider: credentialsProvider)
|
||||
}
|
||||
@@ -158,7 +161,7 @@ class Registry {
|
||||
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
|
||||
let postResponse = try await endpointRequest(.POST, "\(namespace)/blobs/uploads/",
|
||||
headers: ["Content-Length": "0"])
|
||||
@@ -169,27 +172,55 @@ class Registry {
|
||||
}
|
||||
|
||||
// Figure out where to upload the blob
|
||||
let uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||
|
||||
// Upload the blob
|
||||
let headers = [
|
||||
"Content-Length": "\(fromData.count)",
|
||||
"Content-Type": "application/octet-stream",
|
||||
]
|
||||
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||
|
||||
let digest = Digest.hash(fromData)
|
||||
let parameters = [
|
||||
"digest": digest,
|
||||
]
|
||||
|
||||
let putResponse = try await rawRequest(.PUT, uploadLocation, headers: headers, parameters: parameters,
|
||||
body: fromData)
|
||||
if putResponse.status != .created {
|
||||
let body = try await postResponse.body.readTextResponse()
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
|
||||
code: putResponse.status.code, details: body ?? "")
|
||||
|
||||
if chunkSizeMb == 0 {
|
||||
// monolithic upload
|
||||
let response = try await rawRequest(
|
||||
.PUT,
|
||||
uploadLocation,
|
||||
headers: [
|
||||
"Content-Type": "application/octet-stream",
|
||||
],
|
||||
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
|
||||
}
|
||||
|
||||
@@ -226,11 +257,12 @@ class Registry {
|
||||
_ urlComponents: URLComponents,
|
||||
headers: Dictionary<String, String> = Dictionary(),
|
||||
parameters: Dictionary<String, String> = Dictionary(),
|
||||
body: Data? = nil
|
||||
body: Data? = nil,
|
||||
doAuth: Bool = true
|
||||
) async throws -> HTTPClientResponse {
|
||||
var urlComponents = urlComponents
|
||||
|
||||
if urlComponents.queryItems == nil {
|
||||
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
||||
urlComponents.queryItems = []
|
||||
}
|
||||
urlComponents.queryItems?.append(contentsOf: parameters.map { key, value -> URLQueryItem in
|
||||
@@ -243,6 +275,7 @@ class Registry {
|
||||
request.headers.add(name: key, value: value)
|
||||
}
|
||||
if body != nil {
|
||||
request.headers.add(name: "Content-Length", value: "\(body!.count)")
|
||||
request.body = HTTPClientRequest.Body.bytes(body!)
|
||||
}
|
||||
|
||||
@@ -253,7 +286,7 @@ class Registry {
|
||||
|
||||
var response = try await authAwareRequest(request: request)
|
||||
|
||||
if response.status == .unauthorized {
|
||||
if doAuth && response.status == .unauthorized {
|
||||
try await auth(response: response)
|
||||
response = try await authAwareRequest(request: request)
|
||||
}
|
||||
@@ -312,7 +345,7 @@ class Registry {
|
||||
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 {
|
||||
let body = try await response.body.readTextResponse() ?? ""
|
||||
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.status.code) "
|
||||
|
||||
@@ -5,6 +5,7 @@ import Dynamic
|
||||
// Kudos to @saagarjha's VirtualApple for finding about _VZVirtualMachineStartOptions
|
||||
|
||||
extension VZVirtualMachine {
|
||||
@available(macOS 12, *)
|
||||
func start(_ recovery: Bool) async throws {
|
||||
if !recovery {
|
||||
// just use the regular API
|
||||
|
||||
+37
-11
@@ -25,7 +25,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
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)
|
||||
|
||||
name = vmDir.name
|
||||
@@ -37,7 +40,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
||||
softnet: softnet)
|
||||
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
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)
|
||||
|
||||
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();
|
||||
|
||||
// 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,
|
||||
softnet: softnet)
|
||||
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
@@ -158,7 +167,19 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
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: {
|
||||
sema.wait()
|
||||
@@ -183,7 +204,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
diskURL: URL,
|
||||
auxStorage: VZMacAuxiliaryStorage,
|
||||
vmConfig: VMConfig,
|
||||
softnet: Softnet? = nil
|
||||
softnet: Softnet? = nil,
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment]
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -227,7 +249,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
// Audio
|
||||
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]
|
||||
|
||||
// Keyboard and mouse
|
||||
@@ -247,9 +273,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.networkDevices = [vio]
|
||||
|
||||
// Storage
|
||||
let attachment = try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
|
||||
let storage = VZVirtioBlockDeviceConfiguration(attachment: attachment)
|
||||
configuration.storageDevices = [storage]
|
||||
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
|
||||
attachments.append(contentsOf: additionalDiskAttachments)
|
||||
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
|
||||
|
||||
// Entropy
|
||||
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||
|
||||
@@ -98,13 +98,14 @@ extension VMDirectory {
|
||||
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>()
|
||||
|
||||
// Read VM's config and push it as blob
|
||||
let config = try VMConfig(fromURL: configURL)
|
||||
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))
|
||||
|
||||
// Progress
|
||||
@@ -126,21 +127,21 @@ extension VMDirectory {
|
||||
|
||||
return data
|
||||
}
|
||||
while let chunk = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
||||
let chunkDigest = try await registry.pushBlob(fromData: chunk)
|
||||
layers.append(OCIManifestLayer(mediaType: Self.diskMediaType, size: chunk.count, digest: chunkDigest))
|
||||
while let compressedLayerData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
||||
let layerDigest = try await registry.pushBlob(fromData: compressedLayerData, chunkSizeMb: chunkSizeMb)
|
||||
layers.append(OCIManifestLayer(mediaType: Self.diskMediaType, size: compressedLayerData.count, digest: layerDigest))
|
||||
}
|
||||
|
||||
// Read VM's NVRAM and push it as blob
|
||||
defaultLogger.appendNewLine("pushing NVRAM...")
|
||||
|
||||
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))
|
||||
|
||||
// Craft a stub OCI config for Docker Hub compatibility
|
||||
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(
|
||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||
layers: layers,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
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 {
|
||||
baseURL.appendingPathComponent(name, isDirectory: true)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
class VMStorageOCI {
|
||||
let baseURL = Config.tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
let baseURL = Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
|
||||
private func vmURL(_ name: RemoteName) -> URL {
|
||||
baseURL.appendingRemoteName(name)
|
||||
|
||||
@@ -42,13 +42,13 @@ final class RegistryTests: XCTestCase {
|
||||
XCTAssertEqual(pushedBlob, pulledBlob)
|
||||
}
|
||||
|
||||
func testPushPullBlobHuge() async throws {
|
||||
func testPushPullBlobHugeInChunks() async throws {
|
||||
// Generate a large enough blob
|
||||
let fh = FileHandle(forReadingAtPath: "/dev/urandom")!
|
||||
let largeBlobToPush = try fh.read(upToCount: 768 * 1024 * 1024)!
|
||||
|
||||
// Push it
|
||||
let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush)
|
||||
let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush, chunkSizeMb: 10)
|
||||
|
||||
// Pull it
|
||||
var pulledLargeBlob = Data()
|
||||
|
||||
Reference in New Issue
Block a user