mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-11 00:15:33 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0bf286aa1 | ||
|
|
195a4b3a72 | ||
|
|
59393df2e1 | ||
|
|
732c8244a4 | ||
|
|
9e69c8c161 | ||
|
|
e4ac2275b9 | ||
|
|
1a1f19e169 | ||
|
|
fea916dacc | ||
|
|
b1be9730c7 | ||
|
|
14059a1d6f | ||
|
|
440681320a | ||
|
|
a80954c888 | ||
|
|
cc8201dee6 | ||
|
|
2cab49b3f1 | ||
|
|
131827802a | ||
|
|
e2b7f12388 | ||
|
|
617a5d02dc | ||
|
|
b83bce4544 | ||
|
|
7d16516b6a | ||
|
|
56ddd8df75 | ||
|
|
b1534a05d5 | ||
|
|
f54e7c2cab | ||
|
|
85dfa961c9 | ||
|
|
3a74fc35e6 | ||
|
|
7bf7f890c4 |
+2
-2
@@ -10,7 +10,7 @@ task:
|
||||
name: Build
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:14
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
build_script: swift build --product tart
|
||||
sign_script: codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
|
||||
binary_artifacts:
|
||||
@@ -20,7 +20,7 @@ task:
|
||||
name: Release
|
||||
only_if: $CIRRUS_TAG != ''
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:14
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
env:
|
||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||
|
||||
+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
|
||||
|
||||
+40
-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",
|
||||
@@ -99,6 +126,15 @@
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
+6
-2
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version:5.6
|
||||
// swift-tools-version:5.7
|
||||
|
||||
import PackageDescription
|
||||
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/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"),
|
||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1")
|
||||
],
|
||||
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"),
|
||||
.product(name: "Parsing", package: "swift-parsing"),
|
||||
.product(name: "SwiftDate", package: "SwiftDate"),
|
||||
]),
|
||||
.testTarget(name: "TartTests", dependencies: ["tart"])
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||

