mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-01 19:51:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7b3b702ac | ||
|
|
560dba79e4 | ||
|
|
2b33b8f9e6 | ||
|
|
d8b010c79c | ||
|
|
5cd83c38cd | ||
|
|
1a3b862631 | ||
|
|
ae2d59e5c2 | ||
|
|
7eac45702b | ||
|
|
e06d89f95d | ||
|
|
0602d6e0e1 | ||
|
|
ac5d0baa0c | ||
|
|
ee27cc57bb | ||
|
|
a9e2a19015 | ||
|
|
89ff5f6b65 | ||
|
|
6bf39e73d0 | ||
|
|
0b693f6bc9 | ||
|
|
99bbd838a1 | ||
|
|
5c7743b7cd | ||
|
|
a40e104c03 | ||
|
|
e2d6c13ed0 | ||
|
|
3f17884ac2 | ||
|
|
7dcebf9c04 | ||
|
|
f6c56ed8eb | ||
|
|
a48f4d4ec9 | ||
|
|
9bb71a4051 | ||
|
|
18d462dd3d | ||
|
|
cd6a97f842 | ||
|
|
3e4bc73ff0 | ||
|
|
89fff42d0a | ||
|
|
7bb50b5e4b | ||
|
|
b5cbf67ee6 | ||
|
|
090a8232fc | ||
|
|
306ace792c | ||
|
|
96f6f94fa7 | ||
|
|
fbc481250d | ||
|
|
35538c2c5d | ||
|
|
4c33064916 | ||
|
|
1a267d4a39 | ||
|
|
36c54d95cb | ||
|
|
1d8bfafde5 | ||
|
|
537f0ae5db | ||
|
|
02f1ff5238 | ||
|
|
9c9bcd586e | ||
|
|
60f0eac7a8 | ||
|
|
35377a3475 | ||
|
|
dda4e91a91 | ||
|
|
ac5f794e6d | ||
|
|
5bcbc77249 | ||
|
|
bf03873c8d | ||
|
|
bad37b129c | ||
|
|
0f47cca746 | ||
|
|
d70eca4484 | ||
|
|
25887b075f | ||
|
|
8c011623be | ||
|
|
2dccdfb306 | ||
|
|
aca768a838 | ||
|
|
68b3557747 |
@@ -7,6 +7,8 @@ export VERSION="${CIRRUS_TAG:-0}"
|
||||
mkdir -p .ci/pkg/
|
||||
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
|
||||
cp Resources/embedded.provisionprofile .ci/pkg/embedded.provisionprofile
|
||||
cp Resources/AppIcon.png .ci/pkg/AppIcon.png
|
||||
cp Resources/Info.plist .ci/pkg/Info.plist
|
||||
pkgbuild --root .ci/pkg/ --identifier com.github.cirruslabs.tart --version $VERSION \
|
||||
--scripts .ci/pkg/scripts \
|
||||
--install-location "/Library/Application Support/Tart" \
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
set -e
|
||||
|
||||
# fix structure
|
||||
mkdir -p "$2/tart.app/Contents/MacOS"
|
||||
mkdir -p "$2/tart.app/Contents/MacOS" "$2/tart.app/Resources"
|
||||
mv "$2/tart" "$2/tart.app/Contents/MacOS/tart"
|
||||
mv "$2/embedded.provisionprofile" "$2/tart.app/Contents/embedded.provisionprofile"
|
||||
mv "$2/AppIcon.png" "$2/tart.app/Resources/AppIcon.png"
|
||||
mv "$2/Info.plist" "$2/tart.app/Contents/Info.plist"
|
||||
|
||||
echo "#!/bin/sh" > /usr/local/bin/tart
|
||||
echo "exec '$2/tart.app/Contents/MacOS/tart' \"\$@\"" >> /usr/local/bin/tart
|
||||
|
||||
+9
-6
@@ -1,7 +1,7 @@
|
||||
use_compute_credits: true
|
||||
|
||||
env:
|
||||
XCODE_TAG: 15
|
||||
XCODE_TAG: 15.2
|
||||
|
||||
task:
|
||||
name: Test on Sonoma
|
||||
@@ -51,14 +51,18 @@ task:
|
||||
|
||||
task:
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
name: Build
|
||||
env:
|
||||
matrix:
|
||||
BUILD_ARCH: arm64
|
||||
BUILD_ARCH: x86_64
|
||||
name: Build ($BUILD_ARCH)
|
||||
alias: build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
build_script: swift build --product tart
|
||||
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
build_script: swift build --arch $BUILD_ARCH --product tart
|
||||
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||
binary_artifacts:
|
||||
path: .build/debug/tart
|
||||
path: .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||
|
||||
task:
|
||||
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
||||
@@ -85,7 +89,6 @@ task:
|
||||
install_script:
|
||||
- brew install go goreleaser/tap/goreleaser-pro
|
||||
- brew install mitchellh/gon/gon
|
||||
- curl -sL https://sentry.io/get-cli/ | sh
|
||||
info_script:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
|
||||
+17
-14
@@ -3,27 +3,35 @@ project_name: tart
|
||||
before:
|
||||
hooks:
|
||||
- .ci/set-version.sh
|
||||
- swift build -c release --product tart
|
||||
- swift build --arch x86_64 -c release --product tart
|
||||
- swift build --arch arm64 -c release --product tart
|
||||
- gon gon.hcl
|
||||
- mkdir -p tart.app/Contents/MacOS
|
||||
- cp .build/arm64-apple-macosx/release/tart tart.app/Contents/MacOS/
|
||||
|
||||
builds:
|
||||
- builder: prebuilt
|
||||
- id: tart
|
||||
builder: prebuilt
|
||||
goamd64: [v1]
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- arm64
|
||||
- amd64
|
||||
binary: tart.app/Contents/MacOS/tart
|
||||
prebuilt:
|
||||
path: tart.app/Contents/MacOS/tart
|
||||
path: '.build/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}-apple-macosx/release/tart'
|
||||
|
||||
archives:
|
||||
- name_template: "{{ .ProjectName }}"
|
||||
- name_template: "{{ .ProjectName }}-{{ .Arch }}"
|
||||
files:
|
||||
- src: Resources/embedded.provisionprofile
|
||||
dst: tart.app/Contents
|
||||
strip_parent: true
|
||||
- src: Resources/Info.plist
|
||||
dst: tart.app/Contents
|
||||
strip_parent: true
|
||||
- src: Resources/AppIcon.png
|
||||
dst: tart.app/Contents/Resources
|
||||
strip_parent: true
|
||||
- LICENSE
|
||||
|
||||
release:
|
||||
@@ -31,24 +39,19 @@ release:
|
||||
|
||||
brews:
|
||||
- name: tart
|
||||
tap:
|
||||
repository:
|
||||
owner: cirruslabs
|
||||
name: homebrew-cli
|
||||
caveats: See the GitHub repository for more information
|
||||
homepage: https://github.com/cirruslabs/tart
|
||||
license: "Fair Source"
|
||||
description: Run macOS VMs on Apple Silicon
|
||||
description: Run macOS and Linux VMs on Apple Hardware
|
||||
skip_upload: auto
|
||||
dependencies:
|
||||
- "cirruslabs/cli/softnet"
|
||||
install: |
|
||||
libexec.install Dir["*"]
|
||||
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
|
||||
generate_completions_from_executable(bin/"tart", "--generate-completion-script")
|
||||
custom_block: |
|
||||
depends_on :macos => :ventura
|
||||
|
||||
on_macos do
|
||||
unless Hardware::CPU.arm?
|
||||
odie "Tart only works on Apple Silicon!"
|
||||
end
|
||||
end
|
||||
|
||||
+11
-2
@@ -23,8 +23,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "d277532e1c8af813981ba01f591b15bbdd735615",
|
||||
"version" : "8.8.0"
|
||||
"revision" : "bf7bdd75e25556d0f97ad54fb804b4287863e106",
|
||||
"version" : "8.22.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -108,6 +108,15 @@
|
||||
"version" : "0.50.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftradix",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/orchetect/SwiftRadix",
|
||||
"state" : {
|
||||
"revision" : "a52c37a4c213403f7377ae77b4c68451bcab8330",
|
||||
"version" : "1.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "texttable",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
+3
-1
@@ -18,9 +18,10 @@ let package = Package(
|
||||
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.8.0"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.22.4"),
|
||||
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.0.0"),
|
||||
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.0")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
@@ -34,6 +35,7 @@ let package = Package(
|
||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||
.product(name: "TextTable", package: "TextTable"),
|
||||
.product(name: "Sysctl", package: "swift-sysctl"),
|
||||
.product(name: "SwiftRadix", package: "SwiftRadix"),
|
||||
], exclude: [
|
||||
"OCI/Reference/Makefile",
|
||||
"OCI/Reference/Reference.g4",
|
||||
|
||||
@@ -8,7 +8,7 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
||||
* Use Tart Packer Plugin to automate VM creation.
|
||||
* Easily integrates with any CI system.
|
||||
|
||||
Tart powers [Cirrus Runners](https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral)
|
||||
Tart powers [Cirrus Runners](https://cirrus-runners.app/)
|
||||
service — a drop-in replacement for the standard GitHub-hosted runners, offering 2-3 times better performance for a fraction of the price.
|
||||
|
||||
<p align="center">
|
||||
@@ -17,21 +17,7 @@ service — a drop-in replacement for the standard GitHub-hosted runners, offeri
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Tart is also adopted by several other automation services:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://codemagic.io/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Codemagic.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://testingbot.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Many more companies are using Tart in their internal setups. Here are a few of them:
|
||||
Many companies are using Tart in their internal setups. Here are a few of them:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://ahrefs.com/" target=_blank>
|
||||
@@ -43,6 +29,9 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
||||
<a href="https://mullvad.net/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://shape.dk/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/shape.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://suran.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
||||
</a>
|
||||
@@ -62,6 +51,12 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
||||
|
||||
**Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
|
||||
|
||||
<p align="center">
|
||||
<a href="https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws?utm_source=github&utm_medium=referral" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/AWSMarkeplaceLogo.png" height="90"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Usage
|
||||
|
||||
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>tart</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.cirruslabs.tart</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>tart</string>
|
||||
<key>LSBackgroundOnly</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon.png</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
@@ -73,7 +73,7 @@ struct Clone: AsyncParsableCommand {
|
||||
// APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
|
||||
// is not actually claiming new space until the VM is started and it writes something to disk.
|
||||
// So once we clone the VM let's try to claim a little bit of space for the VM to run.
|
||||
try Prune.reclaimIfNeeded(UInt64(sourceVM.sizeBytes()), sourceVM)
|
||||
try Prune.reclaimIfNeeded(UInt64(sourceVM.allocatedSizeBytes()), sourceVM)
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
|
||||
struct Create: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Create a VM")
|
||||
@@ -22,6 +23,11 @@ struct Create: AsyncParsableCommand {
|
||||
if fromIPSW == nil && !linux {
|
||||
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
||||
}
|
||||
#if arch(x86_64)
|
||||
if fromIPSW != nil {
|
||||
throw ValidationError("Only Linux VMs are supported on Intel!")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
@@ -32,19 +38,29 @@ struct Create: AsyncParsableCommand {
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
if let fromIPSW = fromIPSW {
|
||||
let ipswURL: URL
|
||||
#if arch(arm64)
|
||||
if let fromIPSW = fromIPSW {
|
||||
let ipswURL: URL
|
||||
|
||||
if fromIPSW == "latest" {
|
||||
ipswURL = try await VM.latestIPSWURL()
|
||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||
ipswURL = URL(string: fromIPSW)!
|
||||
} else {
|
||||
ipswURL = URL(fileURLWithPath: fromIPSW)
|
||||
if fromIPSW == "latest" {
|
||||
defaultLogger.appendNewLine("Looking up the latest supported IPSW...")
|
||||
|
||||
let image = try await withCheckedThrowingContinuation { continuation in
|
||||
VZMacOSRestoreImage.fetchLatestSupported() { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
|
||||
ipswURL = image.url
|
||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||
ipswURL = URL(string: fromIPSW)!
|
||||
} else {
|
||||
ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath)
|
||||
}
|
||||
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||
}
|
||||
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||
}
|
||||
#endif
|
||||
|
||||
if linux {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import SystemConfiguration
|
||||
|
||||
struct FQN: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Get a fully-qualified VM name", shouldDisplay: false)
|
||||
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
func run() async throws {
|
||||
if var remoteName = try? RemoteName(name) {
|
||||
let digest = try VMStorageOCI().digest(remoteName)
|
||||
|
||||
remoteName.reference = Reference(digest: digest)
|
||||
|
||||
print(remoteName)
|
||||
} else {
|
||||
print(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
fileprivate struct VMInfo: Encodable {
|
||||
let OS: OS
|
||||
let CPU: Int
|
||||
let Memory: UInt64
|
||||
let Disk: Int
|
||||
let Size: String
|
||||
let Display: String
|
||||
let Running: Bool
|
||||
let State: String
|
||||
@@ -22,10 +24,9 @@ struct Get: AsyncParsableCommand {
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let diskSizeInGb = try vmDir.sizeGB()
|
||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||
|
||||
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb,
|
||||
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000),
|
||||
Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state())
|
||||
print(format.renderSingle(info))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import SwiftUI
|
||||
fileprivate struct VMInfo: Encodable {
|
||||
let Source: String
|
||||
let Name: String
|
||||
let Disk: Int
|
||||
let Size: Int
|
||||
let Running: Bool
|
||||
let State: String
|
||||
@@ -37,13 +38,13 @@ struct List: AsyncParsableCommand {
|
||||
|
||||
if source == nil || source == "local" {
|
||||
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
||||
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
try VMInfo(Source: "local", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
})
|
||||
}
|
||||
|
||||
if source == nil || source == "oci" {
|
||||
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
||||
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
try VMInfo(Source: "oci", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ struct Prune: AsyncParsableCommand {
|
||||
var prunablesToDelete: [Prunable] = []
|
||||
|
||||
for prunable in prunables {
|
||||
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
|
||||
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
|
||||
|
||||
if prunableSizeBytes <= spaceBudgetBytes {
|
||||
// Don't mark for deletion as
|
||||
@@ -158,7 +158,7 @@ struct Prune: AsyncParsableCommand {
|
||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||
|
||||
// Does it even make sense to start?
|
||||
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
||||
let cacheUsedBytes = try prunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
|
||||
if cacheUsedBytes < reclaimBytes {
|
||||
return
|
||||
}
|
||||
@@ -177,9 +177,9 @@ struct Prune: AsyncParsableCommand {
|
||||
continue
|
||||
}
|
||||
|
||||
try SentrySDK.span?.setData(value: prunable.sizeBytes(), key: prunable.url.path)
|
||||
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path)
|
||||
|
||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||
cacheReclaimedBytes += try prunable.allocatedSizeBytes()
|
||||
|
||||
try prunable.delete()
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ struct Rename: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
if localStorage.exists(newName) {
|
||||
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
|
||||
throw ValidationError("failed to rename VM \(name), target VM \(newName) already exists, delete it first!")
|
||||
}
|
||||
|
||||
try localStorage.rename(name, newName)
|
||||
|
||||
+214
-120
@@ -33,22 +33,31 @@ struct Run: AsyncParsableCommand {
|
||||
))
|
||||
var serialPath: String?
|
||||
|
||||
@Flag(help: "Force open a UI window, even when VNC is enabled.")
|
||||
@Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .private))
|
||||
var graphics: Bool = false
|
||||
|
||||
@Flag(help: "Boot into recovery mode")
|
||||
@Flag(help: "Disable audio pass-through to host.")
|
||||
var noAudio: Bool = false
|
||||
|
||||
#if arch(arm64)
|
||||
@Flag(help: "Boot into recovery mode")
|
||||
#endif
|
||||
var recovery: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Use screen sharing instead of the built-in UI.",
|
||||
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
|
||||
+ "Note that Remote Login option should be enabled inside the VM."))
|
||||
#if arch(arm64)
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Use screen sharing instead of the built-in UI.",
|
||||
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
|
||||
+ "Note that Remote Login option should be enabled inside the VM."))
|
||||
#endif
|
||||
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."))
|
||||
#if arch(arm64)
|
||||
@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."))
|
||||
#endif
|
||||
var vncExperimental: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
@@ -66,32 +75,35 @@ struct Run: AsyncParsableCommand {
|
||||
""", valueName: "path[:ro]"))
|
||||
var disk: [String] = []
|
||||
|
||||
@Option(name: [.customLong("rosetta")], help: ArgumentHelp(
|
||||
"Attaches a Rosetta share to the guest Linux VM with a specific tag (e.g. --rosetta=\"rosetta\")",
|
||||
discussion: """
|
||||
Requires host to be macOS 13.0 (Ventura) with Rosetta installed. The latter can be done
|
||||
by running "softwareupdate --install-rosetta" (without quotes) in the Terminal.app.
|
||||
#if arch(arm64)
|
||||
@Option(name: [.customLong("rosetta")], help: ArgumentHelp(
|
||||
"Attaches a Rosetta share to the guest Linux VM with a specific tag (e.g. --rosetta=\"rosetta\")",
|
||||
discussion: """
|
||||
Requires host to be macOS 13.0 (Ventura) with Rosetta installed. The latter can be done
|
||||
by running "softwareupdate --install-rosetta" (without quotes) in the Terminal.app.
|
||||
|
||||
Note that you also have to configure Rosetta in the guest Linux VM by following the
|
||||
steps from "Mount the Shared Directory and Register Rosetta" section here:
|
||||
https://developer.apple.com/documentation/virtualization/running_intel_binaries_in_linux_vms_with_rosetta#3978496
|
||||
""",
|
||||
valueName: "tag"
|
||||
))
|
||||
Note that you also have to configure Rosetta in the guest Linux VM by following the
|
||||
steps from "Mount the Shared Directory and Register Rosetta" section here:
|
||||
https://developer.apple.com/documentation/virtualization/running_intel_binaries_in_linux_vms_with_rosetta#3978496
|
||||
""",
|
||||
valueName: "tag"
|
||||
))
|
||||
#endif
|
||||
var rosettaTag: String?
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")
|
||||
""", discussion: """
|
||||
Requires host to be macOS 13.0 (Ventura) or newer.
|
||||
A shared directory is automatically mounted to "/Volumes/My Shared Files" directory on macOS,
|
||||
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
|
||||
For macOS guests, they must be running macOS 13.0 (Ventura) or newer.
|
||||
@Option(help: ArgumentHelp("Additional directory shares with an optional read-only and mount tag options (e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")", discussion: """
|
||||
Requires host to be macOS 13.0 (Ventura) or newer. macOS guests must be running macOS 13.0 (Ventura) or newer too.
|
||||
|
||||
In case of passing multiple directories it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\"
|
||||
These names will be used as directory names under the mounting point inside guests. For the example above it will be
|
||||
"/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively.
|
||||
""", valueName: "[name:]path[:ro]"))
|
||||
Options are comma-separated and are as follows:
|
||||
|
||||
* ro — mount this directory share in read-only mode instead of the default read-write (e.g. --dir=\"~/src/sources:ro\")
|
||||
|
||||
* tag=<TAG> — by default, the \"com.apple.virtio-fs.automount\" mount tag is used for all directory shares. On macOS, this causes the directories to be automatically mounted to "/Volumes/My Shared Files" directory. On Linux, you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
|
||||
|
||||
Mount tag can be overridden by appending tag property to the directory share (e.g. --dir=\"~/src/build:tag=build\" or --dir=\"~/src/build:ro,tag=build\"). Then it can be mounted via "mount_virtiofs build ~/build" inside guest macOS and "mount -t virtiofs build ~/build" inside guest Linux.
|
||||
|
||||
In case of passing multiple directories per mount tag it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\". These names will be used as directory names under the mounting point inside guests. For the example above it will be "/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively.
|
||||
""", valueName: "[name:]path[:options]"))
|
||||
var dir: [String] = []
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
@@ -105,11 +117,21 @@ struct Run: AsyncParsableCommand {
|
||||
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
|
||||
var netSoftnet: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\"."))
|
||||
@Option(help: ArgumentHelp("Comma-separated list of CIDRs to allow the traffic to when using Softnet isolation\n(e.g. --net-softnet-allow=192.168.0.0/24)", valueName: "comma-separated CIDRs"))
|
||||
var netSoftnetAllow: String?
|
||||
|
||||
@Flag(help: ArgumentHelp("Restrict network access to the host-only network"))
|
||||
var netHost: Bool = false
|
||||
|
||||
#if arch(arm64)
|
||||
@Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\"."))
|
||||
#endif
|
||||
var suspendable: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp("Whether system hot keys should be sent to the guest instead of the host",
|
||||
discussion: "If enabled then system hot keys like Cmd+Tab will be sent to the guest instead of the host."))
|
||||
#if arch(arm64)
|
||||
@Flag(help: ArgumentHelp("Whether system hot keys should be sent to the guest instead of the host",
|
||||
discussion: "If enabled then system hot keys like Cmd+Tab will be sent to the guest instead of the host."))
|
||||
#endif
|
||||
var captureSystemKeys: Bool = false
|
||||
|
||||
mutating func validate() throws {
|
||||
@@ -117,8 +139,14 @@ struct Run: AsyncParsableCommand {
|
||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||
}
|
||||
|
||||
if netBridged.count > 0 && netSoftnet {
|
||||
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
|
||||
// check that not more than one network option is specified
|
||||
var netFlags = 0
|
||||
if netBridged.count > 0 { netFlags += 1 }
|
||||
if netSoftnet { netFlags += 1 }
|
||||
if netHost { netFlags += 1 }
|
||||
|
||||
if netFlags > 1 {
|
||||
throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive")
|
||||
}
|
||||
|
||||
if graphics && noGraphics {
|
||||
@@ -140,6 +168,12 @@ struct Run: AsyncParsableCommand {
|
||||
throw ValidationError("Suspending VMs with shared directories is not supported")
|
||||
}
|
||||
}
|
||||
|
||||
for disk in disk {
|
||||
if disk.hasSuffix("-amd64.iso") {
|
||||
throw ValidationError("Seems you have a disk targeting x86 architecture (hence amd64 in the name). Please use an 'arm64' version of the disk.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -162,7 +196,7 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
if netSoftnet && isInteractiveSession() {
|
||||
if (netSoftnet || netHost) && isInteractiveSession() {
|
||||
try Softnet.configureSUIDBitIfNeeded()
|
||||
}
|
||||
|
||||
@@ -192,7 +226,8 @@ struct Run: AsyncParsableCommand {
|
||||
additionalStorageDevices: additionalDiskAttachments,
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable
|
||||
suspendable: suspendable,
|
||||
audio: !noAudio
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
@@ -218,7 +253,7 @@ struct Run: AsyncParsableCommand {
|
||||
// configuration file, otherwise we will loose the lock.
|
||||
//
|
||||
// [1]: https://man.openbsd.org/fcntl
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
let lock = try vmDir.lock()
|
||||
if try !lock.trylock() {
|
||||
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
|
||||
}
|
||||
@@ -230,15 +265,17 @@ struct Run: AsyncParsableCommand {
|
||||
do {
|
||||
var resume = false
|
||||
|
||||
if #available(macOS 14, *) {
|
||||
if FileManager.default.fileExists(atPath: vmDir.stateURL.path) {
|
||||
print("restoring VM state from a snapshot...")
|
||||
try await vm!.virtualMachine.restoreMachineStateFrom(url: vmDir.stateURL)
|
||||
try FileManager.default.removeItem(at: vmDir.stateURL)
|
||||
resume = true
|
||||
print("resuming VM...")
|
||||
#if arch(arm64)
|
||||
if #available(macOS 14, *) {
|
||||
if FileManager.default.fileExists(atPath: vmDir.stateURL.path) {
|
||||
print("restoring VM state from a snapshot...")
|
||||
try await vm!.virtualMachine.restoreMachineStateFrom(url: vmDir.stateURL)
|
||||
try FileManager.default.removeItem(at: vmDir.stateURL)
|
||||
resume = true
|
||||
print("resuming VM...")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
try await vm!.start(recovery: recovery, resume: resume)
|
||||
|
||||
@@ -284,23 +321,25 @@ struct Run: AsyncParsableCommand {
|
||||
sigusr1Src.setEventHandler {
|
||||
Task {
|
||||
do {
|
||||
if #available(macOS 14, *) {
|
||||
try vm!.configuration.validateSaveRestoreSupport()
|
||||
#if arch(arm64)
|
||||
if #available(macOS 14, *) {
|
||||
try vm!.configuration.validateSaveRestoreSupport()
|
||||
|
||||
print("pausing VM to take a snapshot...")
|
||||
try await vm!.virtualMachine.pause()
|
||||
print("pausing VM to take a snapshot...")
|
||||
try await vm!.virtualMachine.pause()
|
||||
|
||||
print("creating a snapshot...")
|
||||
try await vm!.virtualMachine.saveMachineStateTo(url: vmDir.stateURL)
|
||||
print("creating a snapshot...")
|
||||
try await vm!.virtualMachine.saveMachineStateTo(url: vmDir.stateURL)
|
||||
|
||||
print("snapshot created successfully! shutting down the VM...")
|
||||
print("snapshot created successfully! shutting down the VM...")
|
||||
|
||||
task.cancel()
|
||||
} else {
|
||||
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
|
||||
task.cancel()
|
||||
} else {
|
||||
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
Foundation.exit(1)
|
||||
}
|
||||
#endif
|
||||
} catch (let e) {
|
||||
print(RuntimeError.SuspendFailed(e.localizedDescription))
|
||||
|
||||
@@ -312,7 +351,8 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||
if noGraphics || useVNCWithoutGraphics {
|
||||
// enter the main even loop and just wait for the VM to exit
|
||||
// enter the main even loop, without bringing up any UI,
|
||||
// and just wait for the VM to exit.
|
||||
NSApplication.shared.run()
|
||||
} else {
|
||||
runUI(suspendable, captureSystemKeys)
|
||||
@@ -337,7 +377,18 @@ struct Run: AsyncParsableCommand {
|
||||
if netSoftnet {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
return try Softnet(vmMACAddress: config.macAddress.string)
|
||||
var extraArguments: [String] = []
|
||||
|
||||
if let netSoftnetAllow = netSoftnetAllow {
|
||||
extraArguments += ["--allow", netSoftnetAllow]
|
||||
}
|
||||
|
||||
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: extraArguments)
|
||||
}
|
||||
|
||||
if netHost {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"])
|
||||
}
|
||||
|
||||
if netBridged.count > 0 {
|
||||
@@ -422,58 +473,64 @@ struct Run: AsyncParsableCommand {
|
||||
throw UnsupportedOSError("directory sharing", "is")
|
||||
}
|
||||
|
||||
var directoryShares: [DirectoryShare] = []
|
||||
var allDirectoryShares: [DirectoryShare] = []
|
||||
|
||||
var allNamedShares = true
|
||||
for rawDir in dir {
|
||||
let directoryShare = try DirectoryShare(parseFrom: rawDir)
|
||||
if (directoryShare.name == nil) {
|
||||
allNamedShares = false
|
||||
allDirectoryShares.append(try DirectoryShare(parseFrom: rawDir))
|
||||
}
|
||||
|
||||
return try Dictionary(grouping: allDirectoryShares, by: {$0.mountTag}).map { mountTag, directoryShares in
|
||||
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: mountTag)
|
||||
|
||||
var allNamedShares = true
|
||||
for directoryShare in directoryShares {
|
||||
if directoryShare.name == nil {
|
||||
allNamedShares = false
|
||||
}
|
||||
}
|
||||
directoryShares.append(directoryShare)
|
||||
if directoryShares.count == 1 && directoryShares.first!.name == nil {
|
||||
let directoryShare = directoryShares.first!
|
||||
let singleDirectoryShare = VZSingleDirectoryShare(directory: try directoryShare.createConfiguration())
|
||||
sharingDevice.share = singleDirectoryShare
|
||||
} else if !allNamedShares {
|
||||
throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named")
|
||||
} else {
|
||||
var directories: [String : VZSharedDirectory] = Dictionary()
|
||||
try directoryShares.forEach { directories[$0.name!] = try $0.createConfiguration() }
|
||||
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
|
||||
}
|
||||
|
||||
return sharingDevice
|
||||
}
|
||||
|
||||
|
||||
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
||||
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
|
||||
if allNamedShares {
|
||||
var directories: [String : VZSharedDirectory] = Dictionary()
|
||||
try directoryShares.forEach { directories[$0.name!] = try $0.createConfiguration() }
|
||||
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
|
||||
} else if dir.count > 1 {
|
||||
throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named")
|
||||
} else if dir.count == 1 {
|
||||
let directoryShare = directoryShares.first!
|
||||
let singleDirectoryShare = VZSingleDirectoryShare(directory: try directoryShare.createConfiguration())
|
||||
sharingDevice.share = singleDirectoryShare
|
||||
}
|
||||
|
||||
return [sharingDevice]
|
||||
}
|
||||
|
||||
private func rosettaDirectoryShare() throws -> [VZDirectorySharingDeviceConfiguration] {
|
||||
guard let rosettaTag = rosettaTag else {
|
||||
return []
|
||||
}
|
||||
#if arch(arm64)
|
||||
guard #available(macOS 13, *) else {
|
||||
throw UnsupportedOSError("Rosetta directory share", "is")
|
||||
}
|
||||
|
||||
guard #available(macOS 13, *) else {
|
||||
throw UnsupportedOSError("Rosetta directory share", "is")
|
||||
}
|
||||
switch VZLinuxRosettaDirectoryShare.availability {
|
||||
case .notInstalled:
|
||||
throw UnsupportedOSError("Rosetta directory share", "is", "that have Rosetta installed")
|
||||
case .notSupported:
|
||||
throw UnsupportedOSError("Rosetta directory share", "is", "running Apple silicon")
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
switch VZLinuxRosettaDirectoryShare.availability {
|
||||
case .notInstalled:
|
||||
throw UnsupportedOSError("Rosetta directory share", "is", "that have Rosetta installed")
|
||||
case .notSupported:
|
||||
throw UnsupportedOSError("Rosetta directory share", "is", "running Apple silicon")
|
||||
default:
|
||||
break
|
||||
}
|
||||
try VZVirtioFileSystemDeviceConfiguration.validateTag(rosettaTag)
|
||||
let device = VZVirtioFileSystemDeviceConfiguration(tag: rosettaTag)
|
||||
device.share = try VZLinuxRosettaDirectoryShare()
|
||||
|
||||
try VZVirtioFileSystemDeviceConfiguration.validateTag(rosettaTag)
|
||||
let device = VZVirtioFileSystemDeviceConfiguration(tag: rosettaTag)
|
||||
device.share = try VZLinuxRosettaDirectoryShare()
|
||||
|
||||
return [device]
|
||||
return [device]
|
||||
#elseif arch(x86_64)
|
||||
// there is no Rosetta on Intel
|
||||
return []
|
||||
#endif
|
||||
}
|
||||
|
||||
private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool) {
|
||||
@@ -481,8 +538,6 @@ struct Run: AsyncParsableCommand {
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
|
||||
nsApp.applicationIconImage = NSImage(data: AppIconData)
|
||||
|
||||
struct MainApp: App {
|
||||
static var disappearSignal: Int32 = SIGINT
|
||||
static var capturesSystemKeys: Bool = false
|
||||
@@ -601,7 +656,10 @@ struct VMView: NSViewRepresentable {
|
||||
|
||||
// Enable automatic display reconfiguration
|
||||
// for guests that support it
|
||||
if #available(macOS 14.0, *) {
|
||||
//
|
||||
// This is disabled for Linux because of poor HiDPI
|
||||
// support, which manifests in fonts being too small
|
||||
if #available(macOS 14.0, *), vm.config.os != .linux {
|
||||
machineView.automaticallyReconfiguresDisplay = true
|
||||
}
|
||||
|
||||
@@ -617,30 +675,62 @@ struct DirectoryShare {
|
||||
let name: String?
|
||||
let path: URL
|
||||
let readOnly: Bool
|
||||
let mountTag: String
|
||||
|
||||
init(parseFrom: String) throws {
|
||||
let readOnlySuffix = ":ro"
|
||||
readOnly = parseFrom.hasSuffix(readOnlySuffix)
|
||||
let maybeNameAndURL = readOnly ? String(parseFrom.dropLast(readOnlySuffix.count)) : parseFrom
|
||||
var parseFrom = parseFrom
|
||||
|
||||
// Consume options
|
||||
(self.readOnly, self.mountTag, parseFrom) = Self.parseOptions(parseFrom)
|
||||
|
||||
// Special case for URLs
|
||||
if parseFrom.hasPrefix("http:") || parseFrom.hasPrefix("https:") {
|
||||
self.name = nil
|
||||
self.path = URL(string: parseFrom)!
|
||||
|
||||
if maybeNameAndURL.starts(with: "https://") || maybeNameAndURL.starts(with: "http://") {
|
||||
// just a URL
|
||||
name = nil
|
||||
path = URL(string: maybeNameAndURL)!
|
||||
return
|
||||
}
|
||||
|
||||
let splits = maybeNameAndURL.split(separator: ":", maxSplits: 1)
|
||||
let arguments = parseFrom.split(separator: ":", maxSplits: 1)
|
||||
|
||||
if splits.count == 2 {
|
||||
name = String(splits[0])
|
||||
path = String(splits[1]).toRemoteOrLocalURL()
|
||||
if arguments.count == 2 {
|
||||
self.name = String(arguments[0])
|
||||
self.path = String(arguments[1]).toRemoteOrLocalURL()
|
||||
} else {
|
||||
name = nil
|
||||
path = String(splits[0]).toRemoteOrLocalURL()
|
||||
self.name = nil
|
||||
self.path = String(arguments[0]).toRemoteOrLocalURL()
|
||||
}
|
||||
}
|
||||
|
||||
static func parseOptions(_ parseFrom: String) -> (Bool, String, String) {
|
||||
var arguments = parseFrom.split(separator: ":")
|
||||
let options = arguments.last!.split(separator: ",")
|
||||
|
||||
var readOnly: Bool = false
|
||||
var mountTag: String = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
||||
|
||||
var found: Bool = false
|
||||
|
||||
for option in options {
|
||||
switch true {
|
||||
case option == "ro":
|
||||
readOnly = true
|
||||
found = true
|
||||
case option.hasPrefix("tag="):
|
||||
mountTag = String(option.dropFirst(4))
|
||||
found = true
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
arguments.removeLast()
|
||||
}
|
||||
|
||||
return (readOnly, mountTag, arguments.joined(separator: ":"))
|
||||
}
|
||||
|
||||
func createConfiguration() throws -> VZSharedDirectory {
|
||||
if (path.isFileURL) {
|
||||
return VZSharedDirectory(url: path, readOnly: readOnly)
|
||||
@@ -650,7 +740,7 @@ struct DirectoryShare {
|
||||
|
||||
let archiveRequest = URLRequest(url: path, cachePolicy: .returnCacheDataElseLoad)
|
||||
var response: CachedURLResponse? = urlCache.cachedResponse(for: archiveRequest)
|
||||
if (response == nil) {
|
||||
if (response == nil || response?.data.isEmpty == true) {
|
||||
print("Downloading \(path)...")
|
||||
// download and unarchive remote directories if needed here
|
||||
// use old school API to prevent deadlocks since we are running via MainActor
|
||||
@@ -658,8 +748,12 @@ struct DirectoryShare {
|
||||
Task {
|
||||
do {
|
||||
let (archiveData, archiveResponse) = try await URLSession.shared.data(for: archiveRequest)
|
||||
urlCache.storeCachedResponse(CachedURLResponse(response: archiveResponse, data: archiveData, storagePolicy: .allowed), for: archiveRequest)
|
||||
print("Cached for future invocations!")
|
||||
if archiveData.isEmpty {
|
||||
print("Remote archive is empty!")
|
||||
} else {
|
||||
urlCache.storeCachedResponse(CachedURLResponse(response: archiveResponse, data: archiveData, storagePolicy: .allowed), for: archiveRequest)
|
||||
print("Cached for future invocations!")
|
||||
}
|
||||
} catch {
|
||||
print("Download failed: \(error)")
|
||||
}
|
||||
|
||||
@@ -16,7 +16,18 @@ struct Set: AsyncParsableCommand {
|
||||
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||
var display: VMDisplayConfig?
|
||||
|
||||
@Option(help: .hidden)
|
||||
@Option(help: ArgumentHelp("Resize the VMs disk to the specified size in GB (note that the disk size can only be increased to avoid losing data)",
|
||||
discussion: """
|
||||
Disk resizing works on most cloud-ready Linux distributions out-of-the box (e.g. Ubuntu Cloud Images
|
||||
have the \"cloud-initramfs-growroot\" package installed that runs on boot) and on the rest of the
|
||||
distributions by running the \"growpart\" or \"resize2fs\" commands.
|
||||
|
||||
For macOS, however, things are a bit more complicated: you need to remove the recovery partition
|
||||
first and then run various \"diskutil\" commands, see Tart's packer plugin source code for more
|
||||
details[1].
|
||||
|
||||
[1]: https://github.com/cirruslabs/packer-plugin-tart/blob/main/builder/tart/step_disk_resize.go
|
||||
"""))
|
||||
var diskSize: UInt16?
|
||||
|
||||
func run() async throws {
|
||||
|
||||
@@ -29,12 +29,12 @@ struct Stop: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func stopRunning(_ vmDir: VMDirectory) async throws {
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
let lock = try vmDir.lock()
|
||||
|
||||
// Find the VM's PID
|
||||
var pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||
throw RuntimeError.VMNotRunning(name)
|
||||
}
|
||||
|
||||
// Try to gracefully terminate the VM
|
||||
|
||||
@@ -11,7 +11,7 @@ struct Suspend: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
let lock = try vmDir.lock()
|
||||
|
||||
// Find the VM's PID
|
||||
var pid = try lock.pid()
|
||||
|
||||
@@ -41,13 +41,18 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
let outputData = try outPipe.fileHandleForReading.readToEnd()
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
if let outputData = outputData {
|
||||
print(String(decoding: outputData, as: UTF8.self))
|
||||
}
|
||||
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
|
||||
}
|
||||
if outputData == nil || outputData?.count == 0 {
|
||||
throw CredentialsProviderError.Failed(message: "Docker helper output is empty!")
|
||||
}
|
||||
|
||||
let getOutput = try JSONDecoder().decode(
|
||||
DockerGetOutput.self, from: outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
)
|
||||
let getOutput = try JSONDecoder().decode(DockerGetOutput.self, from: outputData!)
|
||||
return (getOutput.Username, getOutput.Secret)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+24
-11
@@ -3,19 +3,32 @@ import AsyncAlgorithms
|
||||
|
||||
fileprivate let urlSession = createURLSession()
|
||||
|
||||
class Fetcher {
|
||||
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
if viaFile {
|
||||
return try await fetchViaFile(request)
|
||||
}
|
||||
|
||||
return try await fetchViaMemory(request)
|
||||
class DownloadDelegate: NSObject, URLSessionTaskDelegate {
|
||||
let progress: Progress
|
||||
init(_ progress: Progress) throws {
|
||||
self.progress = progress
|
||||
}
|
||||
|
||||
private static func fetchViaMemory(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) {
|
||||
self.progress.addChild(task.progress, withPendingUnitCount: self.progress.totalUnitCount)
|
||||
}
|
||||
}
|
||||
|
||||
class Fetcher {
|
||||
static func fetch(_ request: URLRequest, viaFile: Bool = false, progress: Progress? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
let delegate = progress != nil ? try DownloadDelegate(progress!) : nil
|
||||
|
||||
if viaFile {
|
||||
return try await fetchViaFile(request, delegate: delegate)
|
||||
}
|
||||
|
||||
return try await fetchViaMemory(request, delegate: delegate)
|
||||
}
|
||||
|
||||
private static func fetchViaMemory(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
let (data, response) = try await urlSession.data(for: request, delegate: delegate)
|
||||
|
||||
Task {
|
||||
await dataCh.send(data)
|
||||
@@ -26,10 +39,10 @@ class Fetcher {
|
||||
return (dataCh, response as! HTTPURLResponse)
|
||||
}
|
||||
|
||||
private static func fetchViaFile(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
private static func fetchViaFile(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||
|
||||
let (fileURL, response) = try await urlSession.download(for: request)
|
||||
let (fileURL, response) = try await urlSession.download(for: request, delegate: delegate)
|
||||
|
||||
// Acquire a handle to the downloaded file and then remove it.
|
||||
//
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import SwiftRadix
|
||||
|
||||
struct Lease {
|
||||
var mac: MACAddress
|
||||
var ip: IPv4Address
|
||||
var expiresAt: Date
|
||||
|
||||
init?(fromRawLease: [String : String]) {
|
||||
// Retrieve the required fields
|
||||
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
||||
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
||||
guard let lease = fromRawLease["lease"] else { return nil }
|
||||
|
||||
// Parse MAC address
|
||||
let hwAddressSplits = hwAddress.split(separator: ",")
|
||||
@@ -26,7 +30,13 @@ struct Lease {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse expiration timestamp
|
||||
guard let leaseTimestamp = lease.hex?.value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.ip = ip
|
||||
self.mac = mac
|
||||
self.expiresAt = Date(timeIntervalSince1970: TimeInterval(leaseTimestamp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,13 +37,18 @@ class Leases {
|
||||
}
|
||||
|
||||
init(_ fromString: String) throws {
|
||||
var leases: [MACAddress : Lease] = Dictionary()
|
||||
let leases = try Self.retrieveRawLeases(fromString).compactMap({ rawLease in
|
||||
Lease(fromRawLease: rawLease)
|
||||
}).filter({ lease in
|
||||
lease.expiresAt.isInFuture
|
||||
}).map({ lease in
|
||||
(lease.mac, lease)
|
||||
})
|
||||
|
||||
for lease in try Self.retrieveRawLeases(fromString).compactMap({ Lease(fromRawLease: $0) }) {
|
||||
leases[lease.mac] = lease
|
||||
self.leases = Dictionary(leases) { (left, right) in
|
||||
// When duplicate lease is found, prefer a newer lease over the older one
|
||||
(left.expiresAt > right.expiresAt) ? left : right
|
||||
}
|
||||
|
||||
self.leases = leases
|
||||
}
|
||||
|
||||
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
|
||||
@@ -107,7 +112,7 @@ class Leases {
|
||||
return rawLeases
|
||||
}
|
||||
|
||||
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||
func ResolveMACAddress(macAddress: MACAddress) -> IPv4Address? {
|
||||
leases[macAddress]?.ip
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class Softnet: Network {
|
||||
|
||||
let vmFD: Int32
|
||||
|
||||
init(vmMACAddress: String) throws {
|
||||
init(vmMACAddress: String, extraArguments: [String] = []) throws {
|
||||
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||
|
||||
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
||||
@@ -30,7 +30,7 @@ class Softnet: Network {
|
||||
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
||||
|
||||
process.executableURL = try Self.softnetExecutableURL()
|
||||
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress]
|
||||
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress] + extraArguments
|
||||
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Compression
|
||||
class DiskV2: Disk {
|
||||
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||
private static let holeGranularityBytes = 64 * 1024
|
||||
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||
var pushedLayers: [OCIManifestLayer] = []
|
||||
@@ -92,14 +93,38 @@ class DiskV2: Disk {
|
||||
return
|
||||
}
|
||||
|
||||
// Open the disk file at the specific offset
|
||||
// Open the disk file
|
||||
let disk = try FileHandle(forWritingTo: diskURL)
|
||||
try disk.seek(toOffset: diskWritingOffset)
|
||||
|
||||
// A zero chunk for faster than byte-by-byte comparisons
|
||||
//
|
||||
// Assumes that the other Data(...) is equal in size, but it's fine to get a false-negative
|
||||
// on the last block since it costs only 64 KiB of excess data per 500 MB layer.
|
||||
//
|
||||
// Some simple benchmarks ("sync && sudo purge" command was used to negate the disk caching effects):
|
||||
// +--------------------------------------+---------------------------------------------------+
|
||||
// | Operation | time(1) result |
|
||||
// +--------------------------------------+---------------------------------------------------+
|
||||
// | Data(...) == zeroChunk | 2.16s user 11.71s system 73% cpu 18.928 total |
|
||||
// | Data(...).contains(where: {$0 != 0}) | 603.68s user 12.97s system 99% cpu 10:22.85 total |
|
||||
// +--------------------------------------+---------------------------------------------------+
|
||||
let zeroChunk = Data(count: holeGranularityBytes)
|
||||
var diskWritingOffset = diskWritingOffset
|
||||
|
||||
// Pull and decompress a single layer into the specific offset on disk
|
||||
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||
if let data = data {
|
||||
disk.write(data)
|
||||
guard let data = data else {
|
||||
return
|
||||
}
|
||||
|
||||
for chunk in data.chunks(ofCount: holeGranularityBytes) {
|
||||
// Only write chunks that are not zero
|
||||
if chunk != zeroChunk {
|
||||
try disk.seek(toOffset: diskWritingOffset)
|
||||
disk.write(chunk)
|
||||
}
|
||||
|
||||
diskWritingOffset += UInt64(chunk.count)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+110
-106
@@ -6,127 +6,131 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
struct Darwin: PlatformSuspendable {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
#if arch(arm64)
|
||||
|
||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
struct Darwin: PlatformSuspendable {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
|
||||
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) throws -> VZPlatformConfiguration {
|
||||
let result = VZMacPlatformConfiguration()
|
||||
|
||||
result.machineIdentifier = ecid
|
||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(url: nvramURL)
|
||||
|
||||
if !hardwareModel.isSupported {
|
||||
// At the moment support of M1 chip is not yet dropped in any macOS version
|
||||
// This mean that host software is not supporting this hardware model and should be updated
|
||||
throw UnsupportedHostOSError()
|
||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
|
||||
result.hardwareModel = hardwareModel
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
return result
|
||||
}
|
||||
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
|
||||
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
let result = VZMacGraphicsDeviceConfiguration()
|
||||
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) throws -> VZPlatformConfiguration {
|
||||
let result = VZMacPlatformConfiguration()
|
||||
|
||||
result.machineIdentifier = ecid
|
||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(url: nvramURL)
|
||||
|
||||
if !hardwareModel.isSupported {
|
||||
// At the moment support of M1 chip is not yet dropped in any macOS version
|
||||
// This mean that host software is not supporting this hardware model and should be updated
|
||||
throw UnsupportedHostOSError()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if let hostMainScreen = NSScreen.main {
|
||||
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
||||
result.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
]
|
||||
func keyboards() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||
return [VZUSBKeyboardConfiguration(), VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
return [VZUSBKeyboardConfiguration()]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return keyboards()
|
||||
}
|
||||
}
|
||||
|
||||
func keyboards() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||
return [VZMacKeyboardConfiguration(), VZUSBKeyboardConfiguration()]
|
||||
} else {
|
||||
return [VZUSBKeyboardConfiguration()]
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
// Trackpad is only supported by guests starting with macOS Ventura
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return pointingDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return keyboards()
|
||||
}
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
// Trackpad is only supported by guests starting with macOS Ventura
|
||||
[VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return pointingDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -8,5 +8,8 @@ protocol Prunable {
|
||||
var url: URL { get }
|
||||
func delete() throws
|
||||
func accessDate() throws -> Date
|
||||
// size on disk as seen in Finder including empty blocks
|
||||
func sizeBytes() throws -> Int
|
||||
// actual size on disk without empty blocks
|
||||
func allocatedSizeBytes() throws -> Int
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ struct Root: AsyncParsableCommand {
|
||||
Rename.self,
|
||||
Stop.self,
|
||||
Delete.self,
|
||||
FQN.self,
|
||||
])
|
||||
|
||||
public static func main() async throws {
|
||||
@@ -49,6 +50,10 @@ struct Root: AsyncParsableCommand {
|
||||
}
|
||||
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
||||
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setExtra(value: ProcessInfo.processInfo.arguments, key: "Command-line arguments")
|
||||
}
|
||||
|
||||
// Enrich future events with Cirrus CI-specific tags
|
||||
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
||||
SentrySDK.configureScope { scope in
|
||||
|
||||
@@ -9,7 +9,11 @@ extension URL: Prunable {
|
||||
try FileManager.default.removeItem(at: self)
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
func allocatedSizeBytes() throws -> Int {
|
||||
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try resourceValues(forKeys: [.totalFileSizeKey]).totalFileSize!
|
||||
}
|
||||
}
|
||||
|
||||
+111
-94
@@ -45,7 +45,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = [],
|
||||
suspendable: Bool = false
|
||||
suspendable: Bool = false,
|
||||
audio: Bool = true
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
@@ -61,7 +62,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
network: network, additionalStorageDevices: additionalStorageDevices,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable
|
||||
suspendable: suspendable,
|
||||
audio: audio
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -89,12 +91,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Download the IPSW
|
||||
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
|
||||
|
||||
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
|
||||
let downloadProgress = Progress(totalUnitCount: 100)
|
||||
ProgressObserver(downloadProgress).log(defaultLogger)
|
||||
|
||||
let progress = Progress(totalUnitCount: response.expectedContentLength)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
let request = URLRequest(url: remoteURL)
|
||||
let (channel, response) = try await Fetcher.fetch(request, viaFile: true, progress: downloadProgress)
|
||||
|
||||
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw")
|
||||
defaultLogger.appendNewLine("Computing digest for \(temporaryLocation.path)...")
|
||||
let digestProgress = Progress(totalUnitCount: response.expectedContentLength)
|
||||
ProgressObserver(digestProgress).log(defaultLogger)
|
||||
|
||||
FileManager.default.createFile(atPath: temporaryLocation.path, contents: nil)
|
||||
let lock = try FileLock(lockURL: temporaryLocation)
|
||||
try lock.lock()
|
||||
@@ -106,7 +113,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
let chunkAsData = Data(chunk)
|
||||
fileHandle.write(chunkAsData)
|
||||
digest.update(chunkAsData)
|
||||
progress.completedUnitCount += Int64(chunk.count)
|
||||
digestProgress.completedUnitCount += Int64(chunk.count)
|
||||
}
|
||||
|
||||
try fileHandle.close()
|
||||
@@ -116,18 +123,6 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
return try FileManager.default.replaceItemAt(finalLocation, withItemAt: temporaryLocation)!
|
||||
}
|
||||
|
||||
static func latestIPSWURL() async throws -> URL {
|
||||
defaultLogger.appendNewLine("Looking up the latest supported IPSW...")
|
||||
|
||||
let image = try await withCheckedThrowingContinuation { continuation in
|
||||
VZMacOSRestoreImage.fetchLatestSupported() { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
|
||||
return image.url
|
||||
}
|
||||
|
||||
var inFinalState: Bool {
|
||||
get {
|
||||
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
||||
@@ -137,82 +132,90 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
vmDir: VMDirectory,
|
||||
ipswURL: URL,
|
||||
diskSizeGB: UInt16,
|
||||
network: Network = NetworkShared(),
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = []
|
||||
) async throws {
|
||||
var ipswURL = ipswURL
|
||||
#if arch(arm64)
|
||||
init(
|
||||
vmDir: VMDirectory,
|
||||
ipswURL: URL,
|
||||
diskSizeGB: UInt16,
|
||||
network: Network = NetworkShared(),
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = []
|
||||
) async throws {
|
||||
var ipswURL = ipswURL
|
||||
|
||||
if !ipswURL.isFileURL {
|
||||
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
||||
}
|
||||
|
||||
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
|
||||
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
|
||||
// cannot deal with paths that contain symlinks, so expand them here first.
|
||||
ipswURL.resolveSymlinksInPath()
|
||||
|
||||
// Load the restore image and try to get the requirements
|
||||
// that match both the image and our platform
|
||||
let image = try await withCheckedThrowingContinuation { continuation in
|
||||
VZMacOSRestoreImage.load(from: ipswURL) { result in
|
||||
continuation.resume(with: result)
|
||||
if !ipswURL.isFileURL {
|
||||
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
||||
}
|
||||
}
|
||||
|
||||
guard let requirements = image.mostFeaturefulSupportedConfiguration else {
|
||||
throw UnsupportedRestoreImageError()
|
||||
}
|
||||
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
|
||||
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
|
||||
// cannot deal with paths that contain symlinks, so expand them here first.
|
||||
ipswURL.resolveSymlinksInPath()
|
||||
|
||||
// Create NVRAM
|
||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
|
||||
name = vmDir.name
|
||||
// Create config
|
||||
config = VMConfig(
|
||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
||||
)
|
||||
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
||||
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
self.network = network
|
||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, network: network,
|
||||
additionalStorageDevices: additionalStorageDevices,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
virtualMachine.delegate = self
|
||||
|
||||
// Run automated installation
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
DispatchQueue.main.async { [ipswURL] in
|
||||
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: ipswURL)
|
||||
|
||||
defaultLogger.appendNewLine("Installing OS...")
|
||||
ProgressObserver(installer.progress).log(defaultLogger)
|
||||
|
||||
installer.install { result in
|
||||
// Load the restore image and try to get the requirements
|
||||
// that match both the image and our platform
|
||||
let image = try await withCheckedThrowingContinuation { continuation in
|
||||
VZMacOSRestoreImage.load(from: ipswURL) { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
|
||||
guard let requirements = image.mostFeaturefulSupportedConfiguration else {
|
||||
throw UnsupportedRestoreImageError()
|
||||
}
|
||||
|
||||
// Create NVRAM
|
||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
|
||||
name = vmDir.name
|
||||
// Create config
|
||||
config = VMConfig(
|
||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
||||
)
|
||||
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
||||
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
self.network = network
|
||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, network: network,
|
||||
additionalStorageDevices: additionalStorageDevices,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
virtualMachine.delegate = self
|
||||
|
||||
// Run automated installation
|
||||
try await install(ipswURL)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func install(_ url: URL) async throws {
|
||||
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: url)
|
||||
defaultLogger.appendNewLine("Installing OS...")
|
||||
ProgressObserver(installer.progress).log(defaultLogger)
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
installer.install { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
}, onCancel: {
|
||||
installer.progress.cancel()
|
||||
})
|
||||
}
|
||||
#endif
|
||||
|
||||
@available(macOS 13, *)
|
||||
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
||||
@@ -257,9 +260,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
@MainActor
|
||||
private func start(_ recovery: Bool) async throws {
|
||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||
startOptions.startUpFromMacOSRecovery = recovery
|
||||
try await virtualMachine.start(options: startOptions)
|
||||
#if arch(arm64)
|
||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||
startOptions.startUpFromMacOSRecovery = recovery
|
||||
try await virtualMachine.start(options: startOptions)
|
||||
#else
|
||||
try await virtualMachine.start()
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -280,7 +287,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||
serialPorts: [VZSerialPortConfiguration],
|
||||
suspendable: Bool = false
|
||||
suspendable: Bool = false,
|
||||
audio: Bool = true
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -298,7 +306,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
||||
|
||||
// Audio
|
||||
if !suspendable {
|
||||
if audio && !suspendable {
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||
@@ -326,9 +334,18 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
// Storage
|
||||
var devices: [VZStorageDeviceConfiguration] = [
|
||||
VZVirtioBlockDeviceConfiguration(attachment: try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false))
|
||||
]
|
||||
let attachment: VZDiskImageStorageDeviceAttachment = vmConfig.os == .linux ?
|
||||
// Use "cached" caching mode for virtio drive to prevent fs corruption on linux
|
||||
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .cached, synchronizationMode: .full) :
|
||||
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
|
||||
|
||||
var device: VZStorageDeviceConfiguration
|
||||
if #available(macOS 14, *), vmConfig.os == .linux {
|
||||
device = VZNVMExpressControllerDeviceConfiguration(attachment: attachment)
|
||||
} else {
|
||||
device = VZVirtioBlockDeviceConfiguration(attachment: attachment)
|
||||
}
|
||||
var devices: [VZStorageDeviceConfiguration] = [device]
|
||||
devices.append(contentsOf: additionalStorageDevices)
|
||||
configuration.storageDevices = devices
|
||||
|
||||
|
||||
@@ -95,7 +95,14 @@ struct VMConfig: Codable {
|
||||
arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
|
||||
switch os {
|
||||
case .darwin:
|
||||
platform = try Darwin(from: decoder)
|
||||
#if arch(arm64)
|
||||
platform = try Darwin(from: decoder)
|
||||
#else
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .os,
|
||||
in: container,
|
||||
debugDescription: "Darwin VMs are only supported on Apple Silicon hosts")
|
||||
#endif
|
||||
case .linux:
|
||||
platform = try Linux(from: decoder)
|
||||
}
|
||||
@@ -132,20 +139,30 @@ struct VMConfig: Codable {
|
||||
}
|
||||
|
||||
mutating func setCPU(cpuCount: Int) throws {
|
||||
if cpuCount < cpuCountMin {
|
||||
if os == .darwin && cpuCount < cpuCountMin {
|
||||
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
||||
+ " at minimum (requested \(cpuCount))")
|
||||
}
|
||||
|
||||
if cpuCount < VZVirtualMachineConfiguration.minimumAllowedCPUCount {
|
||||
throw LessThanMinimalResourcesError("VM should have \(VZVirtualMachineConfiguration.minimumAllowedCPUCount) CPU cores"
|
||||
+ " at minimum (requested \(cpuCount))")
|
||||
}
|
||||
|
||||
self.cpuCount = cpuCount
|
||||
}
|
||||
|
||||
mutating func setMemory(memorySize: UInt64) throws {
|
||||
if memorySize < memorySizeMin {
|
||||
if os == .darwin && memorySize < memorySizeMin {
|
||||
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
||||
+ " of memory at minimum (requested \(memorySize))")
|
||||
}
|
||||
|
||||
if memorySize < VZVirtualMachineConfiguration.minimumAllowedMemorySize {
|
||||
throw LessThanMinimalResourcesError("VM should have \(VZVirtualMachineConfiguration.minimumAllowedMemorySize) bytes"
|
||||
+ " of memory at minimum (requested \(memorySize))")
|
||||
}
|
||||
|
||||
self.memorySize = memorySize
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,13 +30,17 @@ struct VMDirectory: Prunable {
|
||||
baseURL
|
||||
}
|
||||
|
||||
func lock() throws -> PIDLock {
|
||||
try PIDLock(lockURL: configURL)
|
||||
}
|
||||
|
||||
func running() throws -> Bool {
|
||||
// The most common reason why PIDLock() instantiation fails is a race with "tart delete" (ENOENT),
|
||||
// which is fine to report as "not running".
|
||||
//
|
||||
// The other reasons are unlikely and the cost of getting a false positive is way less than
|
||||
// the cost of crashing with an exception when calling "tart list" on a busy machine, for example.
|
||||
guard let lock = try? PIDLock(lockURL: configURL) else {
|
||||
guard let lock = try? lock() else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -130,20 +134,45 @@ struct VMDirectory: Prunable {
|
||||
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
||||
}
|
||||
|
||||
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
||||
// macOS considers kilo being 1000 and not 1024
|
||||
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
|
||||
let currentDiskFileLength = try diskFileHandle.seekToEnd()
|
||||
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||
if desiredDiskFileLength < currentDiskFileLength {
|
||||
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength))
|
||||
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength))
|
||||
throw RuntimeError.InvalidDiskSize("new disk size of \(desiredLengthHuman) should be larger " +
|
||||
"than the current disk size of \(currentLengthHuman)")
|
||||
} else if desiredDiskFileLength > currentDiskFileLength {
|
||||
try diskFileHandle.truncate(atOffset: desiredDiskFileLength)
|
||||
}
|
||||
try diskFileHandle.close()
|
||||
}
|
||||
|
||||
func delete() throws {
|
||||
let lock = try lock()
|
||||
|
||||
if try !lock.trylock() {
|
||||
throw RuntimeError.VMIsRunning(name)
|
||||
}
|
||||
|
||||
try FileManager.default.removeItem(at: baseURL)
|
||||
|
||||
try lock.unlock()
|
||||
}
|
||||
|
||||
func accessDate() throws -> Date {
|
||||
try baseURL.accessDate()
|
||||
}
|
||||
|
||||
func allocatedSizeBytes() throws -> Int {
|
||||
try configURL.allocatedSizeBytes() + diskURL.allocatedSizeBytes() + nvramURL.allocatedSizeBytes()
|
||||
}
|
||||
|
||||
func allocatedSizeGB() throws -> Int {
|
||||
try allocatedSizeBytes() / 1000 / 1000 / 1000
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||
}
|
||||
|
||||
@@ -50,10 +50,12 @@ enum RuntimeError : Error {
|
||||
case VMConfigurationError(_ message: String)
|
||||
case VMDoesNotExist(name: String)
|
||||
case VMMissingFiles(_ message: String)
|
||||
case VMNotRunning(_ message: String)
|
||||
case VMIsRunning(_ name: String)
|
||||
case VMNotRunning(_ name: String)
|
||||
case VMAlreadyRunning(_ message: String)
|
||||
case NoIPAddressFound(_ message: String)
|
||||
case DiskAlreadyInUse(_ message: String)
|
||||
case InvalidDiskSize(_ message: String)
|
||||
case FailedToUpdateAccessDate(_ message: String)
|
||||
case PIDLockFailed(_ message: String)
|
||||
case FailedToParseRemoteName(_ message: String)
|
||||
@@ -81,14 +83,18 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return "the specified VM \"\(name)\" does not exist"
|
||||
case .VMMissingFiles(let message):
|
||||
return message
|
||||
case .VMNotRunning(let message):
|
||||
return message
|
||||
case .VMIsRunning(let name):
|
||||
return "VM \"\(name)\" is running"
|
||||
case .VMNotRunning(let name):
|
||||
return "VM \"\(name)\" is not running"
|
||||
case .VMAlreadyRunning(let message):
|
||||
return message
|
||||
case .NoIPAddressFound(let message):
|
||||
return message
|
||||
case .DiskAlreadyInUse(let message):
|
||||
return message
|
||||
case .InvalidDiskSize(let message):
|
||||
return message
|
||||
case .FailedToUpdateAccessDate(let message):
|
||||
return message
|
||||
case .PIDLockFailed(let message):
|
||||
|
||||
@@ -39,7 +39,7 @@ class VMStorageLocal: PrunableStorage {
|
||||
}
|
||||
|
||||
func delete(_ name: String) throws {
|
||||
try FileManager.default.removeItem(at: vmURL(name))
|
||||
try VMDirectory(baseURL: vmURL(name)).delete()
|
||||
}
|
||||
|
||||
func list() throws -> [(String, VMDirectory)] {
|
||||
|
||||
@@ -134,7 +134,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt) async throws {
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["imageName": name], key: "OCI")
|
||||
scope.setContext(value: ["imageName": name.description], key: "OCI")
|
||||
}
|
||||
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
@@ -219,9 +219,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
func link(from: RemoteName, to: RemoteName) throws {
|
||||
if FileManager.default.fileExists(atPath: vmURL(from).path) {
|
||||
try FileManager.default.removeItem(at: vmURL(from))
|
||||
}
|
||||
try? FileManager.default.removeItem(at: vmURL(from))
|
||||
|
||||
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
import Virtualization
|
||||
|
||||
final class DirectoryShareTests: XCTestCase {
|
||||
func testNamedParsing() throws {
|
||||
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build")
|
||||
@@ -8,25 +10,65 @@ final class DirectoryShareTests: XCTestCase {
|
||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertFalse(share.readOnly)
|
||||
}
|
||||
|
||||
|
||||
func testNamedReadOnlyParsing() throws {
|
||||
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build:ro")
|
||||
XCTAssertEqual(share.name, "build")
|
||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertTrue(share.readOnly)
|
||||
}
|
||||
|
||||
|
||||
func testOptionalNameParsing() throws {
|
||||
let share = try DirectoryShare(parseFrom: "/Users/admin/build")
|
||||
XCTAssertNil(share.name)
|
||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertFalse(share.readOnly)
|
||||
}
|
||||
|
||||
|
||||
func testOptionalNameReadOnlyParsing() throws {
|
||||
let share = try DirectoryShare(parseFrom: "/Users/admin/build:ro")
|
||||
XCTAssertNil(share.name)
|
||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertTrue(share.readOnly)
|
||||
}
|
||||
|
||||
func testMountTagParsing() throws {
|
||||
let share = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar")
|
||||
XCTAssertNil(share.name)
|
||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertFalse(share.readOnly)
|
||||
XCTAssertEqual(share.mountTag, "foo-bar")
|
||||
|
||||
let roShare = try DirectoryShare(parseFrom: "/Users/admin/build:ro,tag=foo-bar")
|
||||
XCTAssertNil(roShare.name)
|
||||
XCTAssertEqual(roShare.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertTrue(roShare.readOnly)
|
||||
XCTAssertEqual(roShare.mountTag, "foo-bar")
|
||||
|
||||
let inverseRoShare = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar,ro")
|
||||
XCTAssertNil(inverseRoShare.name)
|
||||
XCTAssertEqual(inverseRoShare.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertTrue(inverseRoShare.readOnly)
|
||||
XCTAssertEqual(inverseRoShare.mountTag, "foo-bar")
|
||||
}
|
||||
|
||||
func testURL() throws {
|
||||
let archiveWithoutNameOrOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz")
|
||||
XCTAssertNil(archiveWithoutNameOrOptions.name)
|
||||
XCTAssertEqual(archiveWithoutNameOrOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||
XCTAssertFalse(archiveWithoutNameOrOptions.readOnly)
|
||||
XCTAssertEqual(archiveWithoutNameOrOptions.mountTag, VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag)
|
||||
|
||||
let archiveWithOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz:ro,tag=sometag")
|
||||
XCTAssertNil(archiveWithOptions.name)
|
||||
XCTAssertEqual(archiveWithOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||
XCTAssertTrue(archiveWithOptions.readOnly)
|
||||
XCTAssertEqual(archiveWithOptions.mountTag, "sometag")
|
||||
|
||||
let archiveWithNameAndOptions = try DirectoryShare(parseFrom: "somename:https://example.com/archive.tar.gz:ro,tag=sometag")
|
||||
XCTAssertEqual(archiveWithNameAndOptions.name, "somename")
|
||||
XCTAssertEqual(archiveWithNameAndOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||
XCTAssertTrue(archiveWithNameAndOptions.readOnly)
|
||||
XCTAssertEqual(archiveWithNameAndOptions.mountTag, "sometag")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
import Network
|
||||
import SwiftRadix
|
||||
|
||||
final class LeaseTests: XCTestCase {
|
||||
func testCorrectTimezone() throws {
|
||||
let lease = Lease(fromRawLease: [
|
||||
"hw_address": "1,11:22:33:44:55:66",
|
||||
"ip_address": "1.2.3.4",
|
||||
"lease": "0x6565da9e",
|
||||
])
|
||||
|
||||
XCTAssertNotNil(lease)
|
||||
XCTAssertEqual(lease!.expiresAt.toISO(), "2023-11-28T12:18:38Z")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
import Network
|
||||
import SwiftDate
|
||||
|
||||
final class LeasesTests: XCTestCase {
|
||||
func testNoExpired() throws {
|
||||
let macAddress = MACAddress(fromString: "11:22:33:44:55:66")!
|
||||
|
||||
let leases = try Leases("""
|
||||
{
|
||||
name=whatever
|
||||
ip_address=66.66.66.66
|
||||
hw_address=1,\(macAddress)
|
||||
identifier=1,\(macAddress)
|
||||
lease=\(Int((Date() - 1.seconds).timeIntervalSince1970).hex)
|
||||
|
||||
}
|
||||
{
|
||||
name=whatever
|
||||
ip_address=1.2.3.4
|
||||
hw_address=1,\(macAddress)
|
||||
identifier=1,\(macAddress)
|
||||
lease=\(Int((Date() + 10.minutes).timeIntervalSince1970).hex)
|
||||
}
|
||||
{
|
||||
name=whatever
|
||||
ip_address=66.66.66.66
|
||||
hw_address=1,\(macAddress)
|
||||
identifier=1,\(macAddress)
|
||||
lease=\(Int((Date() - 1.seconds).timeIntervalSince1970).hex)
|
||||
}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"), leases.ResolveMACAddress(macAddress: macAddress))
|
||||
}
|
||||
|
||||
func testDuplicateYetNotExpiredLeases() throws {
|
||||
let macAddress = MACAddress(fromString: "11:22:33:44:55:66")!
|
||||
|
||||
let leases = try Leases("""
|
||||
{
|
||||
name=debian
|
||||
ip_address=192.168.64.1
|
||||
hw_address=1,\(macAddress)
|
||||
identifier=1,\(macAddress)
|
||||
lease=\(Int((Date() + 10.minutes).timeIntervalSince1970).hex)
|
||||
}
|
||||
{
|
||||
name=debian
|
||||
ip_address=192.168.64.2
|
||||
hw_address=1,\(macAddress)
|
||||
identifier=1,\(macAddress)
|
||||
lease=\(Int((Date() + 5.minutes).timeIntervalSince1970).hex)
|
||||
}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("192.168.64.1"), leases.ResolveMACAddress(macAddress: macAddress))
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,12 @@ final class MACAddressResolverTests: XCTestCase {
|
||||
{
|
||||
ip_address=1.2.3.4
|
||||
hw_address=1,00:11:22:33:44:55
|
||||
lease=0x7fffffff
|
||||
}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
}
|
||||
|
||||
func testMultipleEntries() throws {
|
||||
@@ -20,16 +21,18 @@ final class MACAddressResolverTests: XCTestCase {
|
||||
{
|
||||
ip_address=1.2.3.4
|
||||
hw_address=1,00:11:22:33:44:55
|
||||
lease=0x7fffffff
|
||||
}
|
||||
{
|
||||
ip_address=5.6.7.8
|
||||
hw_address=1,AA:BB:CC:DD:EE:FF
|
||||
lease=0x7fffffff
|
||||
}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
XCTAssertEqual(IPv4Address("5.6.7.8"),
|
||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
root = true
|
||||
@@ -0,0 +1,92 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
enable-all: true
|
||||
|
||||
disable:
|
||||
# Messages like "struct of size 104 bytes could be of size 96 bytes" from a package
|
||||
# that was last updated 2 years ago[1] are barely helpful.
|
||||
#
|
||||
# After all, we're writing the code for other people, so let's trust the compiler here (that's
|
||||
# constantly evolving compared to this linter) and revisit this if memory usage becomes a problem.
|
||||
#
|
||||
# [1]: https://github.com/mdempsky/maligned/commit/6e39bd26a8c8b58c5a22129593044655a9e25959
|
||||
- maligned
|
||||
|
||||
# We don't have high-performance requirements at this moment, so sacrificing
|
||||
# the code readability for marginal performance gains is not worth it.
|
||||
- prealloc
|
||||
|
||||
# New linters that require a lot of codebase churn and noise, but perhaps we can enable them in the future.
|
||||
- nlreturn
|
||||
- wrapcheck
|
||||
- errorlint
|
||||
|
||||
# Unfortunately, we use globals due to how spf13/cobra works.
|
||||
- gochecknoglobals
|
||||
|
||||
# That's fine that some Proto objects don't have all fields initialized
|
||||
- exhaustivestruct
|
||||
|
||||
# Style linters that are total nuts.
|
||||
- wsl
|
||||
- gofumpt
|
||||
- goimports
|
||||
- funlen
|
||||
|
||||
# This conflicts with the Protocol Buffers Version 3 design,
|
||||
# which is largely based on default values for struct fields.
|
||||
- exhaustivestruct
|
||||
|
||||
# Enough parallelism for now.
|
||||
- paralleltest
|
||||
|
||||
# Ill-based assumptions about identifiers like fmt.Println without taking context into account.
|
||||
- forbidigo
|
||||
|
||||
# Advantages of using t.Helper() are too small to waste developer's cognitive stamina on it.
|
||||
- thelper
|
||||
|
||||
# Too restrictive defaults, plus there's already a gocyclo linter in place.
|
||||
- cyclop
|
||||
|
||||
# Gives false positives for textbook examples[1][2]
|
||||
# [1]: https://github.com/charithe/durationcheck/issues/7
|
||||
# [2]: https://golang.org/pkg/time/ (see "To convert an integer number of units to a Duration, multiply:")
|
||||
- durationcheck
|
||||
|
||||
# No way to disable the "exported" check for the whole project[1]
|
||||
# [1]: https://github.com/mgechev/revive/issues/244#issuecomment-560512162
|
||||
- revive
|
||||
|
||||
# Unfortunately too much false-positives, e.g. for a 0700 umask or number 10 when using strconv.FormatInt()
|
||||
- gomnd
|
||||
|
||||
# Needs package whitelists
|
||||
- depguard
|
||||
|
||||
# Generates absolutely useless errors, e.g.
|
||||
# "string `.yml` has 3 occurrences, make it a constant"
|
||||
- goconst
|
||||
|
||||
# It's OK to not sort imports
|
||||
- gci
|
||||
|
||||
# It's OK to not initialize some struct fields
|
||||
- exhaustruct
|
||||
|
||||
# This is not a library, so it's OK to use dynamic errors
|
||||
- goerr113
|
||||
|
||||
# fmt.Sprintf() looks a bit nicer than string addition
|
||||
- perfsprint
|
||||
|
||||
# We can control this ourselves
|
||||
- varnamelen
|
||||
- contextcheck
|
||||
|
||||
issues:
|
||||
# Don't hide multiple issues that belong to one class since GitHub annotations can handle them all nicely.
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
@@ -0,0 +1,39 @@
|
||||
# Benchmark
|
||||
|
||||
Tart comes with a Golang-based benchmarking utility that allows one to easily compare host and guest performance.
|
||||
|
||||
Currently, only Flexible I/O tester workloads are supported. To run them, [install Golang](https://go.dev/) and run the following command from this (`benchmark/`) directory:
|
||||
|
||||
```shell
|
||||
go run cmd/main.go fio
|
||||
```
|
||||
|
||||
You can also enable the debugging output to diagnose issues:
|
||||
|
||||
```shell
|
||||
go run cmd/main.go fio --debug
|
||||
```
|
||||
|
||||
## Results
|
||||
|
||||
Host:
|
||||
|
||||
* Hardware: Mac mini (Apple M2 Pro, 8 performance and 4 efficiency cores, 32 GB RAM, `Mac14,12`)
|
||||
* OS: macOS Sonoma 14.4.1
|
||||
|
||||
Guest:
|
||||
|
||||
* Hardware: [Virtualization.Framework](https://developer.apple.com/documentation/virtualization)
|
||||
* OS: macOS Sonoma 14.4.1
|
||||
|
||||
```
|
||||
Name Executor Bandwidth I/O operations
|
||||
Random writing of 1MB local 2.6 GB/s 649.35 kIOPS
|
||||
Random writing of 1MB Tart 2.5 GB/s 620.22 kIOPS
|
||||
Random writing of 10MB local 2.6 GB/s 651.74 kIOPS
|
||||
Random writing of 10MB Tart 2.5 GB/s 615.52 kIOPS
|
||||
Random writing of 100MB local 1.9 GB/s 481.51 kIOPS
|
||||
Random writing of 100MB Tart 2.0 GB/s 493.31 kIOPS
|
||||
Random writing of 1000MB local 1.7 GB/s 414.89 kIOPS
|
||||
Random writing of 1000MB Tart 1.1 GB/s 287.4 kIOPS
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/command"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Set up a signal-interruptible context
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
|
||||
// Run the root command
|
||||
if err := command.NewCommand().ExecuteContext(ctx); err != nil {
|
||||
cancel()
|
||||
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
module github.com/cirruslabs/tart/benchmark
|
||||
|
||||
go 1.22.1
|
||||
|
||||
require (
|
||||
github.com/avast/retry-go/v4 v4.5.1
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gosuri/uitable v0.0.4
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
github.com/avast/retry-go/v4 v4.5.1 h1:AxIx0HGi4VZ3I02jr78j5lZ3M6x1E0Ivxa6b0pUUh7o=
|
||||
github.com/avast/retry-go/v4 v4.5.1/go.mod h1:/sipNsvNB3RRuT5iNcb6h73nw3IBmXJ/H3XrCQYSOpc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
|
||||
github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,29 @@
|
||||
package fio
|
||||
|
||||
type Benchmark struct {
|
||||
Name string
|
||||
Command string
|
||||
}
|
||||
|
||||
var benchmarks = []Benchmark{
|
||||
{
|
||||
Name: "Random writing of 1MB",
|
||||
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||
"--size 1MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||
},
|
||||
{
|
||||
Name: "Random writing of 10MB",
|
||||
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||
"--size 10MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||
},
|
||||
{
|
||||
Name: "Random writing of 100MB",
|
||||
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||
"--size 100MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||
},
|
||||
{
|
||||
Name: "Random writing of 1000MB",
|
||||
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||
"--size 1000MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package fio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor/local"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor/tart"
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/gosuri/uitable"
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var debug bool
|
||||
|
||||
func NewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "fio",
|
||||
Short: "run Flexible I/O tester (fio) benchmarks",
|
||||
RunE: run,
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, args []string) error {
|
||||
config := zap.NewProductionConfig()
|
||||
if debug {
|
||||
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
|
||||
}
|
||||
logger, err := config.Build()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = logger.Sync()
|
||||
}()
|
||||
|
||||
executors, err := initializeExecutors(cmd.Context(), logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
errs := []error{err}
|
||||
|
||||
for _, executor := range executors {
|
||||
if err := executor.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to close executor %s: %w", executor.Name(), err))
|
||||
}
|
||||
}
|
||||
|
||||
err = errors.Join(errs...)
|
||||
}()
|
||||
|
||||
table := uitable.New()
|
||||
table.AddRow("Name", "Executor", "Bandwidth", "I/O operations")
|
||||
|
||||
for _, benchmark := range benchmarks {
|
||||
for _, executor := range executors {
|
||||
logger.Sugar().Infof("running benchmark %q on %s executor", benchmark.Name, executor.Name())
|
||||
|
||||
stdout, err := executor.Run(cmd.Context(), benchmark.Command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var fioResult Result
|
||||
|
||||
if err := json.Unmarshal(stdout, &fioResult); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(fioResult.Jobs) != 1 {
|
||||
return fmt.Errorf("expected exactly 1 job from fio's JSON output, got %d",
|
||||
len(fioResult.Jobs))
|
||||
}
|
||||
|
||||
job := fioResult.Jobs[0]
|
||||
|
||||
writeBandwidth := humanize.Bytes(uint64(job.Write.BW)*humanize.KByte) + "/s"
|
||||
writeIOPS := humanize.SIWithDigits(job.Write.IOPS, 2, "IOPS")
|
||||
|
||||
logger.Sugar().Infof("write bandwidth: %s, write IOPS: %s\n", writeBandwidth, writeIOPS)
|
||||
|
||||
table.AddRow(benchmark.Name, executor.Name(), writeBandwidth, writeIOPS)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println(table.String())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeExecutors(ctx context.Context, logger *zap.Logger) ([]executor.Executor, error) {
|
||||
var result []executor.Executor
|
||||
|
||||
logger.Info("initializing local executor")
|
||||
|
||||
local, err := local.New(logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, local)
|
||||
|
||||
logger.Info("local executor initialized")
|
||||
|
||||
logger.Info("initializing Tart executor")
|
||||
|
||||
tart, err := tart.New(ctx, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, tart)
|
||||
|
||||
logger.Info("Tart executor initialized")
|
||||
|
||||
for _, executor := range result {
|
||||
logger.Sugar().Infof("installing Flexible I/O tester (fio) on %s executor", executor.Name())
|
||||
|
||||
if _, err := executor.Run(ctx, "brew install fio"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package fio
|
||||
|
||||
type Result struct {
|
||||
Jobs []Job `json:"jobs"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
Name string `json:"jobname"`
|
||||
Write Write `json:"write"`
|
||||
}
|
||||
|
||||
type Write struct {
|
||||
BW float64 `json:"bw"`
|
||||
IOPS float64 `json:"iops"`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"github.com/cirruslabs/tart/benchmark/internal/command/fio"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "benchmark",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
fio.NewCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Executor interface {
|
||||
Name() string
|
||||
Run(ctx context.Context, command string) ([]byte, error)
|
||||
Close() error
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapio"
|
||||
"io"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
type Local struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func New(logger *zap.Logger) (*Local, error) {
|
||||
return &Local{
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (local *Local) Name() string {
|
||||
return "local"
|
||||
}
|
||||
|
||||
func (local *Local) Run(ctx context.Context, command string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, "zsh", "-c", command)
|
||||
|
||||
loggerWriter := &zapio.Writer{Log: local.logger, Level: zap.DebugLevel}
|
||||
stdoutBuf := &bytes.Buffer{}
|
||||
|
||||
cmd.Stdout = io.MultiWriter(stdoutBuf, loggerWriter)
|
||||
cmd.Stderr = loggerWriter
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
return stdoutBuf.Bytes(), err
|
||||
}
|
||||
|
||||
func (local *Local) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package local_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor/local"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocal(t *testing.T) {
|
||||
local, err := local.New(zap.NewNop())
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := local.Run(context.Background(), "echo \"this is a test\"")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "this is a test\n", string(output))
|
||||
|
||||
require.NoError(t, local.Close())
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package tart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapio"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const tartBinaryName = "tart"
|
||||
|
||||
func Cmd(ctx context.Context, logger *zap.Logger, args ...string) error {
|
||||
_, err := CmdWithOutput(ctx, logger, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func CmdWithOutput(ctx context.Context, logger *zap.Logger, args ...string) (string, error) {
|
||||
logger.Sugar().Debugf("running %s %s", tartBinaryName, strings.Join(args, " "))
|
||||
|
||||
cmd := exec.CommandContext(ctx, tartBinaryName, args...)
|
||||
|
||||
loggerWriter := &zapio.Writer{Log: logger, Level: zap.DebugLevel}
|
||||
stdoutBuf := &bytes.Buffer{}
|
||||
|
||||
cmd.Stdout = io.MultiWriter(stdoutBuf, loggerWriter)
|
||||
cmd.Stderr = loggerWriter
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
return stdoutBuf.String(), err
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package tart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/avast/retry-go/v4"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const baseImage = "ghcr.io/cirruslabs/macos-sonoma-base:latest"
|
||||
|
||||
type Tart struct {
|
||||
vmRunCancel context.CancelFunc
|
||||
vmName string
|
||||
sshClient *ssh.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func New(ctx context.Context, logger *zap.Logger) (*Tart, error) {
|
||||
tart := &Tart{
|
||||
vmName: fmt.Sprintf("tart-benchmark-%s", uuid.NewString()),
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
if err := Cmd(ctx, tart.logger, "pull", baseImage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := Cmd(ctx, tart.logger, "clone", baseImage, tart.vmName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vmRunCtx, vmRunCancel := context.WithCancel(ctx)
|
||||
tart.vmRunCancel = vmRunCancel
|
||||
|
||||
go func() {
|
||||
_ = Cmd(vmRunCtx, tart.logger, "run", "--no-graphics", tart.vmName)
|
||||
}()
|
||||
|
||||
ip, err := CmdWithOutput(ctx, tart.logger, "ip", "--wait", "60", tart.vmName)
|
||||
if err != nil {
|
||||
return nil, tart.Close()
|
||||
}
|
||||
|
||||
err = retry.Do(func() error {
|
||||
dialer := net.Dialer{
|
||||
Timeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:22", strings.TrimSpace(ip))
|
||||
|
||||
netConn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshConn, chans, reqs, err := ssh.NewClientConn(netConn, addr, &ssh.ClientConfig{
|
||||
User: "admin",
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password("admin"),
|
||||
},
|
||||
HostKeyCallback: func(_ string, _ net.Addr, _ ssh.PublicKey) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tart.sshClient = ssh.NewClient(sshConn, chans, reqs)
|
||||
|
||||
return nil
|
||||
}, retry.RetryIf(func(err error) bool {
|
||||
return !errors.Is(err, context.Canceled)
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, tart.Close()
|
||||
}
|
||||
|
||||
return tart, nil
|
||||
}
|
||||
|
||||
func (tart *Tart) Name() string {
|
||||
return "Tart"
|
||||
}
|
||||
|
||||
func (tart *Tart) Run(ctx context.Context, command string) ([]byte, error) {
|
||||
sshSession, err := tart.sshClient.NewSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Work around x/crypto/ssh not being context.Context-friendly (e.g. https://github.com/golang/go/issues/20288)
|
||||
monitorCtx, monitorCancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
<-monitorCtx.Done()
|
||||
_ = sshSession.Close()
|
||||
}()
|
||||
defer monitorCancel()
|
||||
|
||||
stdoutBuf := &bytes.Buffer{}
|
||||
|
||||
sshSession.Stdin = bytes.NewBufferString(command)
|
||||
sshSession.Stdout = stdoutBuf
|
||||
|
||||
if err := sshSession.Shell(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := sshSession.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stdoutBuf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (tart *Tart) Close() error {
|
||||
if tart.sshClient != nil {
|
||||
_ = tart.sshClient.Close()
|
||||
}
|
||||
|
||||
tart.vmRunCancel()
|
||||
_ = Cmd(context.Background(), tart.logger, "delete", tart.vmName)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package tart_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor/tart"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tart, err := tart.New(ctx, zap.NewNop())
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := tart.Run(ctx, "echo \"this is a test\"")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "this is a test\n", string(output))
|
||||
|
||||
require.NoError(t, tart.Close())
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -62,7 +62,7 @@ and before reaching the 100 CPU cores limit is royalty-free and does not have th
|
||||
|
||||
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Tier License](/licensing#license-tiers),
|
||||
which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](/licensing#license-tiers)
|
||||
(\$5000 per month) will be required, and for organizations that exceed 5000 CPU cores, a custom [Diamond Tier License](/licensing#license-tiers)
|
||||
(\$3000 per month) will be required, and for organizations that exceed 3000 CPU cores, a custom [Diamond Tier License](/licensing#license-tiers)
|
||||
(\$1 per core per month) will be necessary. **All paid license tiers will include priority feature development and SLAs on support with urgent issues.**
|
||||
|
||||
## Have we considered alternatives?
|
||||
|
||||
+33
-11
@@ -3,19 +3,9 @@ hide:
|
||||
- navigation
|
||||
---
|
||||
|
||||
## How Tart is different from Anka?
|
||||
|
||||
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
|
||||
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
|
||||
|
||||
Instead of Anka Registry, Tart can work with any OCI-compatible container registry. This provides a much more consistent
|
||||
and scalable experience for distributing virtual machines.
|
||||
|
||||
Tart doesn't yet have an analogue of Anka Controller for managing long living VMs but [soon will be](https://github.com/cirruslabs/tart/issues/372).
|
||||
|
||||
## VM location on disk
|
||||
|
||||
Tart stores all it's files in `~/.tart/` directory. Local images that you can run are stored in `~/.tart/vms/`.
|
||||
Tart stores all its files in `~/.tart/` directory. Local images that you can run are stored in `~/.tart/vms/`.
|
||||
Remote images are pulled into `~/.tart/cache/OCIs/`.
|
||||
|
||||
## Nested virtualization support?
|
||||
@@ -66,3 +56,35 @@ sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.InternetS
|
||||
```
|
||||
|
||||
Note that this tweak persists across reboots, so normally you'll only need to do it once per new host.
|
||||
|
||||
## Running login/clone/pull/push commands over SSH
|
||||
|
||||
When invoking the Tart in an SSH session, you might get error like this:
|
||||
|
||||
>Keychain returned unsuccessful status -25308
|
||||
|
||||
...or this:
|
||||
|
||||
>Keychain failed to update item: User interaction is not allowed.
|
||||
|
||||
This is because Tart uses [Keychain](https://en.wikipedia.org/wiki/Keychain_(software)) to store and retrieve OCI registry credentials by default, but Keychain is only automatically/semi-automatically unlocked in GUI sessions.
|
||||
|
||||
To unlock the Keychain in an SSH session, run the following command, which will ask for your user's password:
|
||||
|
||||
```shell
|
||||
security unlock-keychain
|
||||
```
|
||||
|
||||
This command also supports the `-p` command-line argument that allows you to supply the password and unlock non-interactively, which is great for scripts.
|
||||
|
||||
If that doesn't work for you for some reason, you can pass the credentials via the environment variables, see [Registry Authorization](integrations/vm-management.md#registry-authorization) for more details on how to do that.
|
||||
|
||||
## How Tart is different from Anka?
|
||||
|
||||
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
|
||||
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
|
||||
|
||||
Instead of Anka Registry, Tart can work with any OCI-compatible container registry. This provides a much more consistent
|
||||
and scalable experience for distributing virtual machines.
|
||||
|
||||
Tart doesn't yet have an analogue of Anka Controller for managing long living VMs but [soon will be](https://github.com/cirruslabs/tart/issues/372).
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Buildkite
|
||||
|
||||
It is possible to run [Buildkite](https://buildkite.com/) pipeline steps in isolated ephemeral Tart Virtual Machines with the help of [Tart Buildkite Plugin](https://github.com/cirruslabs/tart-buildkite-plugin):
|
||||
|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
The most basic configuration looks like this:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- command: uname -a
|
||||
plugins:
|
||||
- cirruslabs/tart#main:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-base:latest
|
||||
```
|
||||
|
||||
This will run `uname -r` in a macOS Tart VM cloned from `ghcr.io/cirruslabs/macos-sonoma-base:latest`.
|
||||
|
||||
See plugin's [Configuration section](https://github.com/cirruslabs/tart-buildkite-plugin#configuration) for the full list of available options.
|
||||
@@ -5,6 +5,10 @@ Your actions will be executed in clean macOS virtual machines with 4 Apple M2 co
|
||||
|
||||
## Testimonials from customers
|
||||
|
||||
Mitchell Hashimoto, HashiCorp co-founder:
|
||||
|
||||
> I've been using "Cirrus Runners" since [that tweet](https://twitter.com/mitchellh/status/1731071326201561194) and it has been fantastic. Huge speed increase, huge cost decrease, zero maintenance, exactly what I wanted.
|
||||
|
||||
Max Lapides, Senior Mobile Engineer at [Tonal](https://www.tonal.com/):
|
||||
|
||||
> Previously, we were using the GitHub‑hosted macOS runners and our iOS build took ~30 minutes. Now with Cirrus Runners, the iOS build only takes ~12 minutes. That’s a huge boost to our productivity, and for only $150/month per runner it is much less expensive too.
|
||||
@@ -24,6 +28,15 @@ We recommend to purchase several Cirrus Runners depending on your team size, so
|
||||
parallel. Note that you can change your subscription at any time via [this page](https://billing.stripe.com/p/login/3cs7vNbzo92p7fy3cc)
|
||||
or by emailing [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
||||
|
||||
### Discounts
|
||||
|
||||
We offer two mutually exclusive discounts:
|
||||
|
||||
- 10% "Volume Discount" for subscriptions of 10 or more Cirrus Runners.
|
||||
- 15% "Annual Discount" for 12 months subscription commitment of any amount of Cirrus Runners.
|
||||
|
||||
Please contact [support@cirruslabs.org](mailto:support@cirruslabs.org) after activating the subscription in order to get the discount applied.
|
||||
|
||||
### Priority Support
|
||||
|
||||
Subscriptions of 20 or more Cirrus Runners include access to [Priority Support](../licensing.md#priority-support).
|
||||
|
||||
+12
-11
@@ -13,9 +13,7 @@ will be required to obtain a paid license.
|
||||
Tart VMs will automatically try to balance compute between all available cores of the host CPU. As a result,
|
||||
all performance and energy-efficient cores of the host CPU are always counted towards the license usage.
|
||||
|
||||
# License Tiers
|
||||
|
||||
## Free Tier
|
||||
## License Tiers
|
||||
|
||||
By default, when no [license is purchased](#get-the-license), it is assumed that an organization is using a Free Tier license.
|
||||
You can find the Free Tier license text in [Tart](https://github.com/cirruslabs/tart/blob/main/LICENSE) and [Orchard](https://github.com/cirruslabs/orchard/blob/main/LICENSE) repositories.
|
||||
@@ -34,23 +32,23 @@ Free Tier license has a 100 CPU core limit for Tart and 4 Orchard Workers limit
|
||||
- Using Tart on 13 Mac Minis with 8 CPUs each.
|
||||
- Creating an Orchard cluster of 5 Mac Minis workers with 8 CPUs each.
|
||||
|
||||
## Gold Tier
|
||||
### Gold Tier
|
||||
|
||||
If an organization wishes to exceed the limits of the Free Tier license, a purchase of the [Gold Tier License](#get-the-license) is required, which costs \$1000 per month.
|
||||
|
||||
Gold Tier license has a 500 CPU core limit for Tart and 20 Orchard Workers limit for Orchard.
|
||||
|
||||
## Platinum Tier
|
||||
### Platinum Tier
|
||||
|
||||
If an organization wishes to exceed the limits of the Gold Tier license, a purchase of the [Platinum Tier License](#get-the-license) is required, which costs \$5000 per month.
|
||||
If an organization wishes to exceed the limits of the Gold Tier license, a purchase of the [Platinum Tier License](#get-the-license) is required, which costs \$3000 per month.
|
||||
|
||||
Platinum Tier license has a 5,000 CPU core limit for Tart and 200 Orchard Workers limit for Orchard.
|
||||
Platinum Tier license has a 3,000 CPU core limit for Tart and 200 Orchard Workers limit for Orchard.
|
||||
|
||||
## Diamond Tier
|
||||
### Diamond Tier
|
||||
|
||||
For organizations that wish to exceed the limits of the Platinum Tier license, a purchase of a [custom Diamond Tier License](#get-the-license) is required, which costs \$1 per CPU core per month and gives the ability to run unlimited Orchard Workers.
|
||||
|
||||
# Get the license
|
||||
## Get the license
|
||||
|
||||
If your organization is interested in purchasing one of the license tiers, please email [licensing@cirruslabs.org](mailto:licensing@cirruslabs.org).
|
||||
|
||||
@@ -61,7 +59,10 @@ You can see a template of a license subscription agreement [here](assets/TartLic
|
||||
There are [official AMIs for EC2 Mac Instances](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws)
|
||||
with preconfigured Tart installation that is optimized to work within AWS infrastructure.
|
||||
|
||||
# General Support
|
||||
Additionally, there is a [ECR Pulic Gallery mirror](https://gallery.ecr.aws/cirruslabs/macos) of all the
|
||||
[Tart VM images managed by us](https://github.com/cirruslabs/macos-image-templates).
|
||||
|
||||
## General Support
|
||||
|
||||
The best way to ask general questions about particular use cases is to email our support team at [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
||||
Our support team is trying our best to respond ASAP, but there is no guarantee on a response time unless your organization
|
||||
@@ -70,7 +71,7 @@ has a paid license subscription which includes [Priority Support](#priority-supp
|
||||
If you have a feature request or noticed lack of some documentation please feel free to [create a GitHub issue](https://github.com/cirruslabs/tart/issues/new).
|
||||
Our support team will answer it by replying to the issue or by updating the documentation.
|
||||
|
||||
# Priority Support
|
||||
## Priority Support
|
||||
|
||||
In addition to the general support we provide a *Priority Support* with guaranteed response times included in all the paid license tiers.
|
||||
|
||||
|
||||
+48
-1
@@ -28,6 +28,53 @@ tart run sonoma-base
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartScreenshot.png"/>
|
||||
</p>
|
||||
|
||||
## VM images
|
||||
|
||||
The following macOS images are currently available:
|
||||
|
||||
* macOS 14 (Sonoma)
|
||||
* `ghcr.io/cirruslabs/macos-sonoma-vanilla:latest`
|
||||
* `ghcr.io/cirruslabs/macos-sonoma-base:latest`
|
||||
* `ghcr.io/cirruslabs/macos-sonoma-xcode:latest`
|
||||
* macOS 13 (Ventura)
|
||||
* `ghcr.io/cirruslabs/macos-ventura-vanilla:latest`
|
||||
* `ghcr.io/cirruslabs/macos-ventura-base:latest`
|
||||
* `ghcr.io/cirruslabs/macos-ventura-xcode:latest`
|
||||
* macOS 12 (Monterey)
|
||||
* `ghcr.io/cirruslabs/macos-monterey-vanilla:latest`
|
||||
* `ghcr.io/cirruslabs/macos-monterey-base:latest`
|
||||
* `ghcr.io/cirruslabs/macos-monterey-xcode:latest`
|
||||
|
||||
There's also a [full list of images](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos-) in which you can discovery specific tags (e.g. `ghcr.io/cirruslabs/macos-monterey-xcode:15`) and [macOS-specific Packer templates](https://github.com/cirruslabs/macos-image-templates) that were used to generate these images.
|
||||
|
||||
For, Linux the options are as follows:
|
||||
|
||||
* Ubuntu
|
||||
* `ghcr.io/cirruslabs/ubuntu:latest`
|
||||
* Debian
|
||||
* `ghcr.io/cirruslabs/debian:latest`
|
||||
* Fedora
|
||||
* `ghcr.io/cirruslabs/fedora:latest`
|
||||
|
||||
Note that these Linux images have a minimal disk size of 20 GB, and you might want to resize them right after cloning:
|
||||
|
||||
```bash
|
||||
tart clone ghcr.io/cirruslabs/ubuntu:latest ubuntu
|
||||
tart set ubuntu --disk-size 50
|
||||
tart run ubuntu
|
||||
```
|
||||
|
||||
These Linux images can be ran natively on [Vetu](https://github.com/cirruslabs/vetu), our virtualization solution for Linux, assuming that Vetu itself is running on an `arm64` machine.
|
||||
|
||||
Similarly to macOS, there's also a [full list of images](https://github.com/orgs/cirruslabs/packages?repo_name=linux-image-templates) in which you can discovery specific tags (e.g. `ghcr.io/cirruslabs/ubuntu:22.04`) and [Linux-specific Packer templates](https://github.com/cirruslabs/linux-image-templates) that were used to generate these images.
|
||||
|
||||
All images above use the following credentials:
|
||||
|
||||
* Username: `admin`
|
||||
* Password: `admin`
|
||||
|
||||
These credentials work both for logging in via GUI, console (Linux) and SSH.
|
||||
|
||||
## SSH access
|
||||
|
||||
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
|
||||
@@ -41,7 +88,7 @@ ssh admin@$(tart ip sonoma-base)
|
||||
from within Tart virtual machines. Alternatively, you can use plain ssh connection and `tart ip` command:
|
||||
|
||||
```bash
|
||||
brew install sshpass
|
||||
brew install cirruslabs/cli/sshpass
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sonoma-base) "uname -a"
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sonoma-base) < script.sh
|
||||
```
|
||||
|
||||
Vendored
+24
-33
@@ -171,15 +171,10 @@
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Seamless integration with your existing automations</h2>
|
||||
<p>
|
||||
Tart powers several continuous integration systems including
|
||||
<a href="/integrations/github-actions"
|
||||
>on‑demand GitHub Actions Runners</a
|
||||
>
|
||||
and
|
||||
<a href="https://cirrus-ci.org/guide/macOS/" target="_blank"
|
||||
>Cirrus CI</a
|
||||
>. Double the performance of your macOS actions with
|
||||
a couple lines of code.
|
||||
Tart integrates with many continuous integration systems, including a dedicated
|
||||
service of on-demand GitHub Actions Runners. With a single line change, you can cut your
|
||||
CI/CD costs by up to <b>30 times</b> by using <a href="https://cirrus-runners.app/">Cirrus Runners</a>
|
||||
to run your workflows.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
@@ -269,6 +264,26 @@
|
||||
</h1>
|
||||
</header>
|
||||
<div class="mdx-users">
|
||||
<figure class="mdx-users__testimonial">
|
||||
<img
|
||||
src="assets/images/users/mitchell-hashimoto.webp"
|
||||
alt="Mitchell Hashimoto"
|
||||
loading="lazy"
|
||||
width="200"
|
||||
height="200"
|
||||
/>
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Mitchell Hashimoto</h2>
|
||||
<h3>
|
||||
<a href="https://www.hashicorp.com/" target="_blank">HashiCorp</a> co-founder
|
||||
</h3>
|
||||
<hr/>
|
||||
<cite>
|
||||
I've been using "Cirrus Runners" since <a href="https://x.com/mitchellh/status/1731071326201561194" target="_blank">that tweet</a> and
|
||||
it has been fantastic. Huge speed increase, huge cost decrease, zero maintenance, exactly what I wanted.
|
||||
</cite>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<figure class="mdx-users__testimonial">
|
||||
<img
|
||||
src="assets/images/users/seb-jachec.webp"
|
||||
@@ -293,30 +308,6 @@
|
||||
</cite>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<figure class="mdx-users__testimonial">
|
||||
<img
|
||||
src="assets/images/users/mikhail-tokarev.webp"
|
||||
alt="Mikhail Tokarev"
|
||||
loading="lazy"
|
||||
width="200"
|
||||
height="200"
|
||||
/>
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Mikhail Tokarev</h2>
|
||||
<h3>
|
||||
CTO at
|
||||
<a href="https://codemagic.io/start/" target="_blank"
|
||||
>Codemagic</a
|
||||
>
|
||||
</h3>
|
||||
<hr/>
|
||||
<cite>
|
||||
Thanks to the minimal overhead of using the Apple Virtualization
|
||||
API, we’ve seen some performance improvements in booting new
|
||||
virtual machines compared with Anka.
|
||||
</cite>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<figure class="mdx-users__testimonial">
|
||||
<img
|
||||
src="assets/images/users/max-lapides.webp"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
source = [".build/arm64-apple-macosx/release/tart"]
|
||||
source = [
|
||||
".build/x86_64-apple-macosx/release/tart",
|
||||
".build/arm64-apple-macosx/release/tart"
|
||||
]
|
||||
bundle_id = "com.github.cirruslabs.tart"
|
||||
|
||||
apple_id {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import requests
|
||||
|
||||
from testcontainers.core.waiting_utils import wait_container_is_ready
|
||||
from testcontainers.general import DockerContainer
|
||||
from testcontainers.core.container import DockerContainer
|
||||
|
||||
|
||||
class DockerRegistry(DockerContainer):
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from paramiko.client import SSHClient, AutoAddPolicy
|
||||
|
||||
|
||||
def test_run(tart):
|
||||
@pytest.mark.parametrize("run_opts", [[], ["--no-graphics"]])
|
||||
def test_run(tart, run_opts):
|
||||
vm_name = f"integration-test-run-{uuid.uuid4()}"
|
||||
|
||||
# Instantiate a VM with admin:admin SSH access
|
||||
tart.run(["clone", "ghcr.io/cirruslabs/macos-sonoma-base:latest", vm_name])
|
||||
|
||||
# Run the VM asynchronously
|
||||
tart_run_process = tart.run_async(["run", vm_name])
|
||||
tart_run_process = tart.run_async(["run", vm_name] + run_opts)
|
||||
|
||||
# Obtain the VM's IP
|
||||
stdout, _ = tart.run(["ip", vm_name, "--wait", "120"])
|
||||
|
||||
@@ -91,6 +91,7 @@ nav:
|
||||
- "Integrations":
|
||||
- "GitHub Actions": integrations/github-actions.md
|
||||
- "GitLab Runner": integrations/gitlab-runner.md
|
||||
- "Buildkite": integrations/buildkite.md
|
||||
- "Self-hosted CI": integrations/cirrus-cli.md
|
||||
- "Managing VMs": integrations/vm-management.md
|
||||
- "Support & Licensing": licensing.md
|
||||
|
||||
@@ -8,4 +8,11 @@ set -e
|
||||
swift build --product tart
|
||||
codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
|
||||
.build/debug/tart "$@"
|
||||
rm -Rf .build/tart.app/
|
||||
mkdir -p .build/tart.app/Contents/MacOS .build/tart.app/Contents/Resources
|
||||
cp -c .build/debug/tart .build/tart.app/Contents/MacOS/tart
|
||||
cp -c Resources/embedded.provisionprofile .build/tart.app/Contents/embedded.provisionprofile
|
||||
cp -c Resources/Info.plist .build/tart.app/Contents/Info.plist
|
||||
cp -c Resources/AppIcon.png .build/tart.app/Contents/Resources
|
||||
|
||||
.build/tart.app/Contents/MacOS/tart "$@"
|
||||
|
||||
Reference in New Issue
Block a user