mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-01 19:51:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf5081b3d9 | ||
|
|
017592075f | ||
|
|
84e1ae2b38 | ||
|
|
d50e113300 | ||
|
|
fce52f1514 | ||
|
|
9484b8b2c9 | ||
|
|
dd46033812 | ||
|
|
c655288de7 | ||
|
|
204002f776 | ||
|
|
a0ae2f4e66 | ||
|
|
7c386e3466 | ||
|
|
dbbd716214 | ||
|
|
13d5ddb4a4 | ||
|
|
626316a4cd | ||
|
|
fbe35302c2 | ||
|
|
e1353f4540 | ||
|
|
985db24474 | ||
|
|
1d01bf63fb | ||
|
|
3ff3850da2 | ||
|
|
c6e8d0bfd7 | ||
|
|
755aad4d7c | ||
|
|
9f38441a42 | ||
|
|
3d46c4e6c2 | ||
|
|
e59221f6a0 | ||
|
|
c6e99345cd | ||
|
|
8bc2e99f63 | ||
|
|
f36f86b61c | ||
|
|
79084555f6 | ||
|
|
896d03ce0b | ||
|
|
99c91cbf87 | ||
|
|
da8afa1348 | ||
|
|
97b7ffef52 | ||
|
|
13e7794bfc | ||
|
|
b7b3b702ac | ||
|
|
560dba79e4 | ||
|
|
2b33b8f9e6 | ||
|
|
d8b010c79c | ||
|
|
5cd83c38cd | ||
|
|
1a3b862631 | ||
|
|
ae2d59e5c2 | ||
|
|
7eac45702b | ||
|
|
e06d89f95d | ||
|
|
0602d6e0e1 | ||
|
|
ac5d0baa0c | ||
|
|
ee27cc57bb | ||
|
|
a9e2a19015 | ||
|
|
89ff5f6b65 | ||
|
|
6bf39e73d0 | ||
|
|
0b693f6bc9 | ||
|
|
99bbd838a1 |
@@ -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
|
||||
|
||||
+6
-10
@@ -1,8 +1,5 @@
|
||||
use_compute_credits: true
|
||||
|
||||
env:
|
||||
XCODE_TAG: 15.2
|
||||
|
||||
task:
|
||||
name: Test on Sonoma
|
||||
alias: test
|
||||
@@ -41,7 +38,7 @@ task:
|
||||
name: Lint
|
||||
alias: lint
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||
lint_script:
|
||||
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
||||
always:
|
||||
@@ -58,7 +55,7 @@ task:
|
||||
name: Build ($BUILD_ARCH)
|
||||
alias: build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||
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:
|
||||
@@ -71,7 +68,7 @@ task:
|
||||
- lint
|
||||
- build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
@@ -89,12 +86,11 @@ 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
|
||||
- swift -version
|
||||
goreleaser_script: goreleaser release --skip-publish --snapshot --clean
|
||||
goreleaser_script: goreleaser release --skip=publish --snapshot --clean
|
||||
always:
|
||||
dist_artifacts:
|
||||
path: "dist/*"
|
||||
@@ -107,7 +103,7 @@ task:
|
||||
- test
|
||||
- build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
@@ -115,7 +111,7 @@ task:
|
||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||
SENTRY_ORG: cirrus-labs
|
||||
SENTRY_PROJECT: persistent-workers
|
||||
SENTRY_AUTH_TOKEN: ENCRYPTED[!c16a5cf7da5f856b4bc2f21fe8cb7aa2a6c981f851c094ed4d3025fd02ea59a58a86cee8b193a69a1fc20fa217e56ac3!]
|
||||
SENTRY_AUTH_TOKEN: ENCRYPTED[!9eaf2875d51b113e2f68598441ff8e6b2e53242e48fcb93633bd75a373fbe2e7caa900d837cc92f0b142b65579731644!]
|
||||
setup_script:
|
||||
- cd $HOME
|
||||
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
||||
|
||||
@@ -16,3 +16,6 @@ dist/
|
||||
|
||||
# mkdocs
|
||||
.cache
|
||||
|
||||
# mkdocs-material
|
||||
site
|
||||
|
||||
@@ -26,6 +26,12 @@ archives:
|
||||
- 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:
|
||||
@@ -46,5 +52,6 @@ brews:
|
||||
install: |
|
||||
libexec.install Dir["*"]
|
||||
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
|
||||
generate_completions_from_executable(libexec/"tart.app/Contents/MacOS/tart", "--generate-completion-script")
|
||||
custom_block: |
|
||||
depends_on :macos => :ventura
|
||||
|
||||
+43
-15
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"originHash" : "6d48639bc0ea02002de0b4f38fe3fce0ddc9d174f2e56180c2ffcbedb7391ef8",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "antlr4",
|
||||
@@ -18,13 +19,22 @@
|
||||
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "semaphore",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/groue/Semaphore",
|
||||
"state" : {
|
||||
"revision" : "f1c4a0acabeb591068dea6cffdd39660b86dec28",
|
||||
"version" : "0.0.8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sentry-cocoa",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "d277532e1c8af813981ba01f591b15bbdd735615",
|
||||
"version" : "8.8.0"
|
||||
"revision" : "ef4fec9dfb8dd5027b09a4a5c9362feafd118e1a",
|
||||
"version" : "8.24.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -32,8 +42,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-algorithms",
|
||||
"state" : {
|
||||
"revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
|
||||
"version" : "1.0.0"
|
||||
"revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42",
|
||||
"version" : "1.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -41,8 +51,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "f3c9084a71ef4376f2fabbdf1d3d90a49f1fabdb",
|
||||
"version" : "1.1.2"
|
||||
"revision" : "46989693916f56d1186bd59ac15124caef896560",
|
||||
"version" : "1.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -59,8 +69,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
|
||||
"version" : "1.0.2"
|
||||
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
|
||||
"version" : "1.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -72,6 +82,15 @@
|
||||
"version" : "1.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5",
|
||||
"version" : "1.5.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-numerics",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -81,13 +100,22 @@
|
||||
"version" : "1.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-retry",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/fumoboy007/swift-retry",
|
||||
"state" : {
|
||||
"revision" : "9f133487ffc2ab4539688c29efe57bb1ba31d7b0",
|
||||
"version" : "0.2.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-sysctl",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sersoft-gmbh/swift-sysctl.git",
|
||||
"state" : {
|
||||
"revision" : "71fd64ee84819bb19fbecfb36d5a4503726b6fb7",
|
||||
"version" : "1.6.0"
|
||||
"revision" : "a91be36de6803ebe48f678699dfd0694c2200d2f",
|
||||
"version" : "1.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -95,8 +123,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/malcommac/SwiftDate",
|
||||
"state" : {
|
||||
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
||||
"version" : "6.3.1"
|
||||
"revision" : "5d943224c3bb173e6ecf27295611615eba90c80e",
|
||||
"version" : "7.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -104,8 +132,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/nicklockwood/SwiftFormat",
|
||||
"state" : {
|
||||
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
|
||||
"version" : "0.50.6"
|
||||
"revision" : "9df3b01f477163b33d5e63c5e2e5b9f946a49c56",
|
||||
"version" : "0.53.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -127,5 +155,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
"version" : 3
|
||||
}
|
||||
|
||||
+13
-9
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version:5.7
|
||||
// swift-tools-version:5.10
|
||||
|
||||
import PackageDescription
|
||||
let package = Package(
|
||||
@@ -10,18 +10,20 @@ let package = Package(
|
||||
.executable(name: "tart", targets: ["tart"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"),
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"),
|
||||
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
|
||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
|
||||
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
|
||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
|
||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
|
||||
.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/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
|
||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.24.0"),
|
||||
.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")
|
||||
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"),
|
||||
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"),
|
||||
.package(url: "https://github.com/groue/Semaphore", from: "0.0.8"),
|
||||
.package(url: "https://github.com/fumoboy007/swift-retry", from: "0.2.3"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
@@ -36,6 +38,8 @@ let package = Package(
|
||||
.product(name: "TextTable", package: "TextTable"),
|
||||
.product(name: "Sysctl", package: "swift-sysctl"),
|
||||
.product(name: "SwiftRadix", package: "SwiftRadix"),
|
||||
.product(name: "Semaphore", package: "Semaphore"),
|
||||
.product(name: "DMRetry", package: "swift-retry"),
|
||||
], exclude: [
|
||||
"OCI/Reference/Makefile",
|
||||
"OCI/Reference/Reference.g4",
|
||||
|
||||
@@ -12,7 +12,7 @@ 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">
|
||||
<a href="https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral" target=_blank>
|
||||
<a href="https://cirrus-runners.app/?utm_source=github&utm_medium=referral" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
@@ -20,8 +20,8 @@ service — a drop-in replacement for the standard GitHub-hosted runners, offeri
|
||||
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>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
|
||||
<a href="https://atlassian.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Atlassian.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://krisp.ai/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||
@@ -47,6 +47,9 @@ Many companies are using Tart in their internal setups. Here are a few of them:
|
||||
<a href="https://www.pitsdatarecovery.net/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://ahrefs.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
|
||||
|
||||
@@ -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: 14 KiB |
@@ -8,7 +8,7 @@ struct Clone: AsyncParsableCommand {
|
||||
discussion: """
|
||||
Creates a local virtual machine by cloning either a remote or another local virtual machine.
|
||||
|
||||
Due to copy-on-write magic in Apple File System a cloned VM won't actually claim all the space right away.
|
||||
Due to copy-on-write magic in Apple File System, a cloned VM won't actually claim all the space right away.
|
||||
Only changes to a cloned disk will be written and claim new space. By default, Tart checks available capacity
|
||||
in Tart's home directory and checks if there is enough space for the worst possible scenario: when the whole disk
|
||||
will be modified.
|
||||
@@ -18,7 +18,7 @@ struct Clone: AsyncParsableCommand {
|
||||
"""
|
||||
)
|
||||
|
||||
@Argument(help: "source VM name")
|
||||
@Argument(help: "source VM name", completion: .custom(completeMachines))
|
||||
var sourceName: String
|
||||
|
||||
@Argument(help: "new VM name")
|
||||
@@ -63,7 +63,7 @@ struct Clone: AsyncParsableCommand {
|
||||
try lock.lock()
|
||||
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
&& sourceVM.state() != "suspended"
|
||||
&& sourceVM.state() != .Suspended
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
|
||||
@@ -16,7 +16,7 @@ struct Create: AsyncParsableCommand {
|
||||
@Flag(help: "create a Linux VM")
|
||||
var linux: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Disk size in Gb"))
|
||||
@Option(help: ArgumentHelp("Disk size in GB"))
|
||||
var diskSize: UInt16 = 50
|
||||
|
||||
func validate() throws {
|
||||
|
||||
@@ -5,7 +5,7 @@ import SwiftUI
|
||||
struct Delete: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Delete a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeMachines))
|
||||
var name: [String]
|
||||
|
||||
func run() async throws {
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
struct Export: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Export VM to a compressed .tvm file")
|
||||
|
||||
@Argument(help: "Source VM name.")
|
||||
@Argument(help: "Source VM name.", completion: .custom(completeMachines))
|
||||
var name: String
|
||||
|
||||
@Argument(help: "Path to the destination file.")
|
||||
|
||||
@@ -5,7 +5,7 @@ import SystemConfiguration
|
||||
struct FQN: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Get a fully-qualified VM name", shouldDisplay: false)
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeMachines))
|
||||
var name: String
|
||||
|
||||
func run() async throws {
|
||||
|
||||
@@ -2,6 +2,7 @@ import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
fileprivate struct VMInfo: Encodable {
|
||||
let OS: OS
|
||||
let CPU: Int
|
||||
let Memory: UInt64
|
||||
let Disk: Int
|
||||
@@ -14,7 +15,7 @@ fileprivate struct VMInfo: Encodable {
|
||||
struct Get: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
||||
|
||||
@Argument(help: "VM name.")
|
||||
@Argument(help: "VM name.", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
@Option(help: "Output format: text or json")
|
||||
@@ -25,8 +26,7 @@ struct Get: AsyncParsableCommand {
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||
|
||||
let info = VMInfo(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())
|
||||
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().rawValue)
|
||||
print(format.renderSingle(info))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
||||
struct IP: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
@Option(help: "Number of seconds to wait for a potential VM booting")
|
||||
@@ -61,7 +61,7 @@ struct IP: AsyncParsableCommand {
|
||||
return ip
|
||||
}
|
||||
case .dhcp:
|
||||
if let leases = try Leases(), let ip = try leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,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, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
try VMInfo(Source: "local", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||
})
|
||||
}
|
||||
|
||||
if source == nil || source == "oci" {
|
||||
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
||||
try VMInfo(Source: "oci", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
try VMInfo(Source: "OCI", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import Compression
|
||||
struct Push: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
|
||||
|
||||
@Argument(help: "local or remote VM name")
|
||||
@Argument(help: "local or remote VM name", completion: .custom(completeMachines))
|
||||
var localName: String
|
||||
|
||||
@Argument(help: "remote VM name(s)")
|
||||
|
||||
@@ -2,9 +2,9 @@ import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Rename: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Rename a VM")
|
||||
static var configuration = CommandConfiguration(abstract: "Rename a local VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
@Argument(help: "new VM name")
|
||||
@@ -20,7 +20,7 @@ struct Rename: AsyncParsableCommand {
|
||||
let localStorage = VMStorageLocal()
|
||||
|
||||
if !localStorage.exists(name) {
|
||||
throw ValidationError("failed to rename a non-existent VM: \(name)")
|
||||
throw ValidationError("failed to rename a non-existent local VM: \(name)")
|
||||
}
|
||||
|
||||
if localStorage.exists(newName) {
|
||||
|
||||
+192
-115
@@ -5,6 +5,7 @@ import Dispatch
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
import Sentry
|
||||
import System
|
||||
|
||||
var vm: VM?
|
||||
|
||||
@@ -14,7 +15,7 @@ struct IPNotFound: Error {
|
||||
struct Run: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Run a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
@@ -33,12 +34,17 @@ struct Run: AsyncParsableCommand {
|
||||
))
|
||||
var serialPath: String?
|
||||
|
||||
@Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .hidden))
|
||||
@Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .private))
|
||||
var graphics: Bool = false
|
||||
|
||||
@Flag(help: "Disable audio pass-through to host.")
|
||||
var noAudio: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Disable clipboard sharing between host and guest.",
|
||||
discussion: "Only works with Linux-based guest operating systems."))
|
||||
var noClipboard: Bool = false
|
||||
|
||||
#if arch(arm64)
|
||||
@Flag(help: "Boot into recovery mode")
|
||||
#endif
|
||||
@@ -54,22 +60,23 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
#if arch(arm64)
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Use Virtualization.Framework's VNC server instead of the build-in UI.",
|
||||
"Use Virtualization.Framework's VNC server instead of the built-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("""
|
||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\" --disk=\"/dev/disk0\")
|
||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\" --disk=\"/dev/disk0\" --disk=\"nbd://localhost:10809/myDisk\")
|
||||
""", discussion: """
|
||||
Can be either a disk image file or a block device like a local SSD on AWS EC2 Mac instances.
|
||||
Can be either a disk image file, a block device like a local SSD on AWS EC2 Mac instances or a Network Block Device (NBD).
|
||||
|
||||
Learn how to create a disk image using Disk Utility here:
|
||||
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
|
||||
|
||||
To work with block devices 'tart' binary must be executed as root which affects locating Tart VMs.
|
||||
To workaround this issue pass TART_HOME explicitly:
|
||||
To work with block devices, the easiest way is to modify their permissions (e.g. by using "sudo chown $USER /dev/diskX") or to run the Tart binary as root, which affects locating Tart VMs.
|
||||
|
||||
To work around this pass TART_HOME explicitly:
|
||||
|
||||
sudo TART_HOME="$HOME/.tart" tart run sonoma --disk=/dev/disk0
|
||||
""", valueName: "path[:ro]"))
|
||||
@@ -117,6 +124,12 @@ struct Run: AsyncParsableCommand {
|
||||
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
|
||||
var netSoftnet: Bool = false
|
||||
|
||||
@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
|
||||
@@ -133,12 +146,18 @@ 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 {
|
||||
print("--graphics is deprecated and will be removed in the future.\n")
|
||||
if graphics && noGraphics {
|
||||
throw ValidationError("--graphics and --no-graphics are mutually exclusive")
|
||||
}
|
||||
|
||||
if (noGraphics || vnc || vncExperimental) && captureSystemKeys {
|
||||
@@ -147,11 +166,15 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
let localStorage = VMStorageLocal()
|
||||
let vmDir = try localStorage.open(name)
|
||||
if try vmDir.state() == "suspended" {
|
||||
if try vmDir.state() == .Suspended {
|
||||
suspendable = true
|
||||
}
|
||||
|
||||
if suspendable {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
if (config.platform is Linux) {
|
||||
throw ValidationError("You can only suspend macOS VMs")
|
||||
}
|
||||
if dir.count > 0 {
|
||||
throw ValidationError("Suspending VMs with shared directories is not supported")
|
||||
}
|
||||
@@ -170,21 +193,19 @@ struct Run: AsyncParsableCommand {
|
||||
let vmDir = try localStorage.open(name)
|
||||
|
||||
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
if try vmDir.state() == "suspended" {
|
||||
try storageLock.lock() // lock before checking
|
||||
let needToGenerateNewMac = try localStorage.list().contains {
|
||||
// check if there is a running VM with the same MAC but different name
|
||||
try $1.running() && $1.macAddress() == vmDir.macAddress() && $1.name != vmDir.name
|
||||
}
|
||||
|
||||
if needToGenerateNewMac {
|
||||
print("There is already a running VM with the same MAC address!")
|
||||
print("Resetting VM to assign a new MAC address...")
|
||||
try vmDir.regenerateMACAddress()
|
||||
}
|
||||
try storageLock.lock()
|
||||
// check if there is a running VM with the same MAC address
|
||||
let hasRunningMACCollision = try localStorage.list().contains {
|
||||
// check if there is a running VM with the same MAC but different name
|
||||
try $1.running() && $1.macAddress() == vmDir.macAddress() && $1.name != vmDir.name
|
||||
}
|
||||
if hasRunningMACCollision {
|
||||
print("There is already a running VM with the same MAC address!")
|
||||
print("Resetting VM to assign a new MAC address...")
|
||||
try vmDir.regenerateMACAddress()
|
||||
}
|
||||
|
||||
if netSoftnet && isInteractiveSession() {
|
||||
if (netSoftnet || netHost) && isInteractiveSession() {
|
||||
try Softnet.configureSUIDBitIfNeeded()
|
||||
}
|
||||
|
||||
@@ -215,7 +236,8 @@ struct Run: AsyncParsableCommand {
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable,
|
||||
audio: !noAudio
|
||||
audio: !noAudio,
|
||||
clipboard: !noClipboard
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
@@ -268,7 +290,7 @@ struct Run: AsyncParsableCommand {
|
||||
try await vm!.start(recovery: recovery, resume: resume)
|
||||
|
||||
if let vncImpl = vncImpl {
|
||||
let vncURL = try await vncImpl.waitForURL()
|
||||
let vncURL = try await vncImpl.waitForURL(netBridged: !netBridged.isEmpty)
|
||||
|
||||
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
|
||||
print("VNC server is running at \(vncURL)")
|
||||
@@ -337,12 +359,23 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
sigusr1Src.activate()
|
||||
|
||||
if noGraphics {
|
||||
// Gracefull shutdown support. For macOS this brings up a dialog,
|
||||
// asking the user if they are sure they want to shut down.
|
||||
signal(SIGUSR2, SIG_IGN)
|
||||
let sigusr2Src = DispatchSource.makeSignalSource(signal: SIGUSR2)
|
||||
sigusr2Src.setEventHandler {
|
||||
Task {
|
||||
print("Requesting guest OS to stop...")
|
||||
try vm!.virtualMachine.requestStop()
|
||||
}
|
||||
}
|
||||
sigusr2Src.activate()
|
||||
|
||||
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||
if noGraphics || useVNCWithoutGraphics {
|
||||
// enter the main even loop, without bringing up any UI,
|
||||
// and just wait for the VM to exit.
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.prohibited)
|
||||
nsApp.run()
|
||||
NSApplication.shared.run()
|
||||
} else {
|
||||
runUI(suspendable, captureSystemKeys)
|
||||
}
|
||||
@@ -366,7 +399,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 {
|
||||
@@ -407,36 +451,61 @@ struct Run: AsyncParsableCommand {
|
||||
for rawDisk in expandedDiskPaths {
|
||||
let diskReadOnly = rawDisk.hasSuffix(readOnlySuffix)
|
||||
let diskPath = diskReadOnly ? String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count)) : rawDisk
|
||||
let diskURL = URL(fileURLWithPath: diskPath)
|
||||
|
||||
// check if `diskPath` is a block device or a directory
|
||||
if pathHasMode(diskPath, mode: S_IFBLK) || pathHasMode(diskPath, mode: S_IFDIR) {
|
||||
print("Using block device\n")
|
||||
let diskURL = URL(string: diskPath)
|
||||
if (["nbd", "nbds", "nbd+unix", "nbds+unix"].contains(diskURL?.scheme)) {
|
||||
guard #available(macOS 14, *) else {
|
||||
throw UnsupportedOSError("attaching Network Block Devices", "are")
|
||||
}
|
||||
|
||||
let nbdAttachment = try VZNetworkBlockDeviceStorageDeviceAttachment(
|
||||
url: diskURL!,
|
||||
timeout: 30,
|
||||
isForcedReadOnly: diskReadOnly,
|
||||
synchronizationMode: VZDiskSynchronizationMode.none
|
||||
)
|
||||
result.append(VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment))
|
||||
continue
|
||||
}
|
||||
|
||||
let diskFileURL = URL(fileURLWithPath: diskPath)
|
||||
|
||||
if pathHasMode(diskPath, mode: S_IFBLK) {
|
||||
guard #available(macOS 14, *) else {
|
||||
throw UnsupportedOSError("attaching block devices", "are")
|
||||
}
|
||||
let fileHandle = FileHandle(forUpdatingAtPath: diskPath)
|
||||
guard fileHandle != nil else {
|
||||
if ProcessInfo.processInfo.userName != "root" {
|
||||
throw RuntimeError.VMConfigurationError("need to run as root to work with block devices")
|
||||
|
||||
let fd = open(diskPath, diskReadOnly ? O_RDONLY : O_RDWR)
|
||||
if fd == -1 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
switch details.rawValue {
|
||||
case EBUSY:
|
||||
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "already in use, try umounting it via \"diskutil unmountDisk\" (when the whole disk) or \"diskutil umount\" (when mounting a single partition)")
|
||||
case EACCES:
|
||||
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "permission denied, consider changing the disk's owner using \"sudo chown $USER \(diskFileURL.url.path)\" or run Tart as a superuser (see --disk help for more details on how to do that correctly)")
|
||||
default:
|
||||
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "\(details)")
|
||||
}
|
||||
throw RuntimeError.VMConfigurationError("block device \(diskURL.url.path) seems to be already in use, unmount it first via 'diskutil unmount'")
|
||||
}
|
||||
let attachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: fileHandle!, readOnly: diskReadOnly, synchronizationMode: .full)
|
||||
result.append(VZVirtioBlockDeviceConfiguration(attachment: attachment))
|
||||
} else {
|
||||
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
|
||||
// see https://github.com/cirruslabs/tart/issues/323 for more details.
|
||||
if try !diskReadOnly && !FileLock(lockURL: diskURL).trylock() {
|
||||
throw RuntimeError.DiskAlreadyInUse("disk \(diskURL.url.path) seems to be already in use, unmount it first in Finder")
|
||||
}
|
||||
|
||||
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(
|
||||
url: diskURL,
|
||||
readOnly: diskReadOnly
|
||||
)
|
||||
result.append(VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment))
|
||||
let blockAttachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: FileHandle(fileDescriptor: fd, closeOnDealloc: true),
|
||||
readOnly: diskReadOnly, synchronizationMode: .full)
|
||||
result.append(VZVirtioBlockDeviceConfiguration(attachment: blockAttachment))
|
||||
continue
|
||||
}
|
||||
|
||||
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
|
||||
// see https://github.com/cirruslabs/tart/issues/323 for more details.
|
||||
if try !diskReadOnly && !FileLock(lockURL: diskFileURL).trylock() {
|
||||
throw RuntimeError.DiskAlreadyInUse("disk \(diskFileURL.url.path) seems to be already in use, unmount it first in Finder")
|
||||
}
|
||||
|
||||
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(
|
||||
url: diskFileURL,
|
||||
readOnly: diskReadOnly
|
||||
)
|
||||
result.append(VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment))
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -466,7 +535,7 @@ struct Run: AsyncParsableCommand {
|
||||
allNamedShares = false
|
||||
}
|
||||
}
|
||||
if directoryShares.count == 1 {
|
||||
if directoryShares.count == 1 && directoryShares.first!.name == nil {
|
||||
let directoryShare = directoryShares.first!
|
||||
let singleDirectoryShare = VZSingleDirectoryShare(directory: try directoryShare.createConfiguration())
|
||||
sharingDevice.share = singleDirectoryShare
|
||||
@@ -512,73 +581,69 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool) {
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
MainApp.suspendable = suspendable
|
||||
MainApp.capturesSystemKeys = captureSystemKeys
|
||||
MainApp.main()
|
||||
}
|
||||
}
|
||||
|
||||
nsApp.applicationIconImage = NSImage(data: AppIconData)
|
||||
struct MainApp: App {
|
||||
static var suspendable: Bool = false
|
||||
static var capturesSystemKeys: Bool = false
|
||||
|
||||
struct MainApp: App {
|
||||
static var disappearSignal: Int32 = SIGINT
|
||||
static var capturesSystemKeys: Bool = false
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
||||
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
Group {
|
||||
VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}.onDisappear {
|
||||
let ret = kill(getpid(), MainApp.disappearSignal)
|
||||
if ret != 0 {
|
||||
// Fallback to the old termination method that doesn't
|
||||
// propagate the cancellation to Task's in case graceful
|
||||
// termination via kill(2) is not successful
|
||||
NSApplication.shared.terminate(self)
|
||||
}
|
||||
}
|
||||
}.frame(
|
||||
minWidth: CGFloat(vm!.config.display.width),
|
||||
idealWidth: CGFloat(vm!.config.display.width),
|
||||
maxWidth: .infinity,
|
||||
minHeight: CGFloat(vm!.config.display.height),
|
||||
idealHeight: CGFloat(vm!.config.display.height),
|
||||
maxHeight: .infinity
|
||||
)
|
||||
}.commands {
|
||||
// Remove some standard menu options
|
||||
CommandGroup(replacing: .help, addition: {})
|
||||
CommandGroup(replacing: .newItem, addition: {})
|
||||
CommandGroup(replacing: .pasteboard, addition: {})
|
||||
CommandGroup(replacing: .textEditing, addition: {})
|
||||
CommandGroup(replacing: .undoRedo, addition: {})
|
||||
CommandGroup(replacing: .windowSize, addition: {})
|
||||
// Replace some standard menu options
|
||||
CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) }
|
||||
CommandMenu("Control") {
|
||||
Button("Start") {
|
||||
Task { try await vm!.virtualMachine.start() }
|
||||
}
|
||||
Button("Stop") {
|
||||
Task { try await vm!.virtualMachine.stop() }
|
||||
}
|
||||
Button("Request Stop") {
|
||||
Task { try vm!.virtualMachine.requestStop() }
|
||||
}
|
||||
if #available(macOS 14, *) {
|
||||
Button("Suspend") {
|
||||
kill(getpid(), SIGUSR1)
|
||||
}
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
Group {
|
||||
VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}.onDisappear {
|
||||
let ret = kill(getpid(), MainApp.suspendable ? SIGUSR1 : SIGINT)
|
||||
if ret != 0 {
|
||||
// Fallback to the old termination method that doesn't
|
||||
// propagate the cancellation to Task's in case graceful
|
||||
// termination via kill(2) is not successful
|
||||
NSApplication.shared.terminate(self)
|
||||
}
|
||||
}
|
||||
}.frame(
|
||||
minWidth: CGFloat(vm!.config.display.width),
|
||||
idealWidth: CGFloat(vm!.config.display.width),
|
||||
maxWidth: .infinity,
|
||||
minHeight: CGFloat(vm!.config.display.height),
|
||||
idealHeight: CGFloat(vm!.config.display.height),
|
||||
maxHeight: .infinity
|
||||
)
|
||||
}.commands {
|
||||
// Remove some standard menu options
|
||||
CommandGroup(replacing: .help, addition: {})
|
||||
CommandGroup(replacing: .newItem, addition: {})
|
||||
CommandGroup(replacing: .pasteboard, addition: {})
|
||||
CommandGroup(replacing: .textEditing, addition: {})
|
||||
CommandGroup(replacing: .undoRedo, addition: {})
|
||||
CommandGroup(replacing: .windowSize, addition: {})
|
||||
// Replace some standard menu options
|
||||
CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) }
|
||||
CommandMenu("Control") {
|
||||
Button("Start") {
|
||||
Task { try await vm!.virtualMachine.start() }
|
||||
}
|
||||
Button("Stop") {
|
||||
Task { try await vm!.virtualMachine.stop() }
|
||||
}
|
||||
Button("Request Stop") {
|
||||
Task { try vm!.virtualMachine.requestStop() }
|
||||
}
|
||||
if #available(macOS 14, *) {
|
||||
if (MainApp.suspendable) {
|
||||
Button("Suspend") {
|
||||
kill(getpid(), SIGUSR1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
|
||||
MainApp.capturesSystemKeys = captureSystemKeys
|
||||
MainApp.main()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +653,18 @@ class MinimalMenuAppDelegate: NSObject, NSApplicationDelegate, ObservableObject
|
||||
|
||||
func applicationDidFinishLaunching(_ : Notification) {
|
||||
NSApplication.shared.mainMenu?.removeItem(at: indexOfEditMenu)
|
||||
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
if (kill(getpid(), MainApp.suspendable ? SIGUSR1 : SIGINT) == 0) {
|
||||
return .terminateLater
|
||||
} else {
|
||||
return .terminateNow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,5 +874,5 @@ func pathHasMode(_ path: String, mode: mode_t) -> Bool {
|
||||
guard statRes != -1 else {
|
||||
return false
|
||||
}
|
||||
return (Int32(st.st_mode) & Int32(mode)) == Int32(mode)
|
||||
return (st.st_mode & S_IFMT) == mode
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
struct Set: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
@Option(help: "Number of VM CPUs")
|
||||
@@ -16,6 +17,14 @@ struct Set: AsyncParsableCommand {
|
||||
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||
var display: VMDisplayConfig?
|
||||
|
||||
@Flag(help: ArgumentHelp("Generate a new random MAC address for the VM."))
|
||||
var randomMAC: Bool = false
|
||||
|
||||
#if arch(arm64)
|
||||
@Flag(help: ArgumentHelp("Generate a new random serial number for the macOS VM."))
|
||||
#endif
|
||||
var randomSerial: Bool = false
|
||||
|
||||
@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
|
||||
@@ -51,6 +60,17 @@ struct Set: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
if randomMAC {
|
||||
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||
}
|
||||
|
||||
#if arch(arm64)
|
||||
if randomSerial {
|
||||
let oldPlatform = vmConfig.platform as! Darwin
|
||||
vmConfig.platform = Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: oldPlatform.hardwareModel)
|
||||
}
|
||||
#endif
|
||||
|
||||
try vmConfig.save(toURL: vmDir.configURL)
|
||||
|
||||
if diskSize != nil {
|
||||
|
||||
@@ -6,7 +6,7 @@ import SwiftDate
|
||||
struct Stop: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
|
||||
var name: String
|
||||
|
||||
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
|
||||
@@ -15,12 +15,12 @@ struct Stop: AsyncParsableCommand {
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
switch try vmDir.state() {
|
||||
case "suspended":
|
||||
case .Suspended:
|
||||
try stopSuspended(vmDir)
|
||||
case "running":
|
||||
case .Running:
|
||||
try await stopRunning(vmDir)
|
||||
default:
|
||||
return
|
||||
case .Stopped:
|
||||
throw RuntimeError.VMNotRunning(name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import SwiftDate
|
||||
struct Suspend: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
|
||||
var name: String
|
||||
|
||||
func run() async throws {
|
||||
@@ -14,7 +14,7 @@ struct Suspend: AsyncParsableCommand {
|
||||
let lock = try vmDir.lock()
|
||||
|
||||
// Find the VM's PID
|
||||
var pid = try lock.pid()
|
||||
let pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
||||
kSecAttrLabel as String: "Tart Credentials",
|
||||
]
|
||||
let value: [String: Any] = [kSecAttrAccount as String: user,
|
||||
kSecValueData as String: passwordData,
|
||||
kSecValueData as String: passwordData as Any,
|
||||
]
|
||||
|
||||
let status = SecItemCopyMatching(key as CFDictionary, nil)
|
||||
|
||||
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.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
struct LocalLayerCache {
|
||||
private let mappedDisk: Data
|
||||
private var digestToRange: [String : Range<Data.Index>] = [:]
|
||||
|
||||
init?(_ diskURL: URL, _ manifest: OCIManifest) throws {
|
||||
// mmap(2) the disk that contains the layers from the manifest
|
||||
self.mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
|
||||
// Record the ranges of the disk layers listed in the manifest
|
||||
var offset: UInt64 = 0
|
||||
|
||||
for layer in manifest.layers.filter({ $0.mediaType == diskV2MediaType }) {
|
||||
guard let uncompressedSize = layer.uncompressedSize() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.digestToRange[layer.digest] = Int(offset)..<Int(offset+uncompressedSize)
|
||||
|
||||
offset += uncompressedSize
|
||||
}
|
||||
}
|
||||
|
||||
func find(_ digest: String) -> Data? {
|
||||
guard let foundRange = self.digestToRange[digest] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.mappedDisk.subdata(in: foundRange)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import Virtualization
|
||||
import Semaphore
|
||||
|
||||
protocol Network {
|
||||
func attachments() -> [VZNetworkDeviceAttachment]
|
||||
func run(_ sema: DispatchSemaphore) throws
|
||||
func run(_ sema: AsyncSemaphore) throws
|
||||
func stop() async throws
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Semaphore
|
||||
import Virtualization
|
||||
|
||||
class NetworkBridged: Network {
|
||||
@@ -12,7 +13,7 @@ class NetworkBridged: Network {
|
||||
interfaces.map { VZBridgedNetworkDeviceAttachment(interface: $0) }
|
||||
}
|
||||
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
func run(_ sema: AsyncSemaphore) throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Semaphore
|
||||
import Virtualization
|
||||
|
||||
class NetworkShared: Network {
|
||||
@@ -6,7 +7,7 @@ class NetworkShared: Network {
|
||||
[VZNATNetworkDeviceAttachment()]
|
||||
}
|
||||
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
func run(_ sema: AsyncSemaphore) throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
import Atomics
|
||||
import Foundation
|
||||
import Semaphore
|
||||
import System
|
||||
import Virtualization
|
||||
|
||||
enum SoftnetError: Error {
|
||||
case InitializationFailed(why: String)
|
||||
@@ -15,7 +16,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 +31,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)
|
||||
}
|
||||
|
||||
@@ -44,7 +45,7 @@ class Softnet: Network {
|
||||
return executableURL
|
||||
}
|
||||
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
func run(_ sema: AsyncSemaphore) throws {
|
||||
try process.run()
|
||||
|
||||
monitorTask = Task {
|
||||
|
||||
@@ -2,5 +2,5 @@ import Foundation
|
||||
|
||||
protocol Disk {
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer]
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?) async throws
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ class DiskV1: Disk {
|
||||
return pushedLayers
|
||||
}
|
||||
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws {
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil) async throws {
|
||||
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||
throw OCIError.FailedToCreateVmFile
|
||||
}
|
||||
|
||||
@@ -4,21 +4,17 @@ 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] = []
|
||||
|
||||
// Open the disk file
|
||||
var mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
|
||||
// Compress the disk file as multiple individually decompressible streams,
|
||||
// each equal ``Self.layerLimitBytes`` bytes or slightly larger due to the
|
||||
// internal compressor's buffer
|
||||
var offset: UInt64 = 0
|
||||
|
||||
while let (compressedData, uncompressedSize, uncompressedDigest) = try compressNextLayerOfLimitBytesOrMore(mappedDisk: mappedDisk, offset: offset) {
|
||||
offset += uncompressedSize
|
||||
// each equal ``Self.layerLimitBytes`` bytes or less due to LZ4 compression
|
||||
for data in mappedDisk.chunks(ofCount: layerLimitBytes) {
|
||||
let compressedData = try (data as NSData).compressed(using: .lz4) as Data
|
||||
|
||||
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||
|
||||
@@ -26,18 +22,18 @@ class DiskV2: Disk {
|
||||
mediaType: diskV2MediaType,
|
||||
size: compressedData.count,
|
||||
digest: layerDigest,
|
||||
uncompressedSize: uncompressedSize,
|
||||
uncompressedContentDigest: uncompressedDigest
|
||||
uncompressedSize: UInt64(data.count),
|
||||
uncompressedContentDigest: Digest.hash(data)
|
||||
))
|
||||
|
||||
// Update progress using a relative value
|
||||
progress.completedUnitCount += Int64(uncompressedSize)
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
}
|
||||
|
||||
return pushedLayers
|
||||
}
|
||||
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws {
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil) async throws {
|
||||
// Support resumable pulls
|
||||
let pullResumed = FileManager.default.fileExists(atPath: diskURL.path)
|
||||
|
||||
@@ -96,19 +92,18 @@ class DiskV2: Disk {
|
||||
// Open the disk file
|
||||
let disk = try FileHandle(forWritingTo: diskURL)
|
||||
|
||||
// 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)
|
||||
// Check if we already have this layer contents in the local layer cache
|
||||
if let localLayerCache = localLayerCache, let data = localLayerCache.find(diskLayer.digest), Digest.hash(data) == uncompressedLayerContentDigest {
|
||||
// Fulfil the layer contents from the local blob cache
|
||||
_ = try zeroSkippingWrite(disk, diskWritingOffset, data)
|
||||
try disk.close()
|
||||
|
||||
// Update the progress
|
||||
progress.completedUnitCount += Int64(diskLayer.size)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var diskWritingOffset = diskWritingOffset
|
||||
|
||||
// Pull and decompress a single layer into the specific offset on disk
|
||||
@@ -117,15 +112,7 @@ class DiskV2: Disk {
|
||||
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)
|
||||
}
|
||||
diskWritingOffset = try zeroSkippingWrite(disk, diskWritingOffset, data)
|
||||
}
|
||||
|
||||
try await registry.pullBlob(diskLayer.digest) { data in
|
||||
@@ -145,44 +132,35 @@ class DiskV2: Disk {
|
||||
}
|
||||
}
|
||||
|
||||
private static func compressNextLayerOfLimitBytesOrMore(mappedDisk: Data, offset: UInt64) throws -> (Data, UInt64, String)? {
|
||||
var compressedData = Data()
|
||||
var bytesRead: UInt64 = 0
|
||||
let digest = Digest()
|
||||
private static func zeroSkippingWrite(_ disk: FileHandle, _ offset: UInt64, _ data: Data) throws -> UInt64 {
|
||||
let holeGranularityBytes = 64 * 1024
|
||||
|
||||
// Create a compressing filter that we will terminate upon
|
||||
// reaching ``Self.layerLimitBytes`` of compressed data
|
||||
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: bufferSizeBytes) { (length: Int) -> Data? in
|
||||
if compressedData.count >= Self.layerLimitBytes {
|
||||
return nil
|
||||
// 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 offset = offset
|
||||
|
||||
for chunk in data.chunks(ofCount: holeGranularityBytes) {
|
||||
// Only write chunks that are not zero
|
||||
if chunk != zeroChunk {
|
||||
try disk.seek(toOffset: offset)
|
||||
disk.write(chunk)
|
||||
}
|
||||
|
||||
let readFromByte = Int(offset + bytesRead)
|
||||
|
||||
let numBytesToRead = min(mappedDisk.count - readFromByte, bufferSizeBytes)
|
||||
if numBytesToRead == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
let uncompressedChunk = mappedDisk.subdata(in: readFromByte ..< (readFromByte + numBytesToRead))
|
||||
|
||||
bytesRead += UInt64(uncompressedChunk.count)
|
||||
digest.update(uncompressedChunk)
|
||||
|
||||
return uncompressedChunk
|
||||
offset += UInt64(chunk.count)
|
||||
}
|
||||
|
||||
// Retrieve compressed data chunks, but normally no more than ``Self.layerLimitBytes`` bytes
|
||||
while let compressedChunk = try compressingFilter.readData(ofLength: Self.bufferSizeBytes) {
|
||||
compressedData.append(compressedChunk)
|
||||
}
|
||||
|
||||
// Nothing was read this time from the disk,
|
||||
// signal that to the consumer
|
||||
if bytesRead == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (compressedData, bytesRead, digest.finalize())
|
||||
return offset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,6 @@ extension AsyncThrowingChannel<Data, Error> {
|
||||
}
|
||||
|
||||
struct TokenResponse: Decodable, Authentication {
|
||||
let defaultIssuedAt = Date()
|
||||
let defaultExpiresIn = 60
|
||||
|
||||
var token: String?
|
||||
var accessToken: String?
|
||||
var expiresIn: Int?
|
||||
@@ -66,7 +63,8 @@ struct TokenResponse: Decodable, Authentication {
|
||||
return dateFormatter.date(from: dateString) ?? Date()
|
||||
}
|
||||
|
||||
let response = try decoder.decode(TokenResponse.self, from: fromData)
|
||||
var response = try decoder.decode(TokenResponse.self, from: fromData)
|
||||
response.issuedAt = response.issuedAt ?? Date()
|
||||
|
||||
guard response.token != nil || response.accessToken != nil else {
|
||||
throw DecodingError.keyNotFound(CodingKeys.token, .init(codingPath: [], debugDescription: "Missing token or access_token. One must be present."))
|
||||
@@ -85,7 +83,7 @@ struct TokenResponse: Decodable, Authentication {
|
||||
//
|
||||
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
|
||||
|
||||
(issuedAt ?? defaultIssuedAt) + TimeInterval(expiresIn ?? defaultExpiresIn)
|
||||
(issuedAt ?? Date()) + TimeInterval(expiresIn ?? 60)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
|
||||
#if arch(arm64)
|
||||
|
||||
struct Darwin: PlatformSuspendable {
|
||||
struct Darwin: Platform {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
|
||||
@@ -103,32 +103,18 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
func keyboards() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||
return [VZMacKeyboardConfiguration(), VZUSBKeyboardConfiguration()]
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
return [VZUSBKeyboardConfiguration()]
|
||||
}
|
||||
}
|
||||
|
||||
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, *) {
|
||||
if #available(macOS 13, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return pointingDevices()
|
||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,3 @@ protocol Platform: Codable {
|
||||
func keyboards() -> [VZKeyboardConfiguration]
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||
}
|
||||
|
||||
protocol PlatformSuspendable: Platform {
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration]
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration]
|
||||
}
|
||||
|
||||
@@ -50,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
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
fileprivate func normalizeName(_ name: String) -> String {
|
||||
// Colons are misinterpreted by Zsh completion
|
||||
return name.replacingOccurrences(of: ":", with: "\\:")
|
||||
}
|
||||
|
||||
func completeMachines(_ arguments: [String]) -> [String] {
|
||||
let localVMs = (try? VMStorageLocal().list().map { name, _ in
|
||||
normalizeName(name)
|
||||
}) ?? []
|
||||
let ociVMs = (try? VMStorageOCI().list().map { name, _, _ in
|
||||
normalizeName(name)
|
||||
}) ?? []
|
||||
return (localVMs + ociVMs)
|
||||
}
|
||||
|
||||
func completeLocalMachines(_ arguments: [String]) -> [String] {
|
||||
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||
return localVMs.map { name, _ in normalizeName(name) }
|
||||
}
|
||||
|
||||
func completeRunningMachines(_ arguments: [String]) -> [String] {
|
||||
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||
return localVMs
|
||||
.filter { _, vmDir in (try? vmDir.state() == .Running) ?? false}
|
||||
.map { name, _ in normalizeName(name) }
|
||||
}
|
||||
+51
-29
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
import AsyncAlgorithms
|
||||
import Semaphore
|
||||
|
||||
struct UnsupportedRestoreImageError: Error {
|
||||
}
|
||||
@@ -30,7 +31,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
var configuration: VZVirtualMachineConfiguration
|
||||
|
||||
// Semaphore used to communicate with the VZVirtualMachineDelegate
|
||||
var sema = DispatchSemaphore(value: 0)
|
||||
var sema = AsyncSemaphore(value: 0)
|
||||
|
||||
// VM's config
|
||||
var name: String
|
||||
@@ -46,7 +47,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = [],
|
||||
suspendable: Bool = false,
|
||||
audio: Bool = true
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
@@ -63,7 +65,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable,
|
||||
audio: audio
|
||||
audio: audio,
|
||||
clipboard: clipboard
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -91,12 +94,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()
|
||||
@@ -108,7 +116,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()
|
||||
@@ -191,18 +199,24 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
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)
|
||||
try await install(ipswURL)
|
||||
}
|
||||
|
||||
defaultLogger.appendNewLine("Installing OS...")
|
||||
ProgressObserver(installer.progress).log(defaultLogger)
|
||||
@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
|
||||
|
||||
@@ -232,16 +246,18 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
await withTaskCancellationHandler(operation: {
|
||||
// Wait for the VM to finish running
|
||||
// or for the exit condition
|
||||
sema.wait()
|
||||
}, onCancel: {
|
||||
sema.signal()
|
||||
})
|
||||
do {
|
||||
try await sema.waitUnlessCancelled()
|
||||
} catch is CancellationError {
|
||||
// Triggered by "tart stop", Ctrl+C, or closing the
|
||||
// VM window, so shut down the VM gracefully below.
|
||||
}
|
||||
|
||||
if Task.isCancelled {
|
||||
try await stop()
|
||||
if (self.virtualMachine.state == VZVirtualMachine.State.running) {
|
||||
print("Stopping VM...")
|
||||
try await stop()
|
||||
}
|
||||
}
|
||||
|
||||
try await network.stop()
|
||||
@@ -277,7 +293,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||
serialPorts: [VZSerialPortConfiguration],
|
||||
suspendable: Bool = false,
|
||||
audio: Bool = true
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -306,13 +323,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
// Keyboard and mouse
|
||||
if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
|
||||
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
|
||||
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
|
||||
} else {
|
||||
configuration.keyboards = vmConfig.platform.keyboards()
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
}
|
||||
configuration.keyboards = vmConfig.platform.keyboards()
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
|
||||
// Networking
|
||||
configuration.networkDevices = network.attachments().map {
|
||||
@@ -322,6 +334,16 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
return vio
|
||||
}
|
||||
|
||||
// Clipboard sharing via Spice agent
|
||||
if clipboard && vmConfig.os == .linux {
|
||||
let spiceAgentConsoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||
let spiceAgentPort = VZVirtioConsolePortConfiguration()
|
||||
spiceAgentPort.name = VZSpiceAgentPortAttachment.spiceAgentPortName
|
||||
spiceAgentPort.attachment = VZSpiceAgentPortAttachment()
|
||||
spiceAgentConsoleDevice.ports[0] = spiceAgentPort
|
||||
configuration.consoleDevices.append(spiceAgentConsoleDevice)
|
||||
}
|
||||
|
||||
// Storage
|
||||
let attachment: VZDiskImageStorageDeviceAttachment = vmConfig.os == .linux ?
|
||||
// Use "cached" caching mode for virtio drive to prevent fs corruption on linux
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Compression
|
||||
import Foundation
|
||||
import Sentry
|
||||
|
||||
@@ -13,15 +14,7 @@ extension VMDirectory {
|
||||
private static let bufferSizeBytes = 64 * 1024 * 1024
|
||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||
|
||||
func pullFromRegistry(registry: Registry, reference: String, concurrency: UInt) async throws {
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
let (manifest, _) = try await registry.pullManifest(reference: reference)
|
||||
|
||||
return try await pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency)
|
||||
}
|
||||
|
||||
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt) async throws {
|
||||
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt, localLayerCache: LocalLayerCache?) async throws {
|
||||
// Pull VM's config file layer and re-serialize it into a config file
|
||||
let configLayers = manifest.layers.filter {
|
||||
$0.mediaType == configMediaType
|
||||
@@ -61,7 +54,13 @@ extension VMDirectory {
|
||||
let progress = Progress(totalUnitCount: diskCompressedSize)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL, concurrency: concurrency, progress: progress)
|
||||
do {
|
||||
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
|
||||
concurrency: concurrency, progress: progress,
|
||||
localLayerCache: localLayerCache)
|
||||
} catch let error where error is FilterError {
|
||||
throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
||||
defaultLogger.appendNewLine("pulling NVRAM...")
|
||||
|
||||
@@ -3,6 +3,12 @@ import Virtualization
|
||||
import CryptoKit
|
||||
|
||||
struct VMDirectory: Prunable {
|
||||
enum State: String {
|
||||
case Running = "running"
|
||||
case Suspended = "suspended"
|
||||
case Stopped = "stopped"
|
||||
}
|
||||
|
||||
var baseURL: URL
|
||||
|
||||
var configURL: URL {
|
||||
@@ -47,13 +53,13 @@ struct VMDirectory: Prunable {
|
||||
return try lock.pid() != 0
|
||||
}
|
||||
|
||||
func state() throws -> String {
|
||||
func state() throws -> State {
|
||||
if try running() {
|
||||
return "running"
|
||||
return State.Running
|
||||
} else if FileManager.default.fileExists(atPath: stateURL.path) {
|
||||
return "suspended"
|
||||
return State.Suspended
|
||||
} else {
|
||||
return "stopped"
|
||||
return State.Stopped
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ enum RuntimeError : Error {
|
||||
case VMAlreadyRunning(_ message: String)
|
||||
case NoIPAddressFound(_ message: String)
|
||||
case DiskAlreadyInUse(_ message: String)
|
||||
case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
|
||||
case InvalidDiskSize(_ message: String)
|
||||
case FailedToUpdateAccessDate(_ message: String)
|
||||
case PIDLockFailed(_ message: String)
|
||||
@@ -68,6 +69,7 @@ enum RuntimeError : Error {
|
||||
case OCIStorageError(_ message: String)
|
||||
case OCIUnsupportedDiskFormat(_ format: String)
|
||||
case SuspendFailed(_ message: String)
|
||||
case PullFailed(_ message: String)
|
||||
}
|
||||
|
||||
protocol HasExitCode {
|
||||
@@ -93,6 +95,8 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return message
|
||||
case .DiskAlreadyInUse(let message):
|
||||
return message
|
||||
case .FailedToOpenBlockDevice(let path, let explanation):
|
||||
return "failed to open block device \(path): \(explanation)"
|
||||
case .InvalidDiskSize(let message):
|
||||
return message
|
||||
case .FailedToUpdateAccessDate(let message):
|
||||
@@ -119,6 +123,8 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return "OCI disk format \(format) is not supported by this version of Tart"
|
||||
case .SuspendFailed(let message):
|
||||
return "Failed to suspend the VM: \(message)"
|
||||
case .PullFailed(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Sentry
|
||||
import Retry
|
||||
|
||||
class VMStorageOCI: PrunableStorage {
|
||||
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
@@ -112,6 +113,10 @@ class VMStorageOCI: PrunableStorage {
|
||||
continue
|
||||
}
|
||||
|
||||
// Split the relative VM's path at the last component
|
||||
// and figure out which character should be used
|
||||
// to join them together, either ":" for tags or
|
||||
// "@" for hashes
|
||||
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
||||
var name: String
|
||||
|
||||
@@ -122,6 +127,9 @@ class VMStorageOCI: PrunableStorage {
|
||||
name = parts.joined(separator: "@")
|
||||
}
|
||||
|
||||
// Remove the percent-encoding, if any
|
||||
name = percentDecode(name)
|
||||
|
||||
result.append((name, vmDir, isSymlink))
|
||||
}
|
||||
|
||||
@@ -134,7 +142,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...")
|
||||
@@ -188,7 +196,23 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency)
|
||||
try await retry(maxAttempts: 5, backoff: .exponentialWithFullJitter(baseDelay: .seconds(5), maxDelay: .seconds(60))) {
|
||||
var localLayerCache: LocalLayerCache? = nil
|
||||
|
||||
if name.reference.type == .Tag,
|
||||
let vmDir = try? open(name),
|
||||
let digest = try? digest(name),
|
||||
let (manifest, _) = try? await registry.pullManifest(reference: digest) {
|
||||
localLayerCache = try LocalLayerCache(vmDir.diskURL, manifest)
|
||||
}
|
||||
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache)
|
||||
} recoverFromFailure: { error in
|
||||
print("Error: \(error.localizedDescription)")
|
||||
print("Attempting to re-try...")
|
||||
|
||||
return .retry
|
||||
}
|
||||
try move(digestName, from: tmpVMDir)
|
||||
transaction.finish()
|
||||
}, onCancel: {
|
||||
@@ -231,7 +255,7 @@ extension URL {
|
||||
func appendingRemoteName(_ name: RemoteName) -> URL {
|
||||
var result: URL = self
|
||||
|
||||
for pathComponent in (name.host + "/" + name.namespace + "/" + name.reference.value).split(separator: "/") {
|
||||
for pathComponent in (percentEncode(name.host) + "/" + name.namespace + "/" + name.reference.value).split(separator: "/") {
|
||||
result = result.appendingPathComponent(String(pathComponent))
|
||||
}
|
||||
|
||||
@@ -239,6 +263,23 @@ extension URL {
|
||||
}
|
||||
|
||||
func appendingHost(_ name: RemoteName) -> URL {
|
||||
self.appendingPathComponent(name.host, isDirectory: true)
|
||||
self.appendingPathComponent(percentEncode(name.host), isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
// Work around a pretty inane Swift's URL behavior where calling
|
||||
// appendingPathComponent() or deletingLastPathComponent() on a
|
||||
// URL like URL(filePath: "example.com:8080") (note the "filePath")
|
||||
// will flip its isFileURL from "true" to "false" and discard its
|
||||
// absolute path infromation (if any).
|
||||
//
|
||||
// The same kind of operations won't do anything to a URL like
|
||||
// URL(filePath: "127.0.0.1:8080"), which makes things even more
|
||||
// ridiculous.
|
||||
private func percentEncode(_ s: String) -> String {
|
||||
return s.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: ":").inverted)!
|
||||
}
|
||||
|
||||
private func percentDecode(_ s: String) -> String {
|
||||
s.removingPercentEncoding!
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class FullFledgedVNC: VNC {
|
||||
vnc.start()
|
||||
}
|
||||
|
||||
func waitForURL() async throws -> URL {
|
||||
func waitForURL(netBridged: Bool) async throws -> URL {
|
||||
while true {
|
||||
// Port is 0 shortly after start(),
|
||||
// but will be initialized later
|
||||
|
||||
@@ -9,9 +9,9 @@ class ScreenSharingVNC: VNC {
|
||||
self.vmConfig = vmConfig
|
||||
}
|
||||
|
||||
func waitForURL() async throws -> URL {
|
||||
func waitForURL(netBridged: Bool) async throws -> URL {
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
let ip = try await IP.resolveIP(vmMACAddress, secondsToWait: 60)
|
||||
let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: netBridged ? .arp : .dhcp, secondsToWait: 60)
|
||||
|
||||
if let ip = ip {
|
||||
return URL(string: "vnc://\(ip)")!
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
protocol VNC {
|
||||
func waitForURL() async throws -> URL
|
||||
func waitForURL(netBridged: Bool) async throws -> URL
|
||||
func stop() throws
|
||||
}
|
||||
|
||||
@@ -3,24 +3,26 @@ import XCTest
|
||||
|
||||
final class TokenResponseTests: XCTestCase {
|
||||
func testBasic() throws {
|
||||
var expectedTokenExpiresAtRange = DateInterval()
|
||||
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
|
||||
XCTAssertEqual(tokenResponse.token, "some token")
|
||||
|
||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
|
||||
expectedTokenExpiresAtRange.end = Date().addingTimeInterval(60)
|
||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||
|
||||
XCTAssertTrue(tokenResponse.isValid())
|
||||
}
|
||||
|
||||
func testExpirationBasic() throws {
|
||||
var expectedTokenExpiresAtRange = DateInterval()
|
||||
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":2}".utf8)
|
||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
|
||||
XCTAssertEqual(tokenResponse.expiresIn, 2)
|
||||
|
||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
|
||||
expectedTokenExpiresAtRange.end = Date().addingTimeInterval(2)
|
||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||
|
||||
XCTAssertTrue(tokenResponse.isValid())
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
hide:
|
||||
- navigation
|
||||
title: Frequently Asked Questions
|
||||
description: Advanced configuration and troubleshooting tips for advanced configurations.
|
||||
---
|
||||
|
||||
## VM location on disk
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
---
|
||||
template: overrides/home.html
|
||||
title: Tart
|
||||
title: Toolset to build, run and manage macOS and Linux VMs
|
||||
description: Native performance. Remote storage for Virtual Machines. Many integrations including GitHub, GitLab and more.
|
||||
---
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: Buildkite Integration
|
||||
description: Run pipeline steps in isolated ephemeral Tart Virtual Machines.
|
||||
---
|
||||
|
||||
# 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):
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: Cirrus CLI
|
||||
description: Tool for running isolated tasks reproducibly in any environment with a simple YAML configuration.
|
||||
---
|
||||
|
||||
# Cirrus CLI
|
||||
|
||||
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# Cirrus Runners for GitHub Actions
|
||||
|
||||
*Cirrus Runners* is the fastest and most cost-efficient way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
|
||||
Your actions will be executed in clean macOS virtual machines with 4 Apple M2 cores.
|
||||
|
||||
## 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.
|
||||
|
||||
John A., Software Engineer at [GitKraken](https://www.gitkraken.com/):
|
||||
|
||||
> GitHub Actions MacOS-x86 runners have become increasingly unreliable, so we're moving our Mac builds over to arm64 because Cirrus Labs' M1 runners are not only ~3 times faster, they've also been far more stable.
|
||||
|
||||
Sebastian Jachec, Mobile Engineer at [Daybridge](https://www.daybridge.com/):
|
||||
|
||||
> It’s been plain-sailing with the Cirrus Runners — they’ve been great! They’re consistently 60+% faster on workflows that we previously used Github Actions’ macOS runners for.
|
||||
|
||||
## Pricing
|
||||
|
||||
Each Cirrus Runner costs $150 a month and there is no limit on the amount of minutes for your actions.
|
||||
We recommend to purchase several Cirrus Runners depending on your team size, so you can run actions in
|
||||
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).
|
||||
Please contact [sales@cirruslabs.org](mailto:sales@cirruslabs.org) in order to get all the details.
|
||||
|
||||
### CPU and Memory resources of Cirrus Runners
|
||||
|
||||
By default, a single Cirrus Runner is allocated with 4 M2 cores and 12 GB of unified memory which is enough for most of the workloads.
|
||||
For workloads that require more resources it is possible to use XL Cirrus Runners which have twice the resources: a full M2 chip with 8 cores
|
||||
and 24 GB of unified memory. Note that a single XL Cirrus Runner also uses twice the concurrency.
|
||||
|
||||
In order to use an XL Cirrus Runner for a job please append `-xl` suffix to your `runs-on` property. More on that down below.
|
||||
|
||||
## Installation
|
||||
|
||||
Once you configure [Cirrus Runners App](https://github.com/apps/cirrus-runners) for your organization, you'll be redirected
|
||||
to a checkout page powered by Stripe. During the checkout process you'll be able to configure a subscription for
|
||||
a desired amount of parallel Cirrus Runners and try it for free for 10 days.
|
||||
|
||||
Once configured, please follow instruction below. If you have any questions please contact [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
||||
Subscriptions with more than 10 runners also include Priority Support
|
||||
|
||||
## Configuring Cirrus Runners
|
||||
|
||||
In order for Cirrus Runners to be used by your GitHub Actions workflow jobs, specify a desired image in the `runs-on` property.
|
||||
|
||||
=== "Default Cirrus Runner"
|
||||
|
||||
```yaml
|
||||
name: Tests
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ghcr.io/cirruslabs/macos-sonoma-xcode:latest
|
||||
```
|
||||
|
||||
=== "XL Cirrus Runner"
|
||||
|
||||
```yaml
|
||||
name: Integration Tests
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ghcr.io/cirruslabs/macos-sonoma-xcode:latest-xl
|
||||
```
|
||||
|
||||
List of all available images can be found in [this repository](https://github.com/cirruslabs/macos-image-templates).
|
||||
|
||||
Note that Tart VM images don't have the same set of pre-installed packages as the official Intel GitHub runners.
|
||||
If something is missing please [create an issue within this repository](https://github.com/cirruslabs/macos-image-templates/issues/new).
|
||||
|
||||
When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`.
|
||||
Note that Cirrus Runners will get added to the default runner group.
|
||||
|
||||
!!! tip "Using Cirrus Runners with public repositories"
|
||||
|
||||
By default, only private repositories can access runners in a default runner group, but you can override this in your organization's settings:
|
||||
|
||||
```https://github.com/organizations/<YOUR ORGANIZATION NAME>/settings/actions/runner-groups/1```
|
||||
|
||||

|
||||
|
||||
### Dashboard
|
||||
|
||||
You can also see the status of your runners on the [Cirrus Runners Dashboard](https://cirrus-runners.app/). This dashboard
|
||||
also provides insights into price performance of your Cirrus Runners. Please check out [this blog post](/blog/2023/11/03/new-dashboard-with-insights-into-performance-of-cirrus-runners/)
|
||||
to learn more about what this dashboard can do for you.
|
||||
|
||||

|
||||
|
||||
## Data handling flow
|
||||
|
||||
By design Cirrus Runners service never sees any of your secrets or source code and acts as compute platform with the lastest
|
||||
Apple Silicon hardware that can quickly allocate CPU/Memory resources for your jobs.
|
||||
|
||||
Here is a high-level overview of how Cirrus Runners service manages runners for your organization:
|
||||
|
||||
- Cirrus Runner GitHub App is subscribed to [`workflow_job`](https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_job).
|
||||
- Upon receiving a new event targeting Cirrus Runners via `runs-on` property the following steps take place:
|
||||
|
||||
- Non-personal information about your job is saved to perform health checking of Cirrus Runners execution.
|
||||
- Cirrus Runners GitHub App has only one permission that allows generating temporary registration tokens for
|
||||
self-hosted GitHub Actions Runners. Note that Cirrus Runners GitHub App itself doesn't have access to contents of
|
||||
repositories in your organization.
|
||||
- Cirrus Runners Service creates a new single use Tart VM, generates a temporary registration tokens for self-hosted runners
|
||||
and passes it without storing inside the VM for the GitHub Actions Runner service to [start a ephemeral runner](https://github.blog/changelog/2021-09-20-github-actions-ephemeral-self-hosted-runners-new-webhooks-for-auto-scaling/).
|
||||
|
||||
- Cirrus Runners service continuously monitors health of the Tart VM executing your job to make sure it runs to completion.
|
||||
- After the job finishes the ephemeral Tart VM is getting destroyed with all the information of the job run.
|
||||
|
||||
If you have any questions or concerns please feel free to reach out to [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: GitLab Runner Executor
|
||||
description: Run jobs in isolated ephemeral Tart Virtual Machines.
|
||||
---
|
||||
|
||||
# GitLab Runner Executor
|
||||
|
||||
It is possible to run GitLab jobs in isolated ephemeral Tart Virtual Machines via [Tart Executor](https://github.com/cirruslabs/gitlab-tart-executor).
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: Managing Virtual Machine
|
||||
description: Use Packer to build custom VM images, configure VMs and work with remote OCI registries.
|
||||
---
|
||||
|
||||
# Managing Virtual Machine
|
||||
|
||||
## Creating from scratch
|
||||
@@ -15,7 +20,7 @@ tart create --from-ipsw=latest sonoma-vanilla
|
||||
tart run sonoma-vanilla
|
||||
```
|
||||
|
||||
After the initial booting of the VM you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
|
||||
After the initial booting of the VM, you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
|
||||
|
||||
1. Enable Auto-Login. Users & Groups -> Login Options -> Automatic login -> admin.
|
||||
2. Allow SSH. Sharing -> Remote Login
|
||||
@@ -48,7 +53,7 @@ sudo ufw allow ssh
|
||||
|
||||
## Configuring a VM
|
||||
|
||||
By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command.
|
||||
By default, a Tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed after VM creation with `tart set` command.
|
||||
Please refer to `tart set --help` for additional details.
|
||||
|
||||
## Building with Packer
|
||||
@@ -92,18 +97,19 @@ Here is a [repository with Packer templates](https://github.com/cirruslabs/macos
|
||||
|
||||
## Working with a Remote OCI Container Registry
|
||||
|
||||
<!-- markdownlint-disable MD034 -->
|
||||
For example, let's say you want to push/pull images to a registry hosted at https://acme.io/.
|
||||
<!-- markdownlint-enable MD034 -->
|
||||
Tart supports interacting with Open Container Initiative (OCI) registries, but only runs images created and pushed by Tart. This means images created for container engines, like Docker, can't be pulled. Instead, create a custom image as documented above.
|
||||
|
||||
For example, let's say you want to push/pull images to an OCI registry hosted at `https://acme.io/`.
|
||||
|
||||
### Registry Authorization
|
||||
|
||||
First, you need to log in and save credential for `acme.io` host via `tart login` command:
|
||||
First, you need to login to `acme.io` with the `tart login` command:
|
||||
|
||||
```bash
|
||||
tart login acme.io
|
||||
```
|
||||
|
||||
If you login to your registry with OAuth, you may need to create an access token to use as the password.
|
||||
Credentials are securely stored in Keychain.
|
||||
|
||||
In addition, Tart supports [Docker credential helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers)
|
||||
@@ -128,10 +134,10 @@ You can either pull an image:
|
||||
tart pull acme.io/remoteorg/name:latest
|
||||
```
|
||||
|
||||
...or instantiate a VM from a remote image:
|
||||
or create a VM from a remote image:
|
||||
|
||||
```bash
|
||||
tart clone acme.io/remoteorg/name:latest my-local-vm-name
|
||||
```
|
||||
|
||||
This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
|
||||
If the specified image is not already present, this invocation calls the `tart pull` implicitly before cloning.
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# Copyright (c) 2016-2024 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Definitions
|
||||
definitions:
|
||||
|
||||
# Background image
|
||||
- &background_image >-
|
||||
{{ layout.background_image | x }}
|
||||
|
||||
# Background color (default: indigo)
|
||||
- &background_color >-
|
||||
{%- if layout.background_color -%}
|
||||
{{ layout.background_color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("accent") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set accent = palette.get("accent", "indigo") -%}
|
||||
{%- set accent = accent.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ff1a47",
|
||||
"pink": "#f50056",
|
||||
"purple": "#df41fb",
|
||||
"deep-purple": "#7c4dff",
|
||||
"indigo": "#526cfe",
|
||||
"blue": "#4287ff",
|
||||
"light-blue": "#0091eb",
|
||||
"cyan": "#00bad6",
|
||||
"teal": "#00bda4",
|
||||
"green": "#00c753",
|
||||
"light-green": "#63de17",
|
||||
"lime": "#b0eb00",
|
||||
"yellow": "#ffd500",
|
||||
"amber": "#ffaa00",
|
||||
"orange": "#ff9100",
|
||||
"deep-orange": "#ff6e42"
|
||||
}[accent] or "#4051b5" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Text color (default: white)
|
||||
- &color >-
|
||||
{%- if layout.color -%}
|
||||
{{ layout.color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("accent") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set accent = palette.get("accent", "indigo") -%}
|
||||
{%- set accent = accent.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ffffff",
|
||||
"pink": "#ffffff",
|
||||
"purple": "#ffffff",
|
||||
"deep-purple": "#ffffff",
|
||||
"indigo": "#ffffff",
|
||||
"blue": "#ffffff",
|
||||
"light-blue": "#ffffff",
|
||||
"cyan": "#ffffff",
|
||||
"teal": "#ffffff",
|
||||
"green": "#ffffff",
|
||||
"light-green": "#ffffff",
|
||||
"lime": "#000000",
|
||||
"yellow": "#000000",
|
||||
"amber": "#000000",
|
||||
"orange": "#000000",
|
||||
"deep-orange": "#ffffff"
|
||||
}[accent] or "#ffffff" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Font family (default: Roboto)
|
||||
- &font_family >-
|
||||
{%- if layout.font_family -%}
|
||||
{{ layout.font_family }}
|
||||
{%- elif config.theme.font != false -%}
|
||||
{{ config.theme.font.get("text", "Roboto") }}
|
||||
{%- else -%}
|
||||
Roboto
|
||||
{%- endif -%}
|
||||
|
||||
# Font variant
|
||||
- &font_variant >-
|
||||
{%- if layout.font_variant -%}
|
||||
{{ layout.font_variant }}
|
||||
{%- endif -%}
|
||||
|
||||
# Site name
|
||||
- &site_name >-
|
||||
{{ config.site_name }}
|
||||
|
||||
# Page title
|
||||
- &page_title >-
|
||||
{%- if page.meta.no_title_in_card -%}
|
||||
{# do not show anything #}
|
||||
{%- elif layout.title -%}
|
||||
{{ layout.title }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("title", page.title) }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page title with site name
|
||||
- &page_title_with_site_name >-
|
||||
{%- if not page.is_homepage -%}
|
||||
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("title", page.title) }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page description
|
||||
- &page_description >-
|
||||
{%- if layout.description -%}
|
||||
{{ layout.description }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("description", config.site_description) | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page description for social card
|
||||
- &page_description_social_card >-
|
||||
{%- if layout.description -%}
|
||||
{{ layout.description }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("description", config.site_description_social_card) | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo
|
||||
- &logo >-
|
||||
{%- if layout.logo -%}
|
||||
{{ layout.logo }}
|
||||
{%- elif config.theme.logo -%}
|
||||
{{ config.docs_dir }}/{{ config.theme.logo }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo (icon)
|
||||
- &logo_icon >-
|
||||
{%- if not layout.logo -%}
|
||||
{{ config.theme.icon.logo | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Meta tags
|
||||
tags:
|
||||
|
||||
# Open Graph
|
||||
og:type: website
|
||||
og:title: *page_title_with_site_name
|
||||
og:description: *page_description
|
||||
og:image: "{{ image.url }}"
|
||||
og:image:type: "{{ image.type }}"
|
||||
og:image:width: "{{ image.width }}"
|
||||
og:image:height: "{{ image.height }}"
|
||||
og:url: "{{ page.canonical_url }}"
|
||||
|
||||
# Twitter
|
||||
twitter:card: summary_large_image
|
||||
twitter:title: *page_title_with_site_name
|
||||
twitter:description: *page_description
|
||||
twitter:image: "{{ image.url }}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Specification
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Card size and layers
|
||||
size: { width: 1200, height: 630 }
|
||||
layers:
|
||||
|
||||
# Background
|
||||
- background:
|
||||
image: *background_image
|
||||
color: *background_color
|
||||
|
||||
# Logo
|
||||
- size: { width: 170, height: 192 }
|
||||
offset: { x: 966, y: 64 }
|
||||
background:
|
||||
image: *logo
|
||||
icon:
|
||||
value: *logo_icon
|
||||
color: *color
|
||||
|
||||
# Site name
|
||||
- size: { width: 832, height: 42 }
|
||||
offset: { x: 64, y: 64 }
|
||||
typography:
|
||||
content: *site_name
|
||||
align: start center
|
||||
color: *color
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Motto
|
||||
- size: { width: 832, height: 150 }
|
||||
offset: { x: 64, y: 106 }
|
||||
typography:
|
||||
content: "{{ config.motto }}"
|
||||
align: start center
|
||||
color: *color
|
||||
line:
|
||||
amount: 2.5
|
||||
height: 1.25
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page title
|
||||
- size: { width: 1072, height: 256 }
|
||||
offset: { x: 64, y: 256 }
|
||||
typography:
|
||||
content: *page_title
|
||||
align: start center
|
||||
color: *color
|
||||
line:
|
||||
amount: 3
|
||||
height: 1.25
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page description
|
||||
- size: { width: 832, height: 64 }
|
||||
offset: { x: 64, y: 512 }
|
||||
typography:
|
||||
content: *page_description_social_card
|
||||
align: start center
|
||||
color: *color
|
||||
line:
|
||||
amount: 2
|
||||
height: 1.5
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Regular
|
||||
+1
-1
@@ -11,7 +11,7 @@ This page covers Terms of Service only for Cirrus Runners and Tart Documentation
|
||||
|
||||
### The Gist
|
||||
|
||||
Cirrus Labs Inc ("Cirrus Labs") operates the [Cirrus Runners service](../integrations/github-actions.md) which we hope you use.
|
||||
Cirrus Labs Inc ("Cirrus Labs") operates the [Cirrus Runners service](https://cirrus-runners.app/) which we hope you use.
|
||||
If you use it, please use it responsibly. If you don't, we'll have to terminate your subscription.
|
||||
|
||||
For paid plans, you'll be charged on a monthly basis. You can cancel anytime, but there are no refunds.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
hide:
|
||||
- navigation
|
||||
title: Licensing and Support
|
||||
description: Free Tier with 100 CPU core limit. Very affordable Tiers for larger enterprises.
|
||||
---
|
||||
|
||||
Both [Tart Virtualization](https://github.com/cirruslabs/tart) and [Orchard Orchestration](https://github.com/cirruslabs/orchard)
|
||||
|
||||
+15
-5
@@ -1,6 +1,8 @@
|
||||
---
|
||||
hide:
|
||||
- navigation
|
||||
title: Quick Start
|
||||
description: Install Tart and run your first virtual machine on Apple Silicon in minutes.
|
||||
---
|
||||
|
||||
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
|
||||
@@ -13,10 +15,10 @@ tart run sonoma-base
|
||||
|
||||
??? info "Manual installation from a release archive"
|
||||
It's also possible to manually install `tart` binary from the latest released archive:
|
||||
|
||||
|
||||
```bash
|
||||
curl -LO https://github.com/cirruslabs/tart/releases/latest/download/tart.tar.gz
|
||||
tar -xzvf tart.tar.gz
|
||||
curl -LO https://github.com/cirruslabs/tart/releases/latest/download/tart-arm64.tar.gz
|
||||
tar -xzvf tart-arm64.tar.gz
|
||||
./tart.app/Contents/MacOS/tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
|
||||
./tart.app/Contents/MacOS/tart run sonoma-base
|
||||
```
|
||||
@@ -129,7 +131,7 @@ Note: to use the directory mounting feature, the guest VM needs to run macOS 13.
|
||||
|
||||
??? tip "Changing mount location"
|
||||
It is possible to remount the directories after a virtual machine is started by running the following commands:
|
||||
|
||||
|
||||
```bash
|
||||
sudo umount "/Volumes/My Shared Files"
|
||||
mkdir ~/workspace
|
||||
@@ -143,7 +145,15 @@ Note: to use the directory mounting feature, the guest VM needs to run macOS 13.
|
||||
To be able to access the shared directories from the Linux guest, you need to manually mount the virtual filesystem first:
|
||||
|
||||
```bash
|
||||
mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
|
||||
sudo mkdir /mnt/shared
|
||||
sudo mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
|
||||
```
|
||||
|
||||
The directory we've mounted above will be accessible from the `/mnt/shared/project` path inside a guest VM.
|
||||
|
||||
??? info "Auto-mount at boot time"
|
||||
To automatically mount this directory at boot time, add the following line to the `/etc/fstab` file:
|
||||
|
||||
```shell
|
||||
com.apple.virtio-fs.automount /mnt/shared virtiofs rw,relatime 0 0
|
||||
```
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
pytest
|
||||
testcontainers
|
||||
requests
|
||||
requests == 2.31.0 # work around https://github.com/psf/requests/issues/6707
|
||||
bitmath
|
||||
pytest-dependency
|
||||
paramiko
|
||||
|
||||
@@ -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"])
|
||||
|
||||
+7
-5
@@ -2,12 +2,11 @@ repo_url: https://github.com/cirruslabs/tart/
|
||||
site_url: https://tart.run/
|
||||
edit_uri: blob/main/docs/
|
||||
|
||||
site_name: Tart
|
||||
site_name: Tart Virtualization
|
||||
site_author: Cirrus Labs
|
||||
copyright: © Cirrus Labs 2017-present
|
||||
site_description: >
|
||||
Tart is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon.
|
||||
Built by CI engineers for your automation needs.
|
||||
|
||||
remote_branch: main
|
||||
|
||||
@@ -47,7 +46,10 @@ plugins:
|
||||
match_path: blog/posts/.*
|
||||
date_from_meta:
|
||||
as_creation: date
|
||||
- social
|
||||
- social:
|
||||
cards_layout_dir: docs/layouts
|
||||
cards_layout: custom
|
||||
debug: true
|
||||
- search
|
||||
- minify
|
||||
|
||||
@@ -89,10 +91,10 @@ nav:
|
||||
- "Home": index.md
|
||||
- "Quick Start": quick-start.md
|
||||
- "Integrations":
|
||||
- "GitHub Actions": integrations/github-actions.md
|
||||
- "Self-hosted CI": integrations/cirrus-cli.md
|
||||
- "GitHub Actions": https://cirrus-runners.app/
|
||||
- "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
|
||||
- "Orchestration": https://github.com/cirruslabs/orchard
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
docker run --pull=always --rm -it -p 8000:8000 -v ${PWD}:/docs ghcr.io/cirruslabs/mkdocs-material-insiders:latest build
|
||||
@@ -8,8 +8,11 @@ set -e
|
||||
swift build --product tart
|
||||
codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
|
||||
mkdir -p .build/tart.app/Contents/MacOS
|
||||
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 "$@"
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
docker run --pull=always --rm -it -p 8000:8000 -v ${PWD}:/docs ghcr.io/cirruslabs/mkdocs-material-insiders:latest
|
||||
Reference in New Issue
Block a user