|
||||
|
||||
*Tart* is a virtualization toolset to build, run and manage virtual machines on Apple Silicon.
|
||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
|
||||
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
||||
|
||||
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/14966395?baseline=14966339).
|
||||
@@ -84,6 +84,11 @@ For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/bina
|
||||
|
||||
### Creating from scratch
|
||||
|
||||
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regarding of the underlying OS a particular VM image has.
|
||||
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
|
||||
|
||||
#### Creating a macOS VM image from scratch
|
||||
|
||||
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
|
||||
use `latest` instead of a path to `*.ipsw` to download the latest available version:
|
||||
|
||||
@@ -100,6 +105,27 @@ After the initial booting of the VM you'll need to manually go through the macOS
|
||||
4. Disable Screen Saver.
|
||||
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
|
||||
|
||||
#### Creating a Linux VM image from scratch
|
||||
|
||||
```bash
|
||||
# Create a bare VM
|
||||
tart create --linux ubuntu
|
||||
|
||||
# Install Ubuntu
|
||||
tart run --disk focal-desktop-arm64.iso ubuntu
|
||||
|
||||
# Run VM
|
||||
tart run ubuntu
|
||||
```
|
||||
|
||||
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
|
||||
|
||||
```shell
|
||||
sudo apt update
|
||||
sudo apt install -y openssh-server
|
||||
sudo ufw allow ssh
|
||||
```
|
||||
|
||||
### Configuring a VM
|
||||
|
||||
By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command.
|
||||
|
||||
@@ -11,6 +11,9 @@ struct Clone: AsyncParsableCommand {
|
||||
@Argument(help: "new VM name")
|
||||
var newName: String
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
if newName.contains("/") {
|
||||
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) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,18 @@ struct Create: AsyncParsableCommand {
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@Option(help: ArgumentHelp("Path to the IPSW file (or \"latest\") to fetch the latest appropriate IPSW", valueName: "path"))
|
||||
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file (or \"latest\") to fetch the latest appropriate IPSW", valueName: "path"))
|
||||
var fromIPSW: String?
|
||||
|
||||
@Flag(help: "create a Linux VM")
|
||||
var linux: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Disk size in Gb"))
|
||||
var diskSize: UInt16 = 50
|
||||
|
||||
func validate() throws {
|
||||
if fromIPSW == nil {
|
||||
throw ValidationError("Please specify a --from-ipsw option!")
|
||||
if fromIPSW == nil && !linux {
|
||||
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +28,20 @@ struct Create: AsyncParsableCommand {
|
||||
do {
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
if fromIPSW! == "latest" {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
|
||||
} else {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW!), diskSizeGB: diskSize)
|
||||
if let fromIPSW = fromIPSW {
|
||||
if fromIPSW == "latest" {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
|
||||
} else {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW), diskSizeGB: diskSize)
|
||||
}
|
||||
}
|
||||
|
||||
if linux {
|
||||
if #available(macOS 13, *) {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
} else {
|
||||
throw UnsupportedOSError()
|
||||
}
|
||||
}
|
||||
|
||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||
|
||||
@@ -16,14 +16,21 @@ struct IP: AsyncParsableCommand {
|
||||
do {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
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?")
|
||||
|
||||
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)
|
||||
} 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 vmMacAddress = MACAddress(fromString: config.macAddress.string)!
|
||||
|
||||
repeat {
|
||||
if let ip = try Leases().resolveMACAddress(macAddress: vmMacAddress) {
|
||||
if let ip = try Leases().resolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ struct List: AsyncParsableCommand {
|
||||
print("Source\tName")
|
||||
|
||||
displayTable("local", try VMStorageLocal().list())
|
||||
displayTable("oci", try VMStorageOCI().list())
|
||||
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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 && !gc {
|
||||
throw ValidationError("at least one pruning 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")
|
||||
var remoteName: String
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
// Be more liberal when accepting local image as argument,
|
||||
@@ -19,7 +22,7 @@ struct Pull: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
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)...")
|
||||
|
||||
|
||||
@@ -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())")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ struct OCIManifest: Codable, Equatable {
|
||||
}
|
||||
|
||||
struct OCIConfig: Codable {
|
||||
var architecture: String = "arm64"
|
||||
var os: String = "darwin"
|
||||
var architecture: Architecture = .arm64
|
||||
var os: OS = .darwin
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
|
||||
@@ -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) "
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
enum Architecture: String, Codable {
|
||||
case arm64
|
||||
case amd64
|
||||
}
|
||||
|
||||
func CurrentArchitecture() -> Architecture {
|
||||
#if arch(arm64)
|
||||
return .arm64
|
||||
#elseif arch(x86_64)
|
||||
return .amd64
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Virtualization
|
||||
|
||||
struct Darwin: Platform {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
|
||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
||||
guard let data = Data.init(base64Encoded: encodedECID) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize Data using the provided value")
|
||||
}
|
||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
||||
}
|
||||
self.ecid = ecid
|
||||
|
||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||
}
|
||||
|
||||
func os() -> OS {
|
||||
.darwin
|
||||
}
|
||||
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||
VZMacOSBootLoader()
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||
let result = VZMacPlatformConfiguration()
|
||||
|
||||
result.machineIdentifier = ecid
|
||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
||||
result.hardwareModel = hardwareModel
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
let result = VZMacGraphicsDeviceConfiguration()
|
||||
|
||||
if let hostMainScreen = NSScreen.main {
|
||||
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
||||
result.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
result.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height,
|
||||
// A reasonable guess according to Apple's documentation[1]
|
||||
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||
pixelsPerInch: 72
|
||||
)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Virtualization
|
||||
|
||||
@available(macOS 13, *)
|
||||
struct Linux: Platform {
|
||||
func os() -> OS {
|
||||
.linux
|
||||
}
|
||||
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||
let result = VZEFIBootLoader()
|
||||
|
||||
result.variableStore = VZEFIVariableStore(url: nvramURL)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||
VZGenericPlatformConfiguration()
|
||||
}
|
||||
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
let result = VZVirtioGraphicsDeviceConfiguration()
|
||||
|
||||
result.scanouts = [
|
||||
VZVirtioGraphicsScanoutConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height
|
||||
)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Virtualization
|
||||
|
||||
enum OS: String, Codable {
|
||||
case darwin
|
||||
case linux
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import Virtualization
|
||||
|
||||
protocol Platform: Codable {
|
||||
func os() -> OS
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||
}
|
||||
@@ -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,
|
||||
Pull.self,
|
||||
Push.self,
|
||||
Prune.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
|
||||
|
||||
extension VZVirtualMachine {
|
||||
@available(macOS 12, *)
|
||||
func start(_ recovery: Bool) async throws {
|
||||
if !recovery {
|
||||
// just use the regular API
|
||||
|
||||
+74
-50
@@ -10,6 +10,13 @@ struct NoMainScreenFoundError: Error {
|
||||
struct DownloadFailed: Error {
|
||||
}
|
||||
|
||||
struct UnsupportedOSError: Error, CustomStringConvertible {
|
||||
private(set) var description: String = "error: Linux VMs are only supported on macOS 13.0 (Ventura) or newer"
|
||||
}
|
||||
|
||||
struct UnsupportedArchitectureError: Error {
|
||||
}
|
||||
|
||||
class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Virtualization.Framework's virtual machine
|
||||
@Published var virtualMachine: VZVirtualMachine
|
||||
@@ -25,19 +32,25 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
var softnet: Softnet? = nil
|
||||
|
||||
init(vmDir: VMDirectory, withSoftnet: Bool = false) throws {
|
||||
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
|
||||
|
||||
init(vmDir: VMDirectory,
|
||||
withSoftnet: Bool = false,
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
if config.arch != CurrentArchitecture() {
|
||||
throw UnsupportedArchitectureError()
|
||||
}
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
if withSoftnet {
|
||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
||||
}
|
||||
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
||||
softnet: softnet)
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
@@ -52,14 +65,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
let expectedIPSWLocation = try IPSWCache().locationFor(image: image)
|
||||
|
||||
if FileManager.default.fileExists(atPath: expectedIPSWLocation.path) {
|
||||
defaultLogger.appendNewLine("Using cached *.ipsw file...")
|
||||
try expectedIPSWLocation.updateAccessDate()
|
||||
return expectedIPSWLocation
|
||||
}
|
||||
|
||||
@@ -94,7 +104,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
|
||||
@@ -110,7 +126,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
// Create NVRAM
|
||||
let auxStorage = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
@@ -118,7 +134,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
name = vmDir.name
|
||||
// Create config
|
||||
config = VMConfig(
|
||||
hardwareModel: requirements.hardwareModel,
|
||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
||||
)
|
||||
@@ -131,8 +147,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
||||
}
|
||||
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
||||
softnet: softnet)
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, softnet: softnet,
|
||||
additionalDiskAttachments: additionalDiskAttachments)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
@@ -153,12 +170,39 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 13, *)
|
||||
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
||||
// Create NVRAM
|
||||
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
|
||||
// Create config
|
||||
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024)
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
|
||||
return try VM(vmDir: vmDir)
|
||||
}
|
||||
|
||||
func run(_ recovery: Bool) async throws {
|
||||
if let softnet = softnet {
|
||||
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()
|
||||
@@ -181,53 +225,33 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
static func craftConfiguration(
|
||||
diskURL: URL,
|
||||
auxStorage: VZMacAuxiliaryStorage,
|
||||
nvramURL: URL,
|
||||
vmConfig: VMConfig,
|
||||
softnet: Softnet? = nil
|
||||
softnet: Softnet? = nil,
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment]
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
// Boot loader
|
||||
configuration.bootLoader = VZMacOSBootLoader()
|
||||
configuration.bootLoader = try vmConfig.platform.bootLoader(nvramURL: nvramURL)
|
||||
|
||||
// CPU and memory
|
||||
configuration.cpuCount = vmConfig.cpuCount
|
||||
configuration.memorySize = vmConfig.memorySize
|
||||
|
||||
// Platform
|
||||
let platform = VZMacPlatformConfiguration()
|
||||
|
||||
platform.machineIdentifier = vmConfig.ecid
|
||||
platform.auxiliaryStorage = auxStorage
|
||||
platform.hardwareModel = vmConfig.hardwareModel
|
||||
|
||||
configuration.platform = platform
|
||||
configuration.platform = vmConfig.platform.platform(nvramURL: nvramURL)
|
||||
|
||||
// Display
|
||||
let graphicsDeviceConfiguration = VZMacGraphicsDeviceConfiguration()
|
||||
if let hostMainScreen = NSScreen.main {
|
||||
let vmScreenSize = NSSize(
|
||||
width: vmConfig.display.width,
|
||||
height: vmConfig.display.height
|
||||
)
|
||||
graphicsDeviceConfiguration.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||
]
|
||||
} else {
|
||||
graphicsDeviceConfiguration.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height,
|
||||
// Reasonable guess like https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||
pixelsPerInch: 72
|
||||
)
|
||||
]
|
||||
}
|
||||
configuration.graphicsDevices = [graphicsDeviceConfiguration]
|
||||
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
||||
|
||||
// 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 +271,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()]
|
||||
|
||||
+30
-36
@@ -16,14 +16,18 @@ class LessThanMinimalResourcesError: NSObject, LocalizedError {
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case version
|
||||
case ecid
|
||||
case hardwareModel
|
||||
case os
|
||||
case arch
|
||||
case cpuCountMin
|
||||
case cpuCount
|
||||
case memorySizeMin
|
||||
case memorySize
|
||||
case macAddress
|
||||
case display
|
||||
|
||||
// macOS-specific keys
|
||||
case ecid
|
||||
case hardwareModel
|
||||
}
|
||||
|
||||
struct VMDisplayConfig: Codable {
|
||||
@@ -33,25 +37,25 @@ struct VMDisplayConfig: Codable {
|
||||
|
||||
struct VMConfig: Codable {
|
||||
var version: Int = 1
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
var os: OS
|
||||
var arch: Architecture
|
||||
var platform: Platform
|
||||
var cpuCountMin: Int
|
||||
private(set) var cpuCount: Int
|
||||
var memorySizeMin: UInt64
|
||||
private(set) var memorySize: UInt64
|
||||
var macAddress: VZMACAddress
|
||||
|
||||
var display: VMDisplayConfig = VMDisplayConfig()
|
||||
|
||||
init(
|
||||
ecid: VZMacMachineIdentifier = VZMacMachineIdentifier(),
|
||||
hardwareModel: VZMacHardwareModel,
|
||||
cpuCountMin: Int,
|
||||
memorySizeMin: UInt64,
|
||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
||||
platform: Platform,
|
||||
cpuCountMin: Int,
|
||||
memorySizeMin: UInt64,
|
||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
||||
) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
self.os = platform.os()
|
||||
self.arch = CurrentArchitecture()
|
||||
self.platform = platform
|
||||
self.macAddress = macAddress
|
||||
self.cpuCountMin = cpuCountMin
|
||||
self.memorySizeMin = memorySizeMin
|
||||
@@ -81,29 +85,18 @@ struct VMConfig: Codable {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
version = try container.decode(Int.self, forKey: .version)
|
||||
|
||||
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
||||
guard let data = Data.init(base64Encoded: encodedECID) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize Data using the provided value")
|
||||
os = try container.decodeIfPresent(OS.self, forKey: .os) ?? .darwin
|
||||
arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
|
||||
switch os {
|
||||
case .darwin:
|
||||
platform = try Darwin(from: decoder)
|
||||
case .linux:
|
||||
if #available(macOS 13, *) {
|
||||
platform = try Linux(from: decoder)
|
||||
} else {
|
||||
throw UnsupportedOSError()
|
||||
}
|
||||
}
|
||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
||||
}
|
||||
self.ecid = ecid
|
||||
|
||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
self.hardwareModel = hardwareModel
|
||||
|
||||
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
|
||||
cpuCount = try container.decode(Int.self, forKey: .cpuCount)
|
||||
memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin)
|
||||
@@ -125,8 +118,9 @@ struct VMConfig: Codable {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(version, forKey: .version)
|
||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||
try container.encode(os, forKey: .os)
|
||||
try container.encode(arch, forKey: .arch)
|
||||
try platform.encode(to: encoder)
|
||||
try container.encode(cpuCountMin, forKey: .cpuCountMin)
|
||||
try container.encode(cpuCount, forKey: .cpuCount)
|
||||
try container.encode(memorySizeMin, forKey: .memorySizeMin)
|
||||
|
||||
@@ -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
|
||||
@@ -116,35 +117,37 @@ extension VMDirectory {
|
||||
|
||||
// Read VM's compressed disk as chunks
|
||||
// and sequentially upload them as blobs
|
||||
let disk = try FileHandle(forReadingFrom: diskURL)
|
||||
var diskReadBytes: UInt64 = 0
|
||||
let compressingFilter = try InputFilter<Data>(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { _ in
|
||||
let data = try disk.read(upToCount: Self.bufferSizeBytes)
|
||||
diskReadBytes += UInt64(data?.count ?? 0)
|
||||
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
let mappedDiskSize = mappedDisk.count
|
||||
var mappedDiskReadOffset = 0
|
||||
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
||||
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
|
||||
}
|
||||
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 ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
||||
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
|
||||
let manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||
layers: layers,
|
||||
uncompressedDiskSize: diskReadBytes
|
||||
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
|
||||
)
|
||||
|
||||
// Manifest
|
||||
|
||||
@@ -7,7 +7,7 @@ struct UninitializedVMDirectoryError: Error {
|
||||
struct AlreadyInitializedVMDirectoryError: Error {
|
||||
}
|
||||
|
||||
struct VMDirectory {
|
||||
struct VMDirectory: Prunable {
|
||||
var baseURL: URL
|
||||
|
||||
var configURL: URL {
|
||||
@@ -20,6 +20,10 @@ struct VMDirectory {
|
||||
baseURL.appendingPathComponent("nvram.bin")
|
||||
}
|
||||
|
||||
var explicitlyPulledMark: URL {
|
||||
baseURL.appendingPathComponent(".explicitly-pulled")
|
||||
}
|
||||
|
||||
var name: String {
|
||||
baseURL.lastPathComponent
|
||||
}
|
||||
@@ -77,4 +81,24 @@ struct VMDirectory {
|
||||
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
|
||||
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
|
||||
|
||||
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)
|
||||
class VMStorageOCI: PrunableStorage {
|
||||
let baseURL = Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
|
||||
private func vmURL(_ name: RemoteName) -> URL {
|
||||
baseURL.appendingRemoteName(name)
|
||||
@@ -16,6 +16,8 @@ class VMStorageOCI {
|
||||
|
||||
try vmDir.validate()
|
||||
|
||||
try vmDir.baseURL.updateAccessDate()
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
@@ -40,10 +42,48 @@ class VMStorageOCI {
|
||||
|
||||
func delete(_ name: RemoteName) throws {
|
||||
try FileManager.default.removeItem(at: vmURL(name))
|
||||
try gc()
|
||||
}
|
||||
|
||||
func list() throws -> [(String, VMDirectory)] {
|
||||
var result: [(String, VMDirectory)] = Array()
|
||||
func gc() throws {
|
||||
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,
|
||||
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
|
||||
@@ -60,28 +100,46 @@ class VMStorageOCI {
|
||||
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
||||
var name: String
|
||||
|
||||
if try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink! {
|
||||
let isSymlink = try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink!
|
||||
if isSymlink {
|
||||
name = parts.joined(separator: ":")
|
||||
} else {
|
||||
name = parts.joined(separator: "@")
|
||||
}
|
||||
|
||||
result.append((name, vmDir))
|
||||
result.append((name, vmDir, isSymlink))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func prunables() throws -> [Prunable] {
|
||||
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
||||
}
|
||||
|
||||
func pull(_ name: RemoteName, registry: Registry) async throws {
|
||||
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,
|
||||
reference: Reference(digest: try manifest.digest()))
|
||||
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||
reference: Reference(digest: Digest.hash(manifestData)))
|
||||
|
||||
if !exists(digestName) {
|
||||
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 tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
||||
try move(digestName, from: tmpVMDir)
|
||||
@@ -93,8 +151,12 @@ class VMStorageOCI {
|
||||
}
|
||||
|
||||
if name != digestName {
|
||||
// Overwrite the old symbolic link
|
||||
// Create new or overwrite the old symbolic link
|
||||
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 gc()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ class ScreenSharingVNC: VNC {
|
||||
}
|
||||
|
||||
func waitForURL() async throws -> URL {
|
||||
let ip = try await IP.resolveIP(vmConfig, secondsToWait: 60)
|
||||
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)")!
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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