mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-22 03:24:40 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
732c8244a4 | ||
|
|
9e69c8c161 | ||
|
|
e4ac2275b9 | ||
|
|
1a1f19e169 | ||
|
|
fea916dacc | ||
|
|
b1be9730c7 | ||
|
|
14059a1d6f | ||
|
|
440681320a | ||
|
|
a80954c888 | ||
|
|
cc8201dee6 | ||
|
|
2cab49b3f1 | ||
|
|
131827802a | ||
|
|
e2b7f12388 | ||
|
|
617a5d02dc | ||
|
|
b83bce4544 | ||
|
|
7d16516b6a | ||
|
|
56ddd8df75 | ||
|
|
b1534a05d5 | ||
|
|
f54e7c2cab | ||
|
|
85dfa961c9 | ||
|
|
3a74fc35e6 | ||
|
|
7bf7f890c4 | ||
|
|
48cd4b47e4 |
+2
-2
@@ -10,7 +10,7 @@ task:
|
|||||||
name: Build
|
name: Build
|
||||||
only_if: $CIRRUS_TAG == ''
|
only_if: $CIRRUS_TAG == ''
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:14
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||||
build_script: swift build --product tart
|
build_script: swift build --product tart
|
||||||
sign_script: codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
|
sign_script: codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
|
||||||
binary_artifacts:
|
binary_artifacts:
|
||||||
@@ -20,7 +20,7 @@ task:
|
|||||||
name: Release
|
name: Release
|
||||||
only_if: $CIRRUS_TAG != ''
|
only_if: $CIRRUS_TAG != ''
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:14
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||||
|
|||||||
+7
-1
@@ -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
|
||||||
|
|||||||
+40
-4
@@ -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",
|
||||||
@@ -99,6 +126,15 @@
|
|||||||
"version" : "0.9.2"
|
"version" : "0.9.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftdate",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/malcommac/SwiftDate",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
||||||
|
"version" : "6.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "xctest-dynamic-overlay",
|
"identity" : "xctest-dynamic-overlay",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
+6
-2
@@ -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,14 +13,18 @@ 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"),
|
||||||
|
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1")
|
||||||
],
|
],
|
||||||
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"),
|
||||||
.product(name: "Parsing", package: "swift-parsing"),
|
.product(name: "Parsing", package: "swift-parsing"),
|
||||||
|
.product(name: "SwiftDate", package: "SwiftDate"),
|
||||||
]),
|
]),
|
||||||
.testTarget(name: "TartTests", dependencies: ["tart"])
|
.testTarget(name: "TartTests", dependencies: ["tart"])
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ struct Clone: AsyncParsableCommand {
|
|||||||
@Argument(help: "new VM name")
|
@Argument(help: "new VM name")
|
||||||
var newName: String
|
var newName: String
|
||||||
|
|
||||||
|
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||||
|
var insecure: Bool = false
|
||||||
|
|
||||||
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")
|
||||||
@@ -24,7 +27,7 @@ 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)
|
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||||
try await ociStorage.pull(remoteName, registry: registry)
|
try await ociStorage.pull(remoteName, registry: registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,21 @@ struct IP: AsyncParsableCommand {
|
|||||||
do {
|
do {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
|
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||||
|
|
||||||
guard let ip = try await IP.resolveIP(vmConfig, secondsToWait: wait) else {
|
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
||||||
print("no IP address found, is your VM running?")
|
print("no IP address found, is your VM running?")
|
||||||
|
|
||||||
Foundation.exit(1)
|
Foundation.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
print(ip)
|
if let ipViaARP = try ARPCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
||||||
|
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
||||||
|
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
||||||
|
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
print(ipViaDHCP)
|
||||||
|
|
||||||
Foundation.exit(0)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -33,12 +40,11 @@ struct IP: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static public func resolveIP(_ config: VMConfig, secondsToWait: UInt16) async throws -> IPv4Address? {
|
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
|
||||||
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
||||||
let vmMacAddress = MACAddress(fromString: config.macAddress.string)!
|
|
||||||
|
|
||||||
repeat {
|
repeat {
|
||||||
if let ip = try Leases().resolveMACAddress(macAddress: vmMacAddress) {
|
if let ip = try Leases().resolveMACAddress(macAddress: vmMACAddress) {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ struct List: AsyncParsableCommand {
|
|||||||
print("Source\tName")
|
print("Source\tName")
|
||||||
|
|
||||||
displayTable("local", try VMStorageLocal().list())
|
displayTable("local", try VMStorageLocal().list())
|
||||||
displayTable("oci", try VMStorageOCI().list())
|
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
|
||||||
|
|
||||||
Foundation.exit(0)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ struct Login: AsyncParsableCommand {
|
|||||||
@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 != nil
|
let usernameProvided = username != nil
|
||||||
let passwordProvided = passwordStdin
|
let passwordProvided = passwordStdin
|
||||||
@@ -30,7 +33,9 @@ struct Login: AsyncParsableCommand {
|
|||||||
|
|
||||||
if let username = username {
|
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)")
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Dispatch
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftDate
|
||||||
|
|
||||||
|
struct Prune: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Remove cache entries last accessed more than n days ago",
|
||||||
|
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
|
||||||
|
valueName: "n"))
|
||||||
|
var olderThan: UInt?
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Remove least recently used cache entries that do not fit the specified cache size budget n, expressed in gigabytes",
|
||||||
|
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
|
||||||
|
valueName: "n"))
|
||||||
|
var cacheBudget: UInt?
|
||||||
|
|
||||||
|
@Flag(help: .hidden)
|
||||||
|
var gc: Bool = false
|
||||||
|
|
||||||
|
func validate() throws {
|
||||||
|
if olderThan == nil && cacheBudget == nil {
|
||||||
|
throw ValidationError("at least one criteria must be specified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
do {
|
||||||
|
if gc {
|
||||||
|
try VMStorageOCI().gc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up cache entries based on last accessed date
|
||||||
|
if let olderThan = olderThan {
|
||||||
|
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
||||||
|
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
|
||||||
|
|
||||||
|
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
||||||
|
if let cacheBudget = cacheBudget {
|
||||||
|
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
Foundation.exit(0)
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
|
||||||
|
Foundation.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pruneOlderThan(olderThanDate: Date) throws {
|
||||||
|
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||||
|
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
|
||||||
|
|
||||||
|
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
|
||||||
|
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||||
|
let prunables: [Prunable] = try prunableStorages
|
||||||
|
.flatMap { try $0.prunables() }
|
||||||
|
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||||
|
|
||||||
|
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
||||||
|
var cacheReclaimedBytes: Int = 0
|
||||||
|
|
||||||
|
var it = prunables.makeIterator()
|
||||||
|
|
||||||
|
while (cacheUsedBytes - cacheReclaimedBytes) > cacheBudgetBytes {
|
||||||
|
guard let prunable = it.next() else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheReclaimedBytes -= try prunable.sizeBytes()
|
||||||
|
try prunable.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pruneReclaim(reclaimBytes: UInt64) throws {
|
||||||
|
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||||
|
let prunables: [Prunable] = try prunableStorages
|
||||||
|
.flatMap { try $0.prunables() }
|
||||||
|
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||||
|
|
||||||
|
// Does it even make sense to start?
|
||||||
|
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
||||||
|
if cacheUsedBytes < reclaimBytes {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheReclaimedBytes: Int = 0
|
||||||
|
|
||||||
|
var it = prunables.makeIterator()
|
||||||
|
|
||||||
|
while cacheReclaimedBytes <= reclaimBytes {
|
||||||
|
guard let prunable = it.next() else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheReclaimedBytes -= try prunable.sizeBytes()
|
||||||
|
try prunable.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ struct Pull: AsyncParsableCommand {
|
|||||||
@Argument(help: "remote VM name")
|
@Argument(help: "remote VM name")
|
||||||
var remoteName: String
|
var remoteName: String
|
||||||
|
|
||||||
|
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||||
|
var insecure: Bool = false
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
do {
|
||||||
// Be more liberal when accepting local image as argument,
|
// Be more liberal when accepting local image as argument,
|
||||||
@@ -19,7 +22,7 @@ struct Pull: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let remoteName = try RemoteName(remoteName)
|
let remoteName = try RemoteName(remoteName)
|
||||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
|
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||||
|
|
||||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -35,27 +35,30 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
|||||||
|
|
||||||
func store(host: String, user: String, password: String) throws {
|
func store(host: String, user: String, password: String) throws {
|
||||||
let passwordData = password.data(using: .utf8)
|
let passwordData = password.data(using: .utf8)
|
||||||
let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
let key: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||||
kSecAttrAccount as String: user,
|
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
||||||
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
kSecAttrServer as String: host,
|
||||||
kSecAttrServer as String: host,
|
kSecAttrLabel as String: "Tart Credentials",
|
||||||
kSecValueData as String: passwordData,
|
]
|
||||||
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 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:
|
case errSecSuccess:
|
||||||
return
|
let status = SecItemUpdate(key as CFDictionary, value as CFDictionary)
|
||||||
case errSecDuplicateItem:
|
|
||||||
let status = SecItemUpdate(attributes as CFDictionary,
|
|
||||||
[kSecValueData as String : passwordData] as CFDictionary)
|
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
|
throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw CredentialsProviderError.Failed(message: "Keychain failed to add item: \(status.explanation())")
|
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
class IPSWCache: PrunableStorage {
|
||||||
|
let baseURL: URL
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
baseURL = Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationFor(image: VZMacOSRestoreImage) -> URL {
|
||||||
|
baseURL.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prunables() throws -> [Prunable] {
|
||||||
|
try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)
|
||||||
|
.filter { $0.lastPathComponent.hasSuffix(".ipsw")}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
struct ARPCommandFailedError: Error, CustomStringConvertible {
|
||||||
|
var terminationReason: Process.TerminationReason
|
||||||
|
var terminationStatus: Int32
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
var reason: String
|
||||||
|
|
||||||
|
switch terminationReason {
|
||||||
|
case .exit:
|
||||||
|
reason = "exit code \(terminationStatus)"
|
||||||
|
case .uncaughtSignal:
|
||||||
|
reason = "uncaught signal"
|
||||||
|
default:
|
||||||
|
reason = "unknown reason"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "arp command failed: \(reason)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
|
||||||
|
var explanation: String
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
"arp command yielded invalid output: \(explanation)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ARPCacheInternalError: Error, CustomStringConvertible {
|
||||||
|
var explanation: String
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
"ARPCache internal error: \(explanation)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ARPCache {
|
||||||
|
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||||
|
let process = Process.init()
|
||||||
|
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
||||||
|
process.arguments = ["-an"]
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
process.standardInput = FileHandle.nullDevice
|
||||||
|
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||||
|
throw ARPCommandFailedError(
|
||||||
|
terminationReason: process.terminationReason,
|
||||||
|
terminationStatus: process.terminationStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
|
||||||
|
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||||
|
}
|
||||||
|
let lines = String(decoding: rawLines, as: UTF8.self)
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.components(separatedBy: "\n")
|
||||||
|
|
||||||
|
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
|
||||||
|
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
|
||||||
|
|
||||||
|
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
|
||||||
|
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
|
||||||
|
guard let ip = IPv4Address(rawIP) else {
|
||||||
|
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
|
||||||
|
if rawMAC == "(incomplete)" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard let mac = MACAddress(fromString: rawMAC) else {
|
||||||
|
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let interface = try match.getCaptureGroup(name: "interface", for: line)
|
||||||
|
if bridgeOnly && !interface.starts(with: "bridge") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if macAddress == mac {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NSTextCheckingResult {
|
||||||
|
func getCaptureGroup(name: String, for string: String) throws -> String {
|
||||||
|
let nsRange = self.range(withName: name)
|
||||||
|
|
||||||
|
if nsRange.location == NSNotFound {
|
||||||
|
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let range = Range.init(nsRange, in: string) else {
|
||||||
|
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(string[range])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,6 @@ struct MACAddress: Equatable, Hashable, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
return String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = "")
|
||||||
@@ -38,15 +40,16 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -75,12 +78,14 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -97,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)
|
||||||
}
|
}
|
||||||
@@ -158,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"])
|
||||||
@@ -169,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,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
|
||||||
@@ -243,6 +275,7 @@ 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!)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +286,7 @@ class Registry {
|
|||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -312,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) "
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PrunableStorage {
|
||||||
|
func prunables() throws -> [Prunable]
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol Prunable {
|
||||||
|
func delete() throws
|
||||||
|
func accessDate() throws -> Date
|
||||||
|
func sizeBytes() throws -> Int
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
IP.self,
|
IP.self,
|
||||||
Pull.self,
|
Pull.self,
|
||||||
Push.self,
|
Push.self,
|
||||||
|
Prune.self,
|
||||||
Delete.self,
|
Delete.self,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension URL {
|
||||||
|
func accessDate() throws -> Date {
|
||||||
|
let attrs = try resourceValues(forKeys: [.contentAccessDateKey])
|
||||||
|
return attrs.contentAccessDate!
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAccessDate(_ accessDate: Date = Date()) throws {
|
||||||
|
let attrs = try resourceValues(forKeys: [.contentAccessDateKey])
|
||||||
|
let modificationDate = attrs.contentAccessDate!
|
||||||
|
|
||||||
|
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
||||||
|
let ret = utimes(path, times)
|
||||||
|
if ret != 0 {
|
||||||
|
throw RuntimeError("utimes(2) failed: \(ret.explanation())")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
func asTimeval() -> timeval {
|
||||||
|
timeval(tv_sec: Int(timeIntervalSince1970), tv_usec: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension URL: Prunable {
|
||||||
|
func delete() throws {
|
||||||
|
try FileManager.default.removeItem(at: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeBytes() throws -> Int {
|
||||||
|
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+38
-15
@@ -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()
|
||||||
@@ -52,14 +55,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let expectedIPSWLocation = try IPSWCache().locationFor(image: image)
|
||||||
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)
|
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: expectedIPSWLocation.path) {
|
if FileManager.default.fileExists(atPath: expectedIPSWLocation.path) {
|
||||||
defaultLogger.appendNewLine("Using cached *.ipsw file...")
|
defaultLogger.appendNewLine("Using cached *.ipsw file...")
|
||||||
|
try expectedIPSWLocation.updateAccessDate()
|
||||||
return expectedIPSWLocation
|
return expectedIPSWLocation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,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 +138,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 +164,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 +201,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 +246,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 +270,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()]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -116,35 +117,37 @@ extension VMDirectory {
|
|||||||
|
|
||||||
// Read VM's compressed disk as chunks
|
// Read VM's compressed disk as chunks
|
||||||
// and sequentially upload them as blobs
|
// and sequentially upload them as blobs
|
||||||
let disk = try FileHandle(forReadingFrom: diskURL)
|
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||||
var diskReadBytes: UInt64 = 0
|
let mappedDiskSize = mappedDisk.count
|
||||||
let compressingFilter = try InputFilter<Data>(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { _ in
|
var mappedDiskReadOffset = 0
|
||||||
let data = try disk.read(upToCount: Self.bufferSizeBytes)
|
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
||||||
diskReadBytes += UInt64(data?.count ?? 0)
|
let bytesRead = min(length, mappedDiskSize - mappedDiskReadOffset)
|
||||||
|
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
||||||
|
mappedDiskReadOffset += bytesRead
|
||||||
|
|
||||||
progress.completedUnitCount += Int64(data?.count ?? 0)
|
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
||||||
|
|
||||||
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,
|
||||||
uncompressedDiskSize: diskReadBytes
|
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manifest
|
// Manifest
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ struct UninitializedVMDirectoryError: Error {
|
|||||||
struct AlreadyInitializedVMDirectoryError: Error {
|
struct AlreadyInitializedVMDirectoryError: Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VMDirectory {
|
struct VMDirectory: Prunable {
|
||||||
var baseURL: URL
|
var baseURL: URL
|
||||||
|
|
||||||
var configURL: URL {
|
var configURL: URL {
|
||||||
@@ -20,6 +20,10 @@ struct VMDirectory {
|
|||||||
baseURL.appendingPathComponent("nvram.bin")
|
baseURL.appendingPathComponent("nvram.bin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var explicitlyPulledMark: URL {
|
||||||
|
baseURL.appendingPathComponent(".explicitly-pulled")
|
||||||
|
}
|
||||||
|
|
||||||
var name: String {
|
var name: String {
|
||||||
baseURL.lastPathComponent
|
baseURL.lastPathComponent
|
||||||
}
|
}
|
||||||
@@ -77,4 +81,24 @@ struct VMDirectory {
|
|||||||
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
|
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
|
||||||
try diskFileHandle.close()
|
try diskFileHandle.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func delete() throws {
|
||||||
|
try FileManager.default.removeItem(at: baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func accessDate() throws -> Date {
|
||||||
|
try baseURL.accessDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeBytes() throws -> Int {
|
||||||
|
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markExplicitlyPulled() {
|
||||||
|
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExplicitlyPulled() -> Bool {
|
||||||
|
FileManager.default.fileExists(atPath: explicitlyPulledMark.path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class VMStorageOCI {
|
class VMStorageOCI: PrunableStorage {
|
||||||
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)
|
||||||
@@ -16,6 +16,8 @@ class VMStorageOCI {
|
|||||||
|
|
||||||
try vmDir.validate()
|
try vmDir.validate()
|
||||||
|
|
||||||
|
try vmDir.baseURL.updateAccessDate()
|
||||||
|
|
||||||
return vmDir
|
return vmDir
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,10 +42,48 @@ class VMStorageOCI {
|
|||||||
|
|
||||||
func delete(_ name: RemoteName) throws {
|
func delete(_ name: RemoteName) throws {
|
||||||
try FileManager.default.removeItem(at: vmURL(name))
|
try FileManager.default.removeItem(at: vmURL(name))
|
||||||
|
try gc()
|
||||||
}
|
}
|
||||||
|
|
||||||
func list() throws -> [(String, VMDirectory)] {
|
func gc() throws {
|
||||||
var result: [(String, VMDirectory)] = Array()
|
var refCounts = Dictionary<URL, UInt>()
|
||||||
|
|
||||||
|
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||||
|
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for case let foundURL as URL in enumerator {
|
||||||
|
let isSymlink = try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink!
|
||||||
|
|
||||||
|
// Perform garbage collection for tag-based images
|
||||||
|
// with broken outgoing references
|
||||||
|
if isSymlink && foundURL == foundURL.resolvingSymlinksInPath() {
|
||||||
|
try FileManager.default.removeItem(at: foundURL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let vmDir = VMDirectory(baseURL: foundURL.resolvingSymlinksInPath())
|
||||||
|
if !vmDir.initialized {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
refCounts[vmDir.baseURL] = (refCounts[vmDir.baseURL] ?? 0) + (isSymlink ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform garbage collection for digest-based images
|
||||||
|
// with no incoming references
|
||||||
|
for (baseURL, incRefCount) in refCounts {
|
||||||
|
let vmDir = VMDirectory(baseURL: baseURL)
|
||||||
|
|
||||||
|
if !vmDir.isExplicitlyPulled() && incRefCount == 0 {
|
||||||
|
try FileManager.default.removeItem(at: baseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func list() throws -> [(String, VMDirectory, Bool)] {
|
||||||
|
var result: [(String, VMDirectory, Bool)] = Array()
|
||||||
|
|
||||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||||
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
|
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
|
||||||
@@ -60,28 +100,46 @@ class VMStorageOCI {
|
|||||||
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
if try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink! {
|
let isSymlink = try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink!
|
||||||
|
if isSymlink {
|
||||||
name = parts.joined(separator: ":")
|
name = parts.joined(separator: ":")
|
||||||
} else {
|
} else {
|
||||||
name = parts.joined(separator: "@")
|
name = parts.joined(separator: "@")
|
||||||
}
|
}
|
||||||
|
|
||||||
result.append((name, vmDir))
|
result.append((name, vmDir, isSymlink))
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prunables() throws -> [Prunable] {
|
||||||
|
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
||||||
|
}
|
||||||
|
|
||||||
func pull(_ name: RemoteName, registry: Registry) async throws {
|
func pull(_ name: RemoteName, registry: Registry) async throws {
|
||||||
defaultLogger.appendNewLine("pulling manifest...")
|
defaultLogger.appendNewLine("pulling manifest...")
|
||||||
|
|
||||||
let (manifest, _) = try await registry.pullManifest(reference: name.reference.value)
|
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
||||||
|
|
||||||
var digestName = RemoteName(host: name.host, namespace: name.namespace,
|
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||||
reference: Reference(digest: try manifest.digest()))
|
reference: Reference(digest: Digest.hash(manifestData)))
|
||||||
|
|
||||||
if !exists(digestName) {
|
if !exists(digestName) {
|
||||||
let tmpVMDir = try VMDirectory.temporary()
|
let tmpVMDir = try VMDirectory.temporary()
|
||||||
|
|
||||||
|
// Try to reclaim some cache space if we know the VM size in advance
|
||||||
|
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
||||||
|
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
|
||||||
|
|
||||||
|
let attrs = try tmpVMDir.baseURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
|
||||||
|
let availableCapacityBytes = UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
|
||||||
|
|
||||||
|
if availableCapacityBytes < requiredCapacityBytes {
|
||||||
|
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
try await withTaskCancellationHandler(operation: {
|
||||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
||||||
try move(digestName, from: tmpVMDir)
|
try move(digestName, from: tmpVMDir)
|
||||||
@@ -93,8 +151,12 @@ class VMStorageOCI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if name != digestName {
|
if name != digestName {
|
||||||
// Overwrite the old symbolic link
|
// Create new or overwrite the old symbolic link
|
||||||
try link(from: digestName, to: name)
|
try link(from: digestName, to: name)
|
||||||
|
} else {
|
||||||
|
// Ensure that images pulled by content digest
|
||||||
|
// are excluded from garbage collection
|
||||||
|
VMDirectory(baseURL: vmURL(name)).markExplicitlyPulled()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +166,8 @@ class VMStorageOCI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
|
try FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
|
||||||
|
|
||||||
|
try gc()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
import Dynamic
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
class ScreenSharingVNC: VNC {
|
||||||
|
let vmConfig: VMConfig
|
||||||
|
|
||||||
|
init(vmConfig: VMConfig) {
|
||||||
|
self.vmConfig = vmConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForURL() async throws -> URL {
|
||||||
|
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||||
|
let ip = try await IP.resolveIP(vmMACAddress, secondsToWait: 60)
|
||||||
|
|
||||||
|
if let ip = ip {
|
||||||
|
return URL(string: "vnc://\(ip)")!
|
||||||
|
}
|
||||||
|
|
||||||
|
throw IPNotFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() throws {
|
||||||
|
// nothing to do
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol VNC {
|
||||||
|
func waitForURL() async throws -> URL
|
||||||
|
func stop() throws
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class URLAccessDateTests: XCTestCase {
|
||||||
|
func testGetAndSetAccessTime() throws {
|
||||||
|
// Create a temporary file
|
||||||
|
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
|
var tmpFile = tmpDir.appendingPathComponent(UUID().uuidString)
|
||||||
|
FileManager.default.createFile(atPath: tmpFile.path, contents: nil)
|
||||||
|
|
||||||
|
// Ensure it's access date is different than our desired access date
|
||||||
|
let arbitraryDate = Date.init(year: 2008, month: 09, day: 28, hour: 23, minute: 15)
|
||||||
|
XCTAssertNotEqual(arbitraryDate, try tmpFile.accessDate())
|
||||||
|
|
||||||
|
// Set our desired access date for a file
|
||||||
|
try tmpFile.updateAccessDate(arbitraryDate)
|
||||||
|
|
||||||
|
// Ensure the access date has changed to our value
|
||||||
|
tmpFile.removeCachedResourceValue(forKey: .contentAccessDateKey)
|
||||||
|
XCTAssertEqual(arbitraryDate, try tmpFile.accessDate())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user