Compare commits

..
96 changed files with 492 additions and 2333 deletions
+7 -12
View File
@@ -1,7 +1,7 @@
use_compute_credits: true
task:
name: Test
name: Test on Sonoma
alias: test
persistent_worker:
labels:
@@ -11,8 +11,6 @@ task:
build_script:
- swift build
test_script:
# Add /usr/sbin to PATH, otherwise testDiskutilInfo() fails to locate "diskutil"
- export PATH=$PATH:/usr/sbin
- swift test
integration_test_script:
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
@@ -23,7 +21,6 @@ task:
- source venv/bin/activate
- pip install -r requirements.txt
- pytest --verbose --junit-xml=pytest-junit.xml
- go test -v ./...
pytest_junit_result_artifacts:
path: "integration-tests/pytest-junit.xml"
format: junit
@@ -40,7 +37,7 @@ task:
name: Lint
alias: lint
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sequoia
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:
@@ -57,7 +54,7 @@ task:
name: Build ($BUILD_ARCH)
alias: build
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sequoia
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:
@@ -70,7 +67,7 @@ task:
- lint
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sequoia
image: ghcr.io/cirruslabs/macos-runner:sonoma
env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
@@ -86,9 +83,8 @@ task:
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
install_script:
- brew install go
- brew install go goreleaser/tap/goreleaser-pro
- brew install mitchellh/gon/gon
- brew install --cask goreleaser/tap/goreleaser-pro
info_script:
- security find-identity -v
- xcodebuild -version
@@ -106,7 +102,7 @@ task:
- test
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sequoia
image: ghcr.io/cirruslabs/macos-runner:sonoma
env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
@@ -125,9 +121,8 @@ task:
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
install_script:
- brew install go getsentry/tools/sentry-cli
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
- brew install mitchellh/gon/gon
- brew install --cask goreleaser/tap/goreleaser-pro
info_script:
- security find-identity -v
- xcodebuild -version
-5
View File
@@ -4,8 +4,3 @@ root = true
indent_style = space
indent_size = 2
insert_final_newline = true
[integration-tests/**]
indent_style = unset
indent_size = unset
insert_final_newline = unset
-37
View File
@@ -1,37 +0,0 @@
name: Build
on:
workflow_dispatch:
permissions:
contents: read
jobs:
build_cached:
name: Build tart (cached)
runs-on: ghcr.io/cirruslabs/macos-runner:tahoe
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- name: Build
run: |
export COMPILATION_CACHE_ENABLE_CACHING=YES
export COMPILATION_CACHE_REMOTE_SERVICE_PATH="$HOME/.cirruslabs/omni-cache.sock"
export COMPILATION_CACHE_ENABLE_PLUGIN=YES
export COMPILATION_CACHE_ENABLE_INTEGRATED_QUERIES=YES
export COMPILATION_CACHE_ENABLE_DETACHED_KEY_QUERIES=YES
export SWIFT_ENABLE_COMPILE_CACHE=YES
export SWIFT_ENABLE_EXPLICIT_MODULES=YES
export SWIFT_USE_INTEGRATED_DRIVER=YES
export CLANG_ENABLE_COMPILE_CACHE=YES
export CLANG_ENABLE_MODULES=YES
swift build --build-system swiftbuild --product tart
build_no_cache:
name: Build tart (no cache)
runs-on: ghcr.io/cirruslabs/macos-runner:tahoe
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- name: Build
run: swift build --build-system swiftbuild --product tart
-3
View File
@@ -8,9 +8,6 @@ tart.xcodeproj/
# AppCode
.idea/
# VS Code
.vscode/
# Swift
.build/
-1
View File
@@ -29,7 +29,6 @@ Table of Contents
1. Code should follow camel case
2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command:
```bash
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
```
+16 -169
View File
@@ -1,5 +1,5 @@
{
"originHash" : "061dfe6cdf4e6dbf32b51c5e7023c4ae69726dcafb42a35b34e5489b0338c17f",
"originHash" : "22b3726bc4e4c6e9c04ac97cb08a82967feb39960a93d2909768a16e11576748",
"pins" : [
{
"identity" : "antlr4",
@@ -10,24 +10,6 @@
"version" : "4.13.2"
}
},
{
"identity" : "cirruslabs_tart-guest-agent_apple_swift",
"kind" : "remoteSourceControl",
"location" : "https://buf.build/gen/swift/git/1.33.3-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_apple_swift.git",
"state" : {
"revision" : "5c49a653f4b003161077d194bc708b7373628c99",
"version" : "1.33.3-20260114140118-bd09c26a260f.1"
}
},
{
"identity" : "cirruslabs_tart-guest-agent_grpc_swift",
"kind" : "remoteSourceControl",
"location" : "https://buf.build/gen/swift/git/1.27.1-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_grpc_swift.git",
"state" : {
"branch" : "main",
"revision" : "4935078c2fe2508360843596d71a1f844ce639a6"
}
},
{
"identity" : "dynamic",
"kind" : "remoteSourceControl",
@@ -37,42 +19,6 @@
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
}
},
{
"identity" : "grpc-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/grpc/grpc-swift.git",
"state" : {
"revision" : "8f57f68b9d247fe3759fa9f18e1fe919911e6031",
"version" : "1.27.1"
}
},
{
"identity" : "opentelemetry-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/open-telemetry/opentelemetry-swift",
"state" : {
"branch" : "main",
"revision" : "ed37be9525081509ab62410d38b705c2b3f0d5a4"
}
},
{
"identity" : "opentelemetry-swift-core",
"kind" : "remoteSourceControl",
"location" : "https://github.com/open-telemetry/opentelemetry-swift-core.git",
"state" : {
"revision" : "240c8d5e36c3c7b774ed961325369f0b1f2c965f",
"version" : "2.3.0"
}
},
{
"identity" : "opentracing-objc",
"kind" : "remoteSourceControl",
"location" : "https://github.com/undefinedlabs/opentracing-objc",
"state" : {
"revision" : "18c1a35ca966236cee0c5a714a51a73ff33384c1",
"version" : "0.5.2"
}
},
{
"identity" : "semaphore",
"kind" : "remoteSourceControl",
@@ -82,6 +28,15 @@
"version" : "0.1.0"
}
},
{
"identity" : "sentry-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa",
"state" : {
"revision" : "5575af93efb776414f243e93d6af9f6258dc539a",
"version" : "8.36.0"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
@@ -96,8 +51,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3",
"version" : "1.6.1"
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
"version" : "1.5.0"
}
},
{
@@ -105,35 +60,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "671108c96644956dddcd89dd59c203dcdb36cec7",
"version" : "1.1.4"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "db6eea3692638a65e2124990155cd220c2915903",
"version" : "1.3.0"
}
},
{
"identity" : "swift-http-types",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types.git",
"state" : {
"revision" : "a0a57e949a8903563aba4615869310c0ebf14c03",
"version" : "1.4.0"
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
"version" : "1.2.0"
}
},
{
@@ -141,62 +69,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181",
"version" : "1.9.1"
}
},
{
"identity" : "swift-metrics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-metrics.git",
"state" : {
"revision" : "0743a9364382629da3bf5677b46a2c4b1ce5d2a6",
"version" : "2.7.1"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "233f61bc2cfbb22d0edeb2594da27a20d2ce514e",
"version" : "2.93.0"
}
},
{
"identity" : "swift-nio-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "f1f6f772198bee35d99dd145f1513d8581a54f2c",
"version" : "1.26.0"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "4281466512f63d1bd530e33f4aa6993ee7864be0",
"version" : "1.36.0"
}
},
{
"identity" : "swift-nio-ssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "4b38f35946d00d8f6176fe58f96d83aba64b36c7",
"version" : "2.31.0"
}
},
{
"identity" : "swift-nio-transport-services",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-transport-services.git",
"state" : {
"revision" : "cd1e89816d345d2523b11c55654570acd5cd4c56",
"version" : "1.24.0"
"revision" : "9cb486020ebf03bfa5b5df985387a14a98744537",
"version" : "1.6.1"
}
},
{
@@ -208,15 +82,6 @@
"version" : "1.0.2"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
"version" : "1.33.3"
}
},
{
"identity" : "swift-retry",
"kind" : "remoteSourceControl",
@@ -235,15 +100,6 @@
"version" : "1.8.0"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1",
"version" : "1.4.2"
}
},
{
"identity" : "swift-xattr",
"kind" : "remoteSourceControl",
@@ -288,15 +144,6 @@
"branch" : "master",
"revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
}
},
{
"identity" : "thrift-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/undefinedlabs/Thrift-Swift",
"state" : {
"revision" : "18ff09e6b30e589ed38f90a1af23e193b8ecef8e",
"version" : "1.1.2"
}
}
],
"version" : 3
+3 -12
View File
@@ -10,24 +10,20 @@ let package = Package(
.executable(name: "tart", targets: ["tart"])
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.6.1"),
.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.2.0"),
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
.package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"),
.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.36.0"),
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
.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"),
.package(url: "https://github.com/jozefizso/swift-xattr", from: "3.0.0"),
.package(url: "https://github.com/grpc/grpc-swift.git", .upToNextMajor(from: "1.27.0")),
.package(url: "https://buf.build/gen/swift/git/1.27.1-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_grpc_swift.git", branch: "main"),
.package(url: "https://github.com/open-telemetry/opentelemetry-swift", branch: "main"),
.package(url: "https://github.com/open-telemetry/opentelemetry-swift-core", from: "2.3.0"),
],
targets: [
.executableTarget(name: "tart", dependencies: [
@@ -37,18 +33,13 @@ let package = Package(
.product(name: "SwiftDate", package: "SwiftDate"),
.product(name: "Antlr4Static", package: "Antlr4"),
.product(name: "Atomics", package: "swift-atomics"),
.product(name: "Sentry", package: "sentry-cocoa"),
.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"),
.product(name: "XAttr", package: "swift-xattr"),
.product(name: "GRPC", package: "grpc-swift"),
.product(name: "Cirruslabs_TartGuestAgent_Grpc_Swift", package: "cirruslabs_tart-guest-agent_grpc_swift"),
.product(name: "OpenTelemetryApi", package: "opentelemetry-swift-core"),
.product(name: "OpenTelemetrySdk", package: "opentelemetry-swift-core"),
.product(name: "OpenTelemetryProtocolExporterHTTP", package: "opentelemetry-swift"),
.product(name: "ResourceExtension", package: "opentelemetry-swift"),
], exclude: [
"OCI/Reference/Makefile",
"OCI/Reference/Reference.g4",
+2 -2
View File
@@ -66,8 +66,8 @@ Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura)
```bash
brew install cirruslabs/cli/tart
tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest tahoe-base
tart run tahoe-base
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
tart run sonoma-base
```
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
-2
View File
@@ -21,7 +21,5 @@
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Access to OCI registries on the local network</string>
</dict>
</plist>
+14 -10
View File
@@ -31,8 +31,14 @@ struct Clone: AsyncParsableCommand {
@Flag(help: .hidden)
var deduplicate: Bool = false
@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
var pruneLimit: UInt = 100
@Option(help: .hidden)
var proxy: String?
@Option(help: .hidden)
var caCert: String?
@Option(help: .hidden)
var maxRetries: UInt = 5
func validate() throws {
if newName.contains("/") {
@@ -45,13 +51,13 @@ struct Clone: AsyncParsableCommand {
}
func run() async throws {
let ociStorage = try VMStorageOCI()
let localStorage = try VMStorageLocal()
let ociStorage = VMStorageOCI()
let localStorage = VMStorageLocal()
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
// Pull the VM in case it's OCI-based and doesn't exist locally yet
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure, proxy: proxy, caCert: caCert)
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate, maxRetries: maxRetries)
}
let sourceVM = try VMStorageHelper.open(sourceName)
@@ -79,10 +85,8 @@ struct Clone: AsyncParsableCommand {
//
// So, once we clone the VM let's try to claim the rest of space for the VM to run without errors.
let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
// Avoid reclaiming an excessive amount of disk space.
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
if reclaimBytes > 0 {
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM)
if unallocatedBytes > 0 {
try Prune.reclaimIfNeeded(UInt64(unallocatedBytes), sourceVM)
}
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
+3 -11
View File
@@ -10,7 +10,7 @@ struct Create: AsyncParsableCommand {
@Argument(help: "VM name")
var name: String
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file or URL (or \"latest\", to fetch the latest supported IPSW automatically)", valueName: "path"), completion: .file())
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file or URL (or \"latest\", to fetch the latest supported IPSW automatically)", valueName: "path"))
var fromIPSW: String?
@Flag(help: "create a Linux VM")
@@ -19,9 +19,6 @@ struct Create: AsyncParsableCommand {
@Option(help: ArgumentHelp("Disk size in GB"))
var diskSize: UInt16 = 50
@Option(help: ArgumentHelp("Disk image format", discussion: "ASIF format provides better performance but requires macOS 26 Tahoe or later"))
var diskFormat: DiskImageFormat = .raw
func validate() throws {
if fromIPSW == nil && !linux {
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
@@ -31,11 +28,6 @@ struct Create: AsyncParsableCommand {
throw ValidationError("Only Linux VMs are supported on Intel!")
}
#endif
// Validate disk format support
if !diskFormat.isSupported {
throw ValidationError("Disk format '\(diskFormat.rawValue)' is not supported on this system.")
}
}
func run() async throws {
@@ -66,12 +58,12 @@ struct Create: AsyncParsableCommand {
ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath)
}
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize, diskFormat: diskFormat)
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
}
#endif
if linux {
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize, diskFormat: diskFormat)
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
}
try VMStorageLocal().move(name, from: tmpVMDir)
-233
View File
@@ -1,233 +0,0 @@
import ArgumentParser
import Foundation
import NIOPosix
import GRPC
import Cirruslabs_TartGuestAgent_Grpc_Swift
struct ExecCustomExitCodeError: Error {
let exitCode: Int32
}
struct Exec: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Execute a command in a running VM", discussion: """
Requires Tart Guest Agent running in a guest VM.
Note that all non-vanilla Cirrus Labs VM images already have the Tart Guest Agent installed.
""")
@Flag(name: [.customShort("i")], help: "Attach host's standard input to a remote command")
var interactive: Bool = false
@Flag(name: [.customShort("t")], help: "Allocate a remote pseudo-terminal (PTY)")
var tty: Bool = false
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
var name: String
@Argument(parsing: .captureForPassthrough, help: "Command to execute")
var command: [String]
func run() async throws {
// We only have withThrowingDiscardingTaskGroup available starting from macOS 14
if #unavailable(macOS 14) {
throw RuntimeError.Generic("\"tart exec\" is only available on macOS 14 (Sonoma) or newer")
}
// Open VM's directory
let vmDir = try VMStorageLocal().open(name)
// Ensure that the VM is running
if try !vmDir.running() {
throw RuntimeError.VMNotRunning(name)
}
// Create a gRPC channel connected to the VM's control socket
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer {
try! group.syncShutdownGracefully()
}
// Change the current working directory to a VM's base directory
// to work around Unix domain socket 104 byte limitation [1]
//
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
if let baseURL = vmDir.controlSocketURL.baseURL {
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
}
let channel = try GRPCChannelPool.with(
target: .unixDomainSocket(vmDir.controlSocketURL.relativePath),
transportSecurity: .plaintext,
eventLoopGroup: group,
)
defer {
try! channel.close().wait()
}
// Switch controlling terminal into raw mode when remote pseudo-terminal is requested
var state: State? = nil
if tty && Term.IsTerminal() {
state = try Term.MakeRaw()
}
defer {
// Restore terminal to its initial state
if let state {
try! Term.Restore(state)
}
}
// Execute a command in a running VM
do {
try await execute(channel)
} catch let error as GRPCConnectionPoolError {
throw RuntimeError.Generic("Failed to connect to the VM using its control socket: \(error.localizedDescription), is the Tart Guest Agent running?")
}
}
private func execute(_ channel: GRPCChannel) async throws {
let agentAsyncClient = AgentAsyncClient(channel: channel)
let execCall = agentAsyncClient.makeExecCall()
try await execCall.requestStream.send(.with {
$0.type = .command(.with {
$0.name = command[0]
$0.args = Array(command.dropFirst(1))
$0.interactive = interactive
$0.tty = tty
if tty {
$0.terminalSize = .with {
let (width, height) = try! Term.GetSize()
$0.cols = UInt32(width)
$0.rows = UInt32(height)
}
}
})
})
// Process command events and optionally send our standard input and/or terminal dimensions
try await withThrowingTaskGroup { group in
// Stream host's standard input if interactive mode is enabled
if interactive {
let stdinStream = AsyncThrowingStream<Data, Error> { continuation in
let handle = FileHandle.standardInput
if isRegularFile(handle.fileDescriptor) {
// Standard input can be a regular file when input redirection (<) is used,
// in which case the handle won't receive any new readability events, so we
// just read the file normally here in chunks and consider done with it
//
// Ideally this is best handled by using non-blocking I/O, but Swift's
// standard library only offers inefficient bytes[1] property and SwiftNIO's
// NIOFileSystem doesn't seem to support opening raw file descriptors.
//
// [1]: https://developer.apple.com/documentation/foundation/filehandle/bytes
while true {
do {
let data = try handle.read(upToCount: 64 * 1024)
if let data = data {
continuation.yield(data)
} else {
continuation.finish()
break
}
} catch (let error) {
continuation.finish(throwing: error)
break
}
}
} else {
handle.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
continuation.finish()
} else {
continuation.yield(data)
}
}
}
}
group.addTask {
for try await stdinData in stdinStream {
try await execCall.requestStream.send(.with {
$0.type = .standardInput(.with {
$0.data = stdinData
})
})
}
// Signal EOF as we're done reading standard input
try await execCall.requestStream.send(.with {
$0.type = .standardInput(.with {
$0.data = Data()
})
})
}
}
// Stream host's terminal dimensions if pseudo-terminal is requested
signal(SIGWINCH, SIG_IGN)
let sigwinchSrc = DispatchSource.makeSignalSource(signal: SIGWINCH)
sigwinchSrc.activate()
if tty {
let terminalDimensionsStream = AsyncStream { continuation in
sigwinchSrc.setEventHandler {
continuation.yield(try! Term.GetSize())
}
}
group.addTask {
for await (width, height) in terminalDimensionsStream {
try await execCall.requestStream.send(.with {
$0.type = .terminalResize(.with {
$0.cols = UInt32(width)
$0.rows = UInt32(height)
})
})
}
}
}
// Process command events
group.addTask {
for try await response in execCall.responseStream {
switch response.type {
case .standardOutput(let ioChunk):
try FileHandle.standardOutput.write(contentsOf: ioChunk.data)
case .standardError(let ioChunk):
try FileHandle.standardError.write(contentsOf: ioChunk.data)
case .exit(let exit):
throw ExecCustomExitCodeError(exitCode: exit.code)
default:
// Unknown event, do nothing
continue
}
}
}
while !group.isEmpty {
do {
try await group.next()
} catch {
group.cancelAll()
throw error
}
}
}
}
}
private func isRegularFile(_ fileDescriptor: Int32) -> Bool {
var stat = stat()
if fstat(fileDescriptor, &stat) != 0 {
return false
}
return (stat.st_mode & S_IFMT) == S_IFREG
}
+1 -1
View File
@@ -7,7 +7,7 @@ struct Export: AsyncParsableCommand {
@Argument(help: "Source VM name.", completion: .custom(completeMachines))
var name: String
@Argument(help: "Path to the destination file.", completion: .file())
@Argument(help: "Path to the destination file.")
var path: String?
func run() async throws {
+1 -2
View File
@@ -6,7 +6,6 @@ fileprivate struct VMInfo: Encodable {
let CPU: Int
let Memory: UInt64
let Disk: Int
let DiskFormat: String
let Size: String
let Display: String
let Running: Bool
@@ -27,7 +26,7 @@ struct Get: AsyncParsableCommand {
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
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))
}
}
+12 -27
View File
@@ -2,11 +2,12 @@ import ArgumentParser
import Foundation
import Network
import SystemConfiguration
import Sentry
enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
case dhcp, arp, agent
case dhcp, arp
private(set) static var allValueStrings: [String] = Self.allCases.map { "\($0)"}
private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"}
}
struct IP: AsyncParsableCommand {
@@ -18,11 +19,13 @@ struct IP: AsyncParsableCommand {
@Option(help: "Number of seconds to wait for a potential VM booting")
var wait: UInt16 = 0
@Option(help: ArgumentHelp("Strategy for resolving IP address",
@Option(help: ArgumentHelp("Strategy for resolving IP address: dhcp or arp",
discussion: """
By default, Tart is using a "dhcp" resolver which parses the DHCP lease file on host and tries to find an entry containing the VM's MAC address. This method is fast and the most reliable, but only works for VMs are not using the bridged networking.\n
Alternatively, Tart has an "arp" resolver which calls an external "arp" executable and parses it's output. This works for VMs using bridged networking and returns their IP, but when they generate enough network activity to populate the host's ARP table. Note that "arp" strategy won't work for VMs using the Softnet networking.\n
A third strategy, "agent" works in all cases reliably, but requires Guest agent for Tart VMs (https://github.com/cirruslabs/tart-guest-agent) to be installed inside of a VM.
By default, Tart is looking up and parsing DHCP lease file to determine the IP of the VM.\n
This method is fast and the most reliable but only returns local IP adresses.\n
Alternatively, Tart can call external `arp` executable and parse it's output.\n
In case of enabled Bridged Networking this method will return VM's IP address on the network interface used for Bridged Networking.\n
Note that `arp` strategy won't work for VMs using `--net-softnet`.
"""))
var resolver: IPResolutionStrategy = .dhcp
@@ -31,16 +34,14 @@ struct IP: AsyncParsableCommand {
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait, controlSocketURL: vmDir.controlSocketURL) else {
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait) else {
var message = "no IP address found"
if try !vmDir.running() {
message += ", is your VM running?"
}
if (resolver == .agent) {
message += " (also make sure that Guest agent for Tart is running inside of a VM)"
} else if (vmConfig.os == .linux && resolver == .arp) {
if (vmConfig.os == .linux && resolver == .arp) {
message += " (not all Linux distributions are compatible with the ARP resolver)"
}
@@ -50,7 +51,7 @@ struct IP: AsyncParsableCommand {
print(ip)
}
static public func resolveIP(_ vmMACAddress: MACAddress, resolutionStrategy: IPResolutionStrategy = .dhcp, secondsToWait: UInt16 = 0, controlSocketURL: URL? = nil) async throws -> IPv4Address? {
static public func resolveIP(_ vmMACAddress: MACAddress, resolutionStrategy: IPResolutionStrategy = .dhcp, secondsToWait: UInt16 = 0) async throws -> IPv4Address? {
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
repeat {
@@ -63,22 +64,6 @@ struct IP: AsyncParsableCommand {
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
return ip
}
case .agent:
guard let controlSocketURL = controlSocketURL else {
throw RuntimeError.Generic("Cannot perform IP resolution via Tart Guest Agent when control socket URL is not set")
}
// Change the current working directory to a VM's base directory
// to work around Unix domain socket 104 byte limitation [1]
//
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
if let baseURL = controlSocketURL.baseURL {
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
}
if let ip = try await AgentResolver.ResolveIP(controlSocketURL.relativePath) {
return ip
}
}
// wait a second
+3 -3
View File
@@ -4,10 +4,10 @@ import Foundation
struct Import: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Import VM from a compressed .tvm file")
@Argument(help: "Path to a file created with \"tart export\".", completion: .file())
@Argument(help: "Path to a file created with \"tart export\".")
var path: String
@Argument(help: "Destination VM name.", completion: .custom(completeLocalMachines))
@Argument(help: "Destination VM name.")
var name: String
func validate() throws {
@@ -17,7 +17,7 @@ struct Import: AsyncParsableCommand {
}
func run() async throws {
let localStorage = try VMStorageLocal()
let localStorage = VMStorageLocal()
// Create a temporary VM directory to which we will load the export file
let tmpVMDir = try VMDirectory.temporary()
+1 -1
View File
@@ -18,7 +18,7 @@ struct List: AsyncParsableCommand {
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
var source: String?
@Option(help: "Output format: text or json", completion: .list(["text", "json"]))
@Option(help: "Output format: text or json")
var format: Format = .text
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
-2
View File
@@ -64,8 +64,6 @@ struct Login: AsyncParsableCommand {
}
fileprivate class DictionaryCredentialsProvider: CredentialsProvider {
let userFriendlyName = "static dictionary credentials provider"
var credentials: Dictionary<String, (String, String)>
init(_ credentials: Dictionary<String, (String, String)>) {
+23 -24
View File
@@ -1,13 +1,13 @@
import ArgumentParser
import Dispatch
import OpenTelemetryApi
import Sentry
import SwiftUI
import SwiftDate
struct Prune: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs")
@Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs."), completion: .list(["caches", "vms"]))
@Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs."))
var entries: String = "caches"
@Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago",
@@ -53,9 +53,9 @@ struct Prune: AsyncParsableCommand {
switch entries {
case "caches":
prunableStorages = [try VMStorageOCI(), try IPSWCache()]
prunableStorages = [VMStorageOCI(), try IPSWCache()]
case "vms":
prunableStorages = [try VMStorageLocal()]
prunableStorages = [VMStorageLocal()]
default:
throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"")
}
@@ -109,10 +109,9 @@ struct Prune: AsyncParsableCommand {
return
}
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "prune.required-bytes",
value: .int(Int(requiredBytes))
)
SentrySDK.configureScope { scope in
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
}
// Figure out how much disk space is available
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [
@@ -124,14 +123,18 @@ struct Prune: AsyncParsableCommand {
UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
)
OpenTelemetry.instance.contextProvider.activeSpan?.setAttributes([
"prune.volume-available-capacity-bytes": .int(Int(attrs.volumeAvailableCapacity!)),
"prune.volume-available-capacity-for-important-usage-bytes": .int(Int(attrs.volumeAvailableCapacityForImportantUsage!)),
"prune.volume-available-capacity-calculated": .int(Int(volumeAvailableCapacityCalculated)),
])
SentrySDK.configureScope { scope in
scope.setContext(value: [
"volumeAvailableCapacity": attrs.volumeAvailableCapacity!,
"volumeAvailableCapacityForImportantUsage": attrs.volumeAvailableCapacityForImportantUsage!,
"volumeAvailableCapacityCalculated": volumeAvailableCapacityCalculated
], key: "Prune")
}
if volumeAvailableCapacityCalculated <= 0 {
OpenTelemetry.instance.contextProvider.activeSpan?.addEvent(name: "Zero volume capacity reported")
SentrySDK.capture(message: "Zero volume capacity reported") { scope in
scope.setLevel(.warning)
}
return
}
@@ -146,10 +149,10 @@ struct Prune: AsyncParsableCommand {
}
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
let span = OTel.shared.tracer.spanBuilder(spanName: "prune").startSpan()
defer { span.end() }
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
defer { transaction.finish() }
let prunableStorages: [PrunableStorage] = [try VMStorageOCI(), try IPSWCache()]
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() }
@@ -174,17 +177,13 @@ struct Prune: AsyncParsableCommand {
continue
}
let allocatedSizeBytes = try prunable.allocatedSizeBytes()
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path)
OpenTelemetry.instance.contextProvider.activeSpan?
.addEvent(name: "Pruned \(allocatedSizeBytes) bytes for \(prunable.url.path)")
cacheReclaimedBytes += allocatedSizeBytes
cacheReclaimedBytes += try prunable.allocatedSizeBytes()
try prunable.delete()
}
OpenTelemetry.instance.contextProvider.activeSpan?
.addEvent(name: "Reclaimed \(cacheReclaimedBytes) bytes")
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
}
}
+12 -3
View File
@@ -26,6 +26,15 @@ struct Pull: AsyncParsableCommand {
@Flag(help: .hidden)
var deduplicate: Bool = false
@Option(help: .hidden)
var proxy: String?
@Option(help: .hidden)
var caCert: String?
@Option(help: .hidden)
var maxRetries: UInt = 5
func validate() throws {
if concurrency < 1 {
throw ValidationError("network concurrency cannot be less than 1")
@@ -35,17 +44,17 @@ struct Pull: AsyncParsableCommand {
func run() async throws {
// Be more liberal when accepting local image as argument,
// see https://github.com/cirruslabs/tart/issues/36
if try VMStorageLocal().exists(remoteName) {
if VMStorageLocal().exists(remoteName) {
print("\"\(remoteName)\" is a local image, nothing to pull here!")
return
}
let remoteName = try RemoteName(remoteName)
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure, proxy: proxy, caCert: caCert)
defaultLogger.appendNewLine("pulling \(remoteName)...")
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate, maxRetries: maxRetries)
}
}
+2 -30
View File
@@ -26,11 +26,6 @@ struct Push: AsyncParsableCommand {
"""))
var chunkSize: Int = 0
@Option(name: [.customLong("label")], help: ArgumentHelp("additional metadata to attach to the OCI image configuration in key=value format",
discussion: "Can be specified multiple times to attach multiple labels."))
var labels: [String] = []
@Option(help: .hidden)
var diskFormat: String = "v2"
@@ -39,7 +34,7 @@ struct Push: AsyncParsableCommand {
var populateCache: Bool = false
func run() async throws {
let ociStorage = try VMStorageOCI()
let ociStorage = VMStorageOCI()
let localVMDir = try VMStorageHelper.open(localName)
let lock = try localVMDir.lock()
if try !lock.trylock() {
@@ -86,8 +81,7 @@ struct Push: AsyncParsableCommand {
references: references,
chunkSizeMb: chunkSize,
diskFormat: diskFormat,
concurrency: concurrency,
labels: parseLabels()
concurrency: concurrency
)
// Populate the local cache (if requested)
if populateCache {
@@ -121,28 +115,6 @@ struct Push: AsyncParsableCommand {
return RemoteName(host: registry.host!, namespace: registry.namespace,
reference: Reference(digest: digest))
}
// Helper method to convert labels array to dictionary
func parseLabels() -> [String: String] {
var result = [String: String]()
for label in labels {
let parts = label.trimmingCharacters(in: .whitespaces).split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
let key = parts.count > 0 ? String(parts[0]) : ""
let value = parts.count > 1 ? String(parts[1]) : ""
// It sometimes makes sense to provide an empty value,
// but not an empty key
if key.isEmpty {
continue
}
result[key] = value
}
return result
}
}
extension Collection where Element == RemoteName {
+1 -1
View File
@@ -17,7 +17,7 @@ struct Rename: AsyncParsableCommand {
}
func run() async throws {
let localStorage = try VMStorageLocal()
let localStorage = VMStorageLocal()
if !localStorage.exists(name) {
throw ValidationError("failed to rename a non-existent local VM: \(name)")
+22 -90
View File
@@ -4,7 +4,7 @@ import Darwin
import Dispatch
import SwiftUI
import Virtualization
import OpenTelemetryApi
import Sentry
import System
var vm: VM?
@@ -81,7 +81,7 @@ struct Run: AsyncParsableCommand {
@Option(help: ArgumentHelp(
"Attach an externally created serial console",
discussion: "Alternative to `--serial` flag for programmatic integrations."
), completion: .file())
))
var serialPath: String?
@Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .private))
@@ -92,7 +92,7 @@ struct Run: AsyncParsableCommand {
@Flag(help: ArgumentHelp(
"Disable clipboard sharing between host and guest.",
discussion: "Clipboard sharing requires spice-vdagent package on Linux and https://github.com/cirruslabs/tart-guest-agent on macOS."))
discussion: "Only works with Linux-based guest operating systems."))
var noClipboard: Bool = false
#if arch(arm64)
@@ -117,7 +117,7 @@ struct Run: AsyncParsableCommand {
var vncExperimental: Bool = false
@Option(help: ArgumentHelp("""
Additional disk attachments with an optional read-only and synchronization options in the form of path[:options] (e.g. --disk="disk.bin", --disk="ubuntu.iso:ro", --disk="/dev/disk0", --disk "ghcr.io/cirruslabs/xcode:16.0:ro" or --disk="nbd://localhost:10809/myDisk:sync=none")
Additional disk attachments with an optional read-only and synchronization options (e.g. --disk="disk.bin", --disk="ubuntu.iso:ro", --disk="/dev/disk0", --disk "ghcr.io/cirruslabs/xcode:16.0:ro" or --disk="nbd://localhost:10809/myDisk:sync=none")
""", discussion: """
The disk attachment can be a:
@@ -134,13 +134,12 @@ struct Run: AsyncParsableCommand {
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, the easiest way is to modify their permissions to be accessible to the current user:
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.
sudo chown $USER /dev/diskX
tart run sequoia --disk=/dev/diskX
To work around this pass TART_HOME explicitly:
Warning: after running the chown command above, all software running under the current user will be able to access /dev/diskX. If that violates your threat model, we recommend avoiding mounting block devices altogether.
""", valueName: "path[:options]"), completion: .file())
sudo TART_HOME="$HOME/.tart" tart run sonoma --disk=/dev/disk0
""", valueName: "path[:options]"))
var disk: [String] = []
#if arch(arm64)
@@ -159,7 +158,7 @@ struct Run: AsyncParsableCommand {
#endif
var rosettaTag: String?
@Option(help: ArgumentHelp("Additional directory shares with an optional read-only and mount tag options in the form of [name:]path[:options] (e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")", discussion: """
@Option(help: ArgumentHelp("Additional directory shares with an optional read-only and mount tag options (e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")", discussion: """
Requires host to be macOS 13.0 (Ventura) or newer. macOS guests must be running macOS 13.0 (Ventura) or newer too.
Options are comma-separated and are as follows:
@@ -171,7 +170,7 @@ struct Run: AsyncParsableCommand {
Mount tag can be overridden by appending tag property to the directory share (e.g. --dir=\"~/src/build:tag=build\" or --dir=\"~/src/build:ro,tag=build\"). Then it can be mounted via "mount_virtiofs build ~/build" inside guest macOS and "mount -t virtiofs build ~/build" inside guest Linux.
In case of passing multiple directories per mount tag it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\". These names will be used as directory names under the mounting point inside guests. For the example above it will be "/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively.
""", valueName: "[name:]path[:options]"), completion: .directory)
""", valueName: "[name:]path[:options]"))
var dir: [String] = []
@Flag(help: ArgumentHelp("Enable nested virtualization if possible"))
@@ -202,28 +201,15 @@ struct Run: AsyncParsableCommand {
"""))
var netSoftnet: Bool = false
@Option(help: ArgumentHelp("Comma-separated list of CIDRs to allow the traffic to when using Softnet isolation (e.g. --net-softnet-allow=192.168.0.0/24)", discussion: """
@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)", discussion: """
This option allows you bypass the private IPv4 address space restrictions imposed by --net-softnet.
For example, you can allow the VM to communicate with the local network with e.g. --net-softnet-allow=10.0.0.0/16 or with --net-softnet-allow=0.0.0.0/0 to completely disable the destination based restrictions, including VMs bridge isolation.
When used with --net-softnet-block, the longest prefix match always wins. In case the same prefix is both allowed and blocked, blocking takes precedence.
For example, you can allow the VM to communicate with the local network with e.g. --net-softnet-allow=10.0.0.0/16 or to completely disable the destination based restrictions with --net-softnet-allow=0.0.0.0/0.
Implies --net-softnet.
""", valueName: "comma-separated CIDRs"))
var netSoftnetAllow: String?
@Option(help: ArgumentHelp("Comma-separated list of CIDRs to block the traffic to when using Softnet isolation (e.g. --net-softnet-block=66.66.0.0/16)", discussion: """
This option allows you to tighten the IPv4 address space restrictions imposed by --net-softnet even further.
For example --net-softnet-block=0.0.0.0/0 may be used to establish a default deny policy that is further relaxed with --net-softnet-allow.
When used with --net-softnet-allow, the longest prefix match always wins. In case the same prefix is both allowed and blocked, blocking takes precedence.
Implies --net-softnet.
""", valueName: "comma-separated CIDRs"))
var netSoftnetBlock: String?
@Option(help: ArgumentHelp("Comma-separated list of TCP ports to expose (e.g. --net-softnet-expose 2222:22,8080:80)", discussion: """
Options are comma-separated and are as follows:
@@ -274,24 +260,13 @@ struct Run: AsyncParsableCommand {
#endif
var captureSystemKeys: Bool = false
#if arch(arm64)
@Flag(help: ArgumentHelp("Don't add trackpad as a pointing device on macOS VMs"))
#endif
var noTrackpad: Bool = false
@Flag(help: ArgumentHelp("Disable the pointer"))
var noPointer: Bool = false
@Flag(help: ArgumentHelp("Disable the keyboard"))
var noKeyboard: Bool = false
mutating func validate() throws {
if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
}
// Automatically enable --net-softnet when any of its related options are specified
if netSoftnetAllow != nil || netSoftnetBlock != nil || netSoftnetExpose != nil {
if netSoftnetAllow != nil || netSoftnetExpose != nil {
netSoftnet = true
}
@@ -315,13 +290,13 @@ struct Run: AsyncParsableCommand {
if nested {
if #unavailable(macOS 15) {
throw ValidationError("Nested virtualization is supported on hosts starting with macOS 15 (Sequoia), and later.")
throw ValidationError("Nested virtualization is supported on hosts starting with macOS 15 (Sequia), and later.")
} else if !VZGenericPlatformConfiguration.isNestedVirtualizationSupported {
throw ValidationError("Nested virtualization is available for Mac with the M3 chip, and later.")
}
}
let localStorage = try VMStorageLocal()
let localStorage = VMStorageLocal()
let vmDir = try localStorage.open(name)
if try vmDir.state() == .Suspended {
suspendable = true
@@ -332,23 +307,8 @@ struct Run: AsyncParsableCommand {
if !(config.platform is PlatformSuspendable) {
throw ValidationError("You can only suspend macOS VMs")
}
if noTrackpad {
throw ValidationError("--no-trackpad cannot be used with --suspendable")
}
if noKeyboard {
throw ValidationError("--no-keyboard cannot be used with --suspendable")
}
if noPointer {
throw ValidationError("--no-pointer cannot be used with --suspendable")
}
}
if noTrackpad {
let config = try VMConfig.init(fromURL: vmDir.configURL)
if config.os != .darwin {
throw ValidationError("--no-trackpad can only be used with macOS VMs")
if dir.count > 0 {
throw ValidationError("Suspending VMs with shared directories is not supported")
}
}
@@ -361,15 +321,9 @@ struct Run: AsyncParsableCommand {
@MainActor
func run() async throws {
let localStorage = try VMStorageLocal()
let localStorage = VMStorageLocal()
let vmDir = try localStorage.open(name)
// Validate disk format support
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
if !vmConfig.diskFormat.isSupported {
throw ValidationError("Disk format '\(vmConfig.diskFormat.rawValue)' is not supported on this system.")
}
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
try storageLock.lock()
// check if there is a running VM with the same MAC address
@@ -419,10 +373,7 @@ struct Run: AsyncParsableCommand {
audio: !noAudio,
clipboard: !noClipboard,
sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw),
caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw),
noTrackpad: noTrackpad,
noPointer: noPointer,
noKeyboard: noKeyboard
caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw)
)
let vncImpl: VNC? = try {
@@ -513,27 +464,20 @@ struct Run: AsyncParsableCommand {
}
}
if #available(macOS 14, *) {
Task {
try await ControlSocket(vmDir.controlSocketURL).run()
}
}
try await vm!.run()
if let vncImpl = vncImpl {
try vncImpl.stop()
}
OTel.shared.flush()
Foundation.exit(0)
} catch {
// Capture the error into OpenTelemetry
OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error)
// Capture the error into Sentry
SentrySDK.capture(error: error)
SentrySDK.flush(timeout: 2.seconds.timeInterval)
fputs("\(error)\n", stderr)
OTel.shared.flush()
Foundation.exit(1)
}
}
@@ -567,14 +511,12 @@ struct Run: AsyncParsableCommand {
} else {
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
OTel.shared.flush()
Foundation.exit(1)
}
#endif
} catch (let e) {
print(RuntimeError.SuspendFailed(e.localizedDescription))
OTel.shared.flush()
Foundation.exit(1)
}
}
@@ -626,10 +568,6 @@ struct Run: AsyncParsableCommand {
softnetExtraArguments += ["--allow", netSoftnetAllow]
}
if let netSoftnetBlock = netSoftnetBlock {
softnetExtraArguments += ["--block", netSoftnetBlock]
}
if let netSoftnetExpose = netSoftnetExpose {
softnetExtraArguments += ["--expose", netSoftnetExpose]
}
@@ -819,12 +757,6 @@ struct MainApp: App {
}
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
func applicationDidFinishLaunching(_ : Notification) {
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
+11 -16
View File
@@ -14,7 +14,7 @@ struct Set: AsyncParsableCommand {
@Option(help: "VM memory size in megabytes")
var memory: UInt64?
@Option(help: "VM display resolution in a format of WIDTHxHEIGHT[pt|px]. For example, 1200x800, 1200x800pt or 1920x1080px. Units are treated as hints and default to \"pt\" (points) for macOS VMs and \"px\" (pixels) for Linux VMs when not specified.")
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
var display: VMDisplayConfig?
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window"))
@@ -33,7 +33,15 @@ struct Set: AsyncParsableCommand {
@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: """
See https://tart.run/faq/#disk-resizing for more details.
Disk resizing works on most cloud-ready Linux distributions out-of-the box (e.g. Ubuntu Cloud Images
have the \"cloud-initramfs-growroot\" package installed that runs on boot) and on the rest of the
distributions by running the \"growpart\" or \"resize2fs\" commands.
For macOS, however, things are a bit more complicated: you need to remove the recovery partition
first and then run various \"diskutil\" commands, see Tart's packer plugin source code for more
details[1].
[1]: https://github.com/cirruslabs/packer-plugin-tart/blob/main/builder/tart/step_disk_resize.go
"""))
var diskSize: UInt16?
@@ -56,7 +64,6 @@ struct Set: AsyncParsableCommand {
if (display.height > 0) {
vmConfig.display.height = display.height
}
vmConfig.display.unit = display.unit
}
vmConfig.displayRefit = displayRefit
@@ -89,24 +96,12 @@ struct Set: AsyncParsableCommand {
extension VMDisplayConfig: ExpressibleByArgument {
public init(argument: String) {
var argument = argument
var unit: Unit? = nil
if argument.hasSuffix(Unit.pixel.rawValue) {
argument = String(argument.dropLast(Unit.pixel.rawValue.count))
unit = Unit.pixel
} else if argument.hasSuffix(Unit.point.rawValue) {
argument = String(argument.dropLast(Unit.point.rawValue.count))
unit = Unit.point
}
let parts = argument.components(separatedBy: "x").map {
Int($0) ?? 0
}
self = VMDisplayConfig(
width: parts[safe: 0] ?? 0,
height: parts[safe: 1] ?? 0,
unit: unit,
height: parts[safe: 1] ?? 0
)
}
}
+1 -22
View File
@@ -9,8 +9,7 @@ struct Config {
var tartHomeDir: URL
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
tartHomeDir = URL(fileURLWithPath: customTartHome, isDirectory: true)
try Self.validateTartHome(url: tartHomeDir)
tartHomeDir = URL(fileURLWithPath: customTartHome)
} else {
tartHomeDir = FileManager.default
.homeDirectoryForCurrentUser
@@ -50,24 +49,4 @@ struct Config {
static func jsonDecoder() -> JSONDecoder {
JSONDecoder()
}
private static func validateTartHome(url: URL) throws {
let urlComponents = url.pathComponents
let descendingURLs = urlComponents.indices.map { i in
URL(fileURLWithPath: urlComponents[0...i].joined(separator: "/"))
}
for descendingURL in descendingURLs {
if FileManager.default.fileExists(atPath: descendingURL.path) {
continue
}
do {
try FileManager.default.createDirectory(at: descendingURL, withIntermediateDirectories: false)
} catch {
throw RuntimeError.Generic("TART_HOME is invalid: \(descendingURL.path) does not exist, yet we can't create it: \(error.localizedDescription)")
}
}
}
}
-97
View File
@@ -1,97 +0,0 @@
import Foundation
import Network
import os.log
import NIO
import NIOPosix
@available(macOS 14, *)
class ControlSocket {
let controlSocketURL: URL
let vmPort: UInt32
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let logger: os.Logger = os.Logger(subsystem: "org.cirruslabs.tart.control-socket", category: "network")
init(_ controlSocketURL: URL, vmPort: UInt32 = 8080) {
self.controlSocketURL = controlSocketURL
self.vmPort = vmPort
}
func run() async throws {
// Remove control socket file from previous "tart run" invocations,
// if any, otherwise we may get the "address already in use" error
try? FileManager.default.removeItem(atPath: controlSocketURL.path())
// Change the current working directory to a VM's base directory
// to work around Unix domain socket 104 byte limitation [1]
//
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
if let baseURL = controlSocketURL.baseURL {
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
}
let serverChannel = try await ServerBootstrap(group: eventLoopGroup)
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
childChannel.eventLoop.makeCompletedFuture {
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
wrappingChannelSynchronously: childChannel
)
}
}
try await withThrowingDiscardingTaskGroup { group in
try await serverChannel.executeThenClose { serverInbound in
for try await clientChannel in serverInbound {
group.addTask {
try await self.handleClient(clientChannel)
}
}
}
}
}
func handleClient(_ clientChannel: NIOAsyncChannel<ByteBuffer, ByteBuffer>) async throws {
self.logger.info("received new control socket connection from a client")
try await clientChannel.executeThenClose { clientInbound, clientOutbound in
self.logger.info("dialing to VM on port \(self.vmPort)...")
do {
guard let vmConnection = try await vm?.connect(toPort: self.vmPort) else {
throw RuntimeError.VMSocketFailed(self.vmPort, "VM is not running")
}
self.logger.info("running control socket proxy")
let vmChannel = try await ClientBootstrap(group: eventLoopGroup).withConnectedSocket(vmConnection.fileDescriptor) { childChannel in
childChannel.eventLoop.makeCompletedFuture {
try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
wrappingChannelSynchronously: childChannel
)
}
}
try await vmChannel.executeThenClose { (vmInbound, vmOutbound) in
try await withThrowingDiscardingTaskGroup { group in
// Proxy data from a client (e.g. "tart exec") to a VM
group.addTask {
for try await message in clientInbound {
try await vmOutbound.write(message)
}
}
// Proxy data from a VM to a client (e.g. "tart exec")
group.addTask {
for try await message in vmInbound {
try await clientOutbound.write(message)
}
}
}
}
self.logger.info("control socket client disconnected")
} catch (let error) {
self.logger.error("control socket connection failed: \(error)")
}
}
}
}
@@ -5,7 +5,6 @@ enum CredentialsProviderError: Error {
}
protocol CredentialsProvider {
var userFriendlyName: String { get }
func retrieve(host: String) throws -> (String, String)?
func store(host: String, user: String, password: String) throws
}
@@ -1,8 +1,6 @@
import Foundation
class DockerConfigCredentialsProvider: CredentialsProvider {
let userFriendlyName = "Docker configuration credentials provider"
func retrieve(host: String) throws -> (String, String)? {
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
@@ -1,8 +1,6 @@
import Foundation
class EnvironmentCredentialsProvider: CredentialsProvider {
let userFriendlyName = "environment variable credentials provider"
func retrieve(host: String) throws -> (String, String)? {
if let tartRegistryHostname = ProcessInfo.processInfo.environment["TART_REGISTRY_HOSTNAME"],
tartRegistryHostname != host {
@@ -1,8 +1,6 @@
import Foundation
class KeychainCredentialsProvider: CredentialsProvider {
let userFriendlyName = "Keychain credentials provider"
func retrieve(host: String) throws -> (String, String)? {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
@@ -6,8 +6,6 @@ enum StdinCredentialsError: Error {
}
class StdinCredentials {
let userFriendlyName = "standard input credentials provider"
static func retrieve() throws -> (String, String) {
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
-43
View File
@@ -1,43 +0,0 @@
import Foundation
import ArgumentParser
enum DiskImageFormat: String, CaseIterable, Codable {
case raw = "raw"
case asif = "asif"
var displayName: String {
switch self {
case .raw:
return "RAW"
case .asif:
return "ASIF (Apple Sparse Image Format)"
}
}
/// Check if the format is supported on the current system
var isSupported: Bool {
switch self {
case .raw:
return true
case .asif:
if #available(macOS 26, *) {
return true
} else {
return false
}
}
}
}
extension DiskImageFormat: ExpressibleByArgument {
init?(argument: String) {
self.init(rawValue: argument.lowercased())
}
static var allValueStrings: [String] {
return allCases.map { $0.rawValue }
}
}
-108
View File
@@ -1,108 +0,0 @@
import Foundation
struct ImageInfo: Codable {
let sizeInfo: SizeInfo?
let size: UInt64?
enum CodingKeys: String, CodingKey {
case sizeInfo = "Size Info"
case size = "Size"
}
func totalBytes() throws -> Int {
if let totalBytes = self.sizeInfo?.totalBytes {
return Int(totalBytes)
}
if let size = self.size {
return Int(size)
}
throw RuntimeError.Generic("Could not find size information in disk image info")
}
}
struct SizeInfo: Codable {
let totalBytes: UInt64?
enum CodingKeys: String, CodingKey {
case totalBytes = "Total Bytes"
}
}
struct Diskutil {
static func imageCreate(diskURL: URL, sizeGB: UInt16) throws {
do {
_ = try run([
"image", "create", "blank",
"--format", "ASIF",
"--size", "\(sizeGB)G",
"--volumeName", "Tart",
diskURL.path
])
} catch {
throw RuntimeError.FailedToCreateDisk("Failed to create ASIF disk image: \(error)")
}
}
static func imageInfo(_ diskURL: URL) throws -> ImageInfo {
do {
let (stdoutData, _) = try run([
"image", "info", "--plist",
diskURL.path
])
do {
return try PropertyListDecoder().decode(ImageInfo.self, from: stdoutData)
} catch {
throw RuntimeError.Generic("Failed to parse \"diskutil image info --plist\" output: \(error)")
}
}
}
private static func run(_ arguments: [String]) throws -> (Data, Data) {
guard let diskutilURL = resolveBinaryPath("diskutil") else {
throw RuntimeError.Generic("\"diskutil\" binary is not found in PATH")
}
let process = Process()
process.executableURL = diskutilURL
process.arguments = arguments
let stdoutPipe = Pipe()
process.standardOutput = stdoutPipe
let stderrPipe = Pipe()
process.standardError = stderrPipe
do {
try process.run()
} catch {
throw RuntimeError.Generic("\"\(arguments.joined(separator: " "))\" failed: \(error)")
}
process.waitUntilExit()
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
if process.terminationStatus != 0 {
let stdoutString = String(data: stdoutData, encoding: .utf8) ?? ""
let stderrString = String(data: stderrData, encoding: .utf8) ?? ""
throw RuntimeError.Generic("\"\(arguments.joined(separator: " "))\" failed with exit code \(process.terminationStatus): \(firstNonEmptyLine(stderrString, stdoutString))")
}
return (stdoutData, stderrData)
}
private static func firstNonEmptyLine(_ outputs: String...) -> String {
for output in outputs {
for line in output.split(separator: "\n", omittingEmptySubsequences: false) {
if !line.isEmpty {
return String(line)
}
}
}
return ""
}
}
+118 -20
View File
@@ -1,26 +1,61 @@
import Foundation
fileprivate var urlSession: URLSession = {
let config = URLSessionConfiguration.default
// Harbor expects a CSRF token to be present if the HTTP client
// carries a session cookie between its requests[1] and fails if
// it was not present[2].
//
// To fix that, we disable the automatic cookies carry in URLSession.
//
// [1]: https://github.com/goharbor/harbor/blob/a4c577f9ec4f18396207a5e686433a6ba203d4ef/src/server/middleware/csrf/csrf.go#L78
// [2]: https://github.com/cirruslabs/tart/issues/295
config.httpShouldSetCookies = false
return URLSession(configuration: config)
}()
class Fetcher {
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
let task = urlSession.dataTask(with: request)
let urlSession: URLSession
let caCert: SecCertificate?
let delegate = Delegate()
init(proxy: String? = nil, caCert: String? = nil) throws {
// Configure URLSession
let config = URLSessionConfiguration.default
// Harbor expects a CSRF token to be present if the HTTP client
// carries a session cookie between its requests[1] and fails if
// it was not present[2].
//
// To fix that, we disable the automatic cookies carry in URLSession.
//
// [1]: https://github.com/goharbor/harbor/blob/a4c577f9ec4f18396207a5e686433a6ba203d4ef/src/server/middleware/csrf/csrf.go#L78
// [2]: https://github.com/cirruslabs/tart/issues/295
config.httpShouldSetCookies = false
if let proxy {
let (host, port) = try Self.parseProxy(proxy)
config.connectionProxyDictionary = [
kCFNetworkProxiesHTTPEnable: true,
kCFNetworkProxiesHTTPProxy: host,
kCFNetworkProxiesHTTPPort: port,
kCFNetworkProxiesHTTPSEnable: true,
kCFNetworkProxiesHTTPSProxy: host,
kCFNetworkProxiesHTTPSPort: port,
]
}
self.urlSession = URLSession(configuration: config)
// Load CA certificate, if any
if let caCert {
let caCertString = try String(contentsOf: URL(filePath: caCert), encoding:. utf8)
let caCertBase64Lines = caCertString.components(separatedBy: .newlines).filter { line in
!line.hasPrefix("-----BEGIN") && !line.hasPrefix("-----END")
}
guard let caCertData = Data(base64Encoded: caCertBase64Lines.joined()) else {
throw RuntimeError.FailedToLoadCACertificate("failed to parse Base64-encoded PEM data")
}
self.caCert = SecCertificateCreateWithData(nil, caCertData as CFData)!
} else {
self.caCert = nil
}
}
func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
let task = self.urlSession.dataTask(with: request)
let delegate = Delegate(caCert: self.caCert)
task.delegate = delegate
let stream = AsyncThrowingStream<Data, Error> { continuation in
@@ -34,15 +69,78 @@ class Fetcher {
return (stream, response as! HTTPURLResponse)
}
private static func parseProxy(_ proxy: String) throws -> (String, Int) {
// Assume that the scheme is specified
var url = URL(string: proxy)
// Fall back to HTTP scheme when not specified
if url?.scheme == nil {
url = URL(string: "http://\(proxy)")
}
guard let url else {
throw RuntimeError.InvalidProxyString
}
guard let host = url.host() else {
throw RuntimeError.InvalidProxyString
}
guard let port = url.port else {
throw RuntimeError.InvalidProxyString
}
return (host, port)
}
}
fileprivate class Delegate: NSObject, URLSessionDataDelegate {
fileprivate class Delegate: NSObject, URLSessionDelegate, URLSessionDataDelegate {
let caCert: SecCertificate?
var responseContinuation: CheckedContinuation<URLResponse, Error>?
var streamContinuation: AsyncThrowingStream<Data, Error>.Continuation?
private var buffer: Data = Data()
private let bufferFlushSize = 16 * 1024 * 1024
init(caCert: SecCertificate?) {
self.caCert = caCert
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
if let caCert {
// Ensure that we're performing server trust authentication
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
// Set the provided CA certificate as the only anchor
if SecTrustSetAnchorCertificates(serverTrust, [caCert] as CFArray) != errSecSuccess {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Evaluate the trust
if SecTrustEvaluateWithError(serverTrust, nil) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.rejectProtectionSpace, nil)
}
return
}
completionHandler(.performDefaultHandling, nil)
}
func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
+2 -12
View File
@@ -4,28 +4,18 @@ public class ProgressObserver: NSObject {
@objc var progressToObserve: Progress
var observation: NSKeyValueObservation?
var lastTimeUpdated = Date.now
private var lastRenderedLine: String?
public init(_ progress: Progress) {
progressToObserve = progress
}
func log(_ renderer: Logger) {
let initialLine = ProgressObserver.lineToRender(progressToObserve)
renderer.appendNewLine(initialLine)
lastRenderedLine = initialLine
renderer.appendNewLine(ProgressObserver.lineToRender(progressToObserve))
observation = observe(\.progressToObserve.fractionCompleted) { progress, _ in
let currentTime = Date.now
if self.progressToObserve.isFinished || currentTime.timeIntervalSince(self.lastTimeUpdated) >= 1.0 {
self.lastTimeUpdated = currentTime
let line = ProgressObserver.lineToRender(self.progressToObserve)
// Skip identical renders so non-interactive logs only see new percent values.
if line == self.lastRenderedLine {
return
}
self.lastRenderedLine = line
renderer.updateLastLine(line)
renderer.updateLastLine(ProgressObserver.lineToRender(self.progressToObserve))
}
}
}
@@ -1,42 +0,0 @@
import Foundation
import Network
import NIOPosix
import GRPC
import Cirruslabs_TartGuestAgent_Apple_Swift
import Cirruslabs_TartGuestAgent_Grpc_Swift
class AgentResolver {
static func ResolveIP(_ controlSocketPath: String) async throws -> IPv4Address? {
do {
return try await resolveIP(controlSocketPath)
} catch let error as GRPCConnectionPoolError {
return nil
}
}
private static func resolveIP(_ controlSocketPath: String) async throws -> IPv4Address? {
// Create a gRPC channel connected to the VM's control socket
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer {
try! group.syncShutdownGracefully()
}
let channel = try GRPCChannelPool.with(
target: .unixDomainSocket(controlSocketPath),
transportSecurity: .plaintext,
eventLoopGroup: group,
)
defer {
try! channel.close().wait()
}
// Invoke ResolveIP() gRPC method
let callOptions = CallOptions(timeLimit: .timeout(.seconds(1)))
let agentAsyncClient = AgentAsyncClient(channel: channel)
let resolveIPCall = agentAsyncClient.makeResolveIpCall(ResolveIPRequest(), callOptions: callOptions)
let response = try await resolveIPCall.response
return IPv4Address(response.ip)
}
}
+1 -1
View File
@@ -2,5 +2,5 @@ import Foundation
protocol Disk {
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer]
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?, deduplicate: Bool, maxRetries: UInt) async throws
}
+1 -1
View File
@@ -45,7 +45,7 @@ class DiskV1: Disk {
return pushedLayers
}
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws {
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false, maxRetries: UInt = 5) async throws {
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
throw OCIError.FailedToCreateVmFile
}
+2 -2
View File
@@ -84,7 +84,7 @@ class DiskV2: Disk {
}
}
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws {
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false, maxRetries: UInt = 5) async throws {
// Support resumable pulls
let pullResumed = FileManager.default.fileExists(atPath: diskURL.path)
@@ -210,7 +210,7 @@ class DiskV2: Disk {
var rangeStart: Int64 = 0
try await retry(maxAttempts: 5) {
try await retry(maxAttempts: Int(maxRetries)) {
try await registry.pullBlob(diskLayer.digest, rangeStart: rangeStart) { data in
try filter.write(data)
-8
View File
@@ -14,9 +14,6 @@ let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
// Manifest labels
let diskFormatLabel = "org.cirruslabs.tart.disk.format"
// Layer annotations
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
@@ -69,11 +66,6 @@ struct OCIManifest: Codable, Equatable {
struct OCIConfig: Codable {
var architecture: Architecture = .arm64
var os: OS = .darwin
var config: ConfigContainer?
struct ConfigContainer: Codable {
var Labels: [String: String]?
}
func toJSON() throws -> Data {
try Config.jsonEncoder().encode(self)
+11 -9
View File
@@ -115,6 +115,7 @@ class Registry {
let namespace: String
let credentialsProviders: [CredentialsProvider]
let authenticationKeeper = AuthenticationKeeper()
let fetcher: Fetcher
var host: String? {
guard let host = baseURL.host else { return nil }
@@ -128,17 +129,22 @@ class Registry {
init(baseURL: URL,
namespace: String,
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()],
proxy: String? = nil,
caCert: String? = nil
) throws {
self.baseURL = baseURL
self.namespace = namespace
self.credentialsProviders = credentialsProviders
self.fetcher = try Fetcher(proxy: proxy, caCert: caCert)
}
convenience init(
host: String,
namespace: String,
insecure: Bool = false,
proxy: String? = nil,
caCert: String? = nil,
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws {
let proto = insecure ? "http" : "https"
@@ -154,7 +160,7 @@ class Registry {
throw RuntimeError.ImproperlyFormattedHost(host, hint)
}
try self.init(baseURL: baseURL, namespace: namespace, credentialsProviders: credentialsProviders)
try self.init(baseURL: baseURL, namespace: namespace, credentialsProviders: credentialsProviders, proxy: proxy, caCert: caCert)
}
func ping() async throws {
@@ -429,12 +435,8 @@ class Registry {
}
for provider in credentialsProviders {
do {
if let (user, password) = try provider.retrieve(host: host) {
return (user, password)
}
} catch (let e) {
print("Failed to retrieve credentials using \(provider.userFriendlyName), authentication may fail: \(e)")
if let (user, password) = try provider.retrieve(host: host) {
return (user, password)
}
}
return nil
@@ -452,6 +454,6 @@ class Registry {
request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))",
forHTTPHeaderField: "User-Agent")
return try await Fetcher.fetch(request, viaFile: viaFile)
return try await self.fetcher.fetch(request, viaFile: viaFile)
}
}
-64
View File
@@ -1,64 +0,0 @@
import Foundation
import OpenTelemetryApi
import OpenTelemetrySdk
import OpenTelemetryProtocolExporterHttp
import ResourceExtension
class OTel {
let tracerProvider: TracerProviderSdk?
let tracer: Tracer
static let shared = OTel()
init() {
tracerProvider = Self.initializeTracing()
tracer = OpenTelemetry.instance.tracerProvider.get(instrumentationName: "tart", instrumentationVersion: CI.version)
}
static func initializeTracing() -> TracerProviderSdk? {
guard let _ = ProcessInfo.processInfo.environment["TRACEPARENT"] else {
return nil
}
var resource = DefaultResources().get()
resource.merge(other: Resource(attributes: [
SemanticConventions.Service.name.rawValue: .string("tart"),
SemanticConventions.Service.version.rawValue: .string(CI.version)
]))
let spanExporter: SpanExporter
if let endpointRaw = ProcessInfo.processInfo.environment["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"],
let endpoint = URL(string: endpointRaw) {
spanExporter = OtlpHttpTraceExporter(endpoint: endpoint)
} else {
spanExporter = OtlpHttpTraceExporter()
}
let spanProcessor = SimpleSpanProcessor(spanExporter: spanExporter)
let tracerProvider = TracerProviderBuilder()
.add(spanProcessor: spanProcessor)
.with(resource: resource)
.build()
OpenTelemetry.registerTracerProvider(tracerProvider: tracerProvider)
return tracerProvider
}
func flush() {
OpenTelemetry.instance.contextProvider.activeSpan?.end()
guard let tracerProvider else {
// No tracing was initialized, so just ending a span is enough
return
}
tracerProvider.forceFlush()
// Work around OpenTelemtry not flushing traces after explicitly asking it to do so
//
// [1]: https://github.com/open-telemetry/opentelemetry-swift/issues/685
// [2]: https://github.com/open-telemetry/opentelemetry-swift/issues/555
Thread.sleep(forTimeInterval: .fromMilliseconds(100))
}
}
+1 -6
View File
@@ -82,7 +82,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZMacGraphicsDeviceConfiguration()
if (vmConfig.display.unit ?? .point) == .point, let hostMainScreen = NSScreen.main {
if let hostMainScreen = NSScreen.main {
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
result.displays = [
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
@@ -127,11 +127,6 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
[VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()]
}
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] {
// Only include the USB pointing device, not the trackpad
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
if #available(macOS 14, *) {
return [VZMacTrackpadConfiguration()]
-5
View File
@@ -42,9 +42,4 @@ struct Linux: Platform {
func pointingDevices() -> [VZPointingDeviceConfiguration] {
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] {
// Linux doesn't support trackpad, so just return the regular pointing devices
return pointingDevices()
}
}
-1
View File
@@ -7,7 +7,6 @@ protocol Platform: Codable {
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
func keyboards() -> [VZKeyboardConfiguration]
func pointingDevices() -> [VZPointingDeviceConfiguration]
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration]
}
protocol PlatformSuspendable: Platform {
+40 -37
View File
@@ -1,9 +1,7 @@
import ArgumentParser
import Darwin
import Foundation
import OpenTelemetryApi
import OpenTelemetrySdk
import OpenTelemetryProtocolExporterHttp
import Sentry
@main
struct Root: AsyncParsableCommand {
@@ -20,7 +18,6 @@ struct Root: AsyncParsableCommand {
Login.self,
Logout.self,
IP.self,
Exec.self,
Pull.self,
Push.self,
Import.self,
@@ -33,6 +30,39 @@ struct Root: AsyncParsableCommand {
])
public static func main() async throws {
// Initialize Sentry
if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
SentrySDK.start { options in
options.dsn = dsn
options.releaseName = CI.release
options.tracesSampleRate = Float(
ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
) as NSNumber?
// By default only 5XX are captured
// Let's capture everything but 401 (unauthorized)
options.enableCaptureFailedRequests = true
options.failedRequestStatusCodes = [
HttpStatusCodeRange(min: 400, max: 400),
HttpStatusCodeRange(min: 402, max: 599)
]
}
}
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
for (key, value) in tags.split(separator: ",").compactMap({ parseCirrusSentryTag($0) }) {
scope.setTag(value: value, key: key)
}
}
}
// Add commands that are only available on specific macOS versions
if #available(macOS 14, *) {
configuration.subcommands.append(Suspend.self)
@@ -52,60 +82,33 @@ struct Root: AsyncParsableCommand {
// Set line-buffered output for stdout
setlinebuf(stdout)
defer { OTel.shared.flush() }
// Parse and run command
do {
// Parse command
var command = try parseAsRoot()
// Create a root span for the command we're about to run
let span = OTel.shared.tracer.spanBuilder(spanName: type(of: command)._commandName).startSpan()
defer { span.end() }
OpenTelemetry.instance.contextProvider.setActiveSpan(span)
// Enrich root command span with command's arguments
let commandLineArguments = ProcessInfo.processInfo.arguments.map { argument in
AttributeValue.string(argument)
}
span.setAttribute(key: "Command-line arguments", value: .array(AttributeArray(values: commandLineArguments)))
// Enrich root command span with Cirrus CI-specific tags
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
for (key, value) in tags.split(separator: ",").compactMap(splitEnvironmentVariable) {
span.setAttribute(key: key, value: .string(value))
}
}
// Run garbage-collection before each command (shouldn't take too long)
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
do {
try Config().gc()
} catch {
fputs("Failed to perform garbage collection: \(error)\n", stderr)
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
}
}
// Run command
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
try command.run()
}
} catch {
// Not an error, just a custom exit code from "tart exec"
if let execCustomExitCodeError = error as? ExecCustomExitCodeError {
OTel.shared.flush()
Foundation.exit(execCustomExitCodeError.exitCode)
}
// Capture the error into OpenTelemetry
OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error)
// Capture the error into Sentry
SentrySDK.capture(error: error)
SentrySDK.flush(timeout: 2.seconds.timeInterval)
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
if let errorWithExitCode = error as? HasExitCode {
fputs("\(error)\n", stderr)
OTel.shared.flush()
Foundation.exit(errorWithExitCode.exitCode)
}
@@ -114,7 +117,7 @@ struct Root: AsyncParsableCommand {
}
}
private static func splitEnvironmentVariable(_ tag: String.SubSequence) -> (String, String)? {
private static func parseCirrusSentryTag(_ tag: String.SubSequence) -> (String, String)? {
let splits = tag.split(separator: "=", maxSplits: 1)
if splits.count != 2 {
return nil
@@ -5,7 +5,7 @@ fileprivate func normalizeName(_ name: String) -> String {
return name.replacingOccurrences(of: ":", with: "\\:")
}
func completeMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
func completeMachines(_ arguments: [String]) -> [String] {
let localVMs = (try? VMStorageLocal().list().map { name, _ in
normalizeName(name)
}) ?? []
@@ -15,12 +15,12 @@ func completeMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefi
return (localVMs + ociVMs)
}
func completeLocalMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
func completeLocalMachines(_ arguments: [String]) -> [String] {
let localVMs = (try? VMStorageLocal().list()) ?? []
return localVMs.map { name, _ in normalizeName(name) }
}
func completeRunningMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
func completeRunningMachines(_ arguments: [String]) -> [String] {
let localVMs = (try? VMStorageLocal().list()) ?? []
return localVMs
.filter { _, vmDir in (try? vmDir.state() == .Running) ?? false}
-60
View File
@@ -1,60 +0,0 @@
import Foundation
import System
struct State {
fileprivate let termios: termios
}
class Term {
static func IsTerminal() -> Bool {
var termios = termios()
return tcgetattr(FileHandle.standardInput.fileDescriptor, &termios) != -1
}
static func MakeRaw() throws -> State {
var termiosOrig = termios()
var ret = tcgetattr(FileHandle.standardInput.fileDescriptor, &termiosOrig)
if ret == -1 {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.TerminalOperationFailed("failed to retrieve terminal parameters: \(details)")
}
var termiosRaw = termiosOrig
cfmakeraw(&termiosRaw)
ret = tcsetattr(FileHandle.standardInput.fileDescriptor, TCSANOW, &termiosRaw)
if ret == -1 {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.TerminalOperationFailed("failed to set terminal parameters: \(details)")
}
return State(termios: termiosOrig)
}
static func Restore(_ state: State) throws {
var termios = state.termios
let ret = tcsetattr(FileHandle.standardInput.fileDescriptor, TCSANOW, &termios)
if ret == -1 {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.TerminalOperationFailed("failed to set terminal parameters: \(details)")
}
}
static func GetSize() throws -> (width: UInt16, height: UInt16) {
var winsize = winsize()
guard ioctl(STDOUT_FILENO, TIOCGWINSZ, &winsize) != -1 else {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.TerminalOperationFailed("failed to get terminal size: \(details)")
}
return (width: winsize.ws_col, height: winsize.ws_row)
}
}
+22 -61
View File
@@ -50,10 +50,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
audio: Bool = true,
clipboard: Bool = true,
sync: VZDiskImageSynchronizationMode = .full,
caching: VZDiskImageCachingMode? = nil,
noTrackpad: Bool = false,
noPointer: Bool = false,
noKeyboard: Bool = false
caching: VZDiskImageCachingMode? = nil
) throws {
name = vmDir.name
config = try VMConfig.init(fromURL: vmDir.configURL)
@@ -74,10 +71,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
audio: audio,
clipboard: clipboard,
sync: sync,
caching: caching,
noTrackpad: noTrackpad,
noPointer: noPointer,
noKeyboard: noKeyboard
caching: caching
)
virtualMachine = VZVirtualMachine(configuration: configuration)
@@ -89,7 +83,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Check if we already have this IPSW in cache
var headRequest = URLRequest(url: remoteURL)
headRequest.httpMethod = "HEAD"
let (_, headResponse) = try await Fetcher.fetch(headRequest, viaFile: false)
let (_, headResponse) = try await Fetcher().fetch(headRequest, viaFile: false)
if let hash = headResponse.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
@@ -106,7 +100,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
let request = URLRequest(url: remoteURL)
let (channel, response) = try await Fetcher.fetch(request, viaFile: true)
let (channel, response) = try await Fetcher().fetch(request, viaFile: true)
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw")
@@ -147,7 +141,6 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
vmDir: VMDirectory,
ipswURL: URL,
diskSizeGB: UInt16,
diskFormat: DiskImageFormat = .raw,
network: Network = NetworkShared(),
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
@@ -180,15 +173,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
// Create disk
try vmDir.resizeDisk(diskSizeGB, format: diskFormat)
try vmDir.resizeDisk(diskSizeGB)
name = vmDir.name
// Create config
config = VMConfig(
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
cpuCountMin: requirements.minimumSupportedCPUCount,
memorySizeMin: requirements.minimumSupportedMemorySize,
diskFormat: diskFormat
memorySizeMin: requirements.minimumSupportedMemorySize
)
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
@@ -230,15 +222,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
#endif
@available(macOS 13, *)
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16, diskFormat: DiskImageFormat = .raw) async throws -> VM {
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
// Create NVRAM
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
// Create disk
try vmDir.resizeDisk(diskSizeGB, format: diskFormat)
try vmDir.resizeDisk(diskSizeGB)
// Create config
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024, diskFormat: diskFormat)
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024)
try config.save(toURL: vmDir.configURL)
return try VM(vmDir: vmDir)
@@ -254,19 +246,6 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
}
@MainActor
func connect(toPort: UInt32) async throws -> VZVirtioSocketConnection {
guard let socketDevice = virtualMachine.socketDevices.first else {
throw RuntimeError.VMSocketFailed(toPort, ", VM has no socket devices configured")
}
guard let virtioSocketDevice = socketDevice as? VZVirtioSocketDevice else {
throw RuntimeError.VMSocketFailed(toPort, ", expected VM's first socket device to have a type of VZVirtioSocketDevice, got \(type(of: socketDevice)) instead")
}
return try await virtioSocketDevice.connect(toPort: toPort)
}
func run() async throws {
do {
try await sema.waitUnlessCancelled()
@@ -319,10 +298,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
audio: Bool = true,
clipboard: Bool = true,
sync: VZDiskImageSynchronizationMode = .full,
caching: VZDiskImageCachingMode? = nil,
noTrackpad: Bool = false,
noPointer: Bool = false,
noKeyboard: Bool = false
caching: VZDiskImageCachingMode? = nil
) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration()
@@ -362,20 +338,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
} else {
if noKeyboard {
configuration.keyboards = []
} else {
configuration.keyboards = vmConfig.platform.keyboards()
}
if noPointer {
configuration.pointingDevices = []
} else if noTrackpad {
configuration.pointingDevices = vmConfig.platform.pointingDevicesSimplified()
} else {
configuration.pointingDevices = vmConfig.platform.pointingDevices()
}
configuration.keyboards = vmConfig.platform.keyboards()
configuration.pointingDevices = vmConfig.platform.pointingDevices()
}
// Networking
@@ -387,19 +351,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
// Clipboard sharing via Spice agent
if clipboard {
if clipboard && vmConfig.os == .linux {
let spiceAgentConsoleDevice = VZVirtioConsoleDeviceConfiguration()
let spiceAgentPort = VZVirtioConsolePortConfiguration()
spiceAgentPort.name = VZSpiceAgentPortAttachment.spiceAgentPortName
let spiceAgentPortAttachment = VZSpiceAgentPortAttachment()
spiceAgentPortAttachment.sharesClipboard = true
spiceAgentPort.attachment = spiceAgentPortAttachment
spiceAgentPort.attachment = VZSpiceAgentPortAttachment()
spiceAgentConsoleDevice.ports[0] = spiceAgentPort
configuration.consoleDevices.append(spiceAgentConsoleDevice)
}
// Storage
var attachment = try VZDiskImageStorageDeviceAttachment(
let attachment: VZDiskImageStorageDeviceAttachment = try VZDiskImageStorageDeviceAttachment(
url: diskURL,
readOnly: false,
// When not specified, use "cached" caching mode for Linux VMs to prevent file-system corruption[1]
@@ -428,16 +390,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
//
// A dummy console device useful for implementing
// host feature checks in the guest agent software.
let consolePort = VZVirtioConsolePortConfiguration()
consolePort.name = "tart-version-\(CI.version)"
if !suspendable {
let consolePort = VZVirtioConsolePortConfiguration()
consolePort.name = "tart-version-\(CI.version)"
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
consoleDevice.ports[0] = consolePort
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
consoleDevice.ports[0] = consolePort
configuration.consoleDevices.append(consoleDevice)
// Socket device
configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()]
configuration.consoleDevices.append(consoleDevice)
}
try configuration.validate()
+3 -20
View File
@@ -25,31 +25,20 @@ enum CodingKeys: String, CodingKey {
case macAddress
case display
case displayRefit
case diskFormat
// macOS-specific keys
case ecid
case hardwareModel
}
struct VMDisplayConfig: Codable, Equatable {
enum Unit: String, Codable {
case point = "pt"
case pixel = "px"
}
struct VMDisplayConfig: Codable {
var width: Int = 1024
var height: Int = 768
var unit: Unit?
}
extension VMDisplayConfig: CustomStringConvertible {
var description: String {
if let unit {
"\(width)x\(height)\(unit.rawValue)"
} else {
"\(width)x\(height)"
}
"\(width)x\(height)"
}
}
@@ -65,14 +54,12 @@ struct VMConfig: Codable {
var macAddress: VZMACAddress
var display: VMDisplayConfig = VMDisplayConfig()
var displayRefit: Bool?
var diskFormat: DiskImageFormat = .raw
init(
platform: Platform,
cpuCountMin: Int,
memorySizeMin: UInt64,
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered(),
diskFormat: DiskImageFormat = .raw
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
) {
self.os = platform.os()
self.arch = CurrentArchitecture()
@@ -80,7 +67,6 @@ struct VMConfig: Codable {
self.macAddress = macAddress
self.cpuCountMin = cpuCountMin
self.memorySizeMin = memorySizeMin
self.diskFormat = diskFormat
cpuCount = cpuCountMin
memorySize = memorySizeMin
}
@@ -138,8 +124,6 @@ struct VMConfig: Codable {
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
displayRefit = try container.decodeIfPresent(Bool.self, forKey: .displayRefit)
let diskFormatString = try container.decodeIfPresent(String.self, forKey: .diskFormat) ?? "raw"
diskFormat = DiskImageFormat(rawValue: diskFormatString) ?? .raw
}
func encode(to encoder: Encoder) throws {
@@ -158,7 +142,6 @@ struct VMConfig: Codable {
if let displayRefit = displayRefit {
try container.encode(displayRefit, forKey: .displayRefit)
}
try container.encode(diskFormat.rawValue, forKey: .diskFormat)
}
mutating func setCPU(cpuCount: Int) throws {
+14 -14
View File
@@ -1,6 +1,6 @@
import Compression
import Foundation
import OpenTelemetryApi
import Sentry
enum OCIError: Error {
case ShouldBeExactlyOneLayer
@@ -11,7 +11,14 @@ enum OCIError: Error {
}
extension VMDirectory {
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws {
func pullFromRegistry(
registry: Registry,
manifest: OCIManifest,
concurrency: UInt,
localLayerCache: LocalLayerCache?,
deduplicate: Bool,
maxRetries: UInt
) async throws {
// Pull VM's config file layer and re-serialize it into a config file
let configLayers = manifest.layers.filter {
$0.mediaType == configMediaType
@@ -43,10 +50,7 @@ extension VMDirectory {
}
let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +)
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "compressed_disk_size_bytes",
value: .int(Int(diskCompressedSize))
)
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte)
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
@@ -58,7 +62,8 @@ extension VMDirectory {
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
concurrency: concurrency, progress: progress,
localLayerCache: localLayerCache,
deduplicate: deduplicate)
deduplicate: deduplicate,
maxRetries: maxRetries)
} catch let error where error is FilterError {
throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)")
}
@@ -90,15 +95,11 @@ extension VMDirectory {
try manifest.toJSON().write(to: manifestURL)
}
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String, concurrency: UInt, labels: [String: String] = [:]) async throws -> RemoteName {
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String, concurrency: UInt) async throws -> RemoteName {
var layers = Array<OCIManifestLayer>()
// Read VM's config and push it as blob
let config = try VMConfig(fromURL: configURL)
// Add disk format label automatically
var labels = labels
labels[diskFormatLabel] = config.diskFormat.rawValue
let configJSON = try JSONEncoder().encode(config)
defaultLogger.appendNewLine("pushing config...")
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
@@ -128,8 +129,7 @@ extension VMDirectory {
layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest))
// Craft a stub OCI config for Docker Hub compatibility
let ociConfigContainer = OCIConfig.ConfigContainer(Labels: labels)
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os, config: ociConfigContainer).toJSON()
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
+3 -119
View File
@@ -26,9 +26,6 @@ struct VMDirectory: Prunable {
var manifestURL: URL {
baseURL.appendingPathComponent("manifest.json")
}
var controlSocketURL: URL {
URL(fileURLWithPath: "control.sock", relativeTo: baseURL)
}
var explicitlyPulledMark: URL {
baseURL.appendingPathComponent(".explicitly-pulled")
@@ -142,34 +139,14 @@ struct VMDirectory: Prunable {
try vmConfig.save(toURL: configURL)
}
func resizeDisk(_ sizeGB: UInt16, format: DiskImageFormat = .raw) throws {
let diskExists = FileManager.default.fileExists(atPath: diskURL.path)
if diskExists {
// Existing disk - resize it
try resizeExistingDisk(sizeGB)
} else {
// New disk - create it with the specified format
try createDisk(sizeGB: sizeGB, format: format)
func resizeDisk(_ sizeGB: UInt16) throws {
if !FileManager.default.fileExists(atPath: diskURL.path) {
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
}
}
private func resizeExistingDisk(_ sizeGB: UInt16) throws {
// Check if this is an ASIF disk by reading the VM config
let vmConfig = try VMConfig(fromURL: configURL)
if vmConfig.diskFormat == .asif {
try resizeASIFDisk(sizeGB)
} else {
try resizeRawDisk(sizeGB)
}
}
private func resizeRawDisk(_ sizeGB: UInt16) throws {
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
let currentDiskFileLength = try diskFileHandle.seekToEnd()
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
if desiredDiskFileLength < currentDiskFileLength {
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength))
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength))
@@ -181,84 +158,6 @@ struct VMDirectory: Prunable {
try diskFileHandle.close()
}
private func resizeASIFDisk(_ sizeGB: UInt16) throws {
do {
let diskImageInfo = try Diskutil.imageInfo(diskURL)
let currentSizeBytes = try diskImageInfo.totalBytes()
let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000
if desiredSizeBytes < currentSizeBytes {
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentSizeBytes))
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredSizeBytes))
throw RuntimeError.InvalidDiskSize("New disk size of \(desiredLengthHuman) should be larger " +
"than the current disk size of \(currentLengthHuman)")
} else if desiredSizeBytes > currentSizeBytes {
// Resize the ASIF disk image using diskutil
try performASIFResize(sizeGB)
} else {
// If sizes are equal, no action needed
}
} catch let error as RuntimeError {
throw error
} catch {
throw RuntimeError.FailedToResizeDisk("\(error)")
}
}
private func performASIFResize(_ sizeGB: UInt16) throws {
guard let diskutilURL = resolveBinaryPath("diskutil") else {
throw RuntimeError.FailedToResizeDisk("diskutil not found in PATH")
}
let process = Process()
process.executableURL = diskutilURL
process.arguments = [
"image", "resize",
"--size", "\(sizeGB)G",
diskURL.path
]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if process.terminationStatus != 0 {
let output = String(data: data, encoding: .utf8) ?? "Unknown error"
throw RuntimeError.FailedToResizeDisk("Failed to resize ASIF disk image: \(output)")
}
} catch {
throw RuntimeError.FailedToResizeDisk("Failed to execute diskutil resize: \(error)")
}
}
private func createDisk(sizeGB: UInt16, format: DiskImageFormat) throws {
switch format {
case .raw:
try createRawDisk(sizeGB: sizeGB)
case .asif:
try Diskutil.imageCreate(diskURL: diskURL, sizeGB: sizeGB)
}
}
private func createRawDisk(sizeGB: UInt16) throws {
// Create traditional raw disk image
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
try diskFileHandle.truncate(atOffset: desiredDiskFileLength)
try diskFileHandle.close()
}
func delete() throws {
let lock = try lock()
@@ -299,21 +198,6 @@ struct VMDirectory: Prunable {
try sizeBytes() / 1000 / 1000 / 1000
}
func diskSizeBytes() throws -> Int {
let vmConfig = try VMConfig(fromURL: configURL)
return switch vmConfig.diskFormat {
case .raw:
try sizeBytes()
case .asif:
try Diskutil.imageInfo(diskURL).totalBytes()
}
}
func diskSizeGB() throws -> Int {
try diskSizeBytes() / 1000 / 1000 / 1000
}
func markExplicitlyPulled() {
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
}
+17 -15
View File
@@ -49,7 +49,6 @@ extension Error {
}
enum RuntimeError : Error {
case Generic(_ message: String)
case VMConfigurationError(_ message: String)
case VMDoesNotExist(name: String)
case VMMissingFiles(_ message: String)
@@ -60,8 +59,6 @@ enum RuntimeError : Error {
case DiskAlreadyInUse(_ message: String)
case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
case InvalidDiskSize(_ message: String)
case FailedToCreateDisk(_ message: String)
case FailedToResizeDisk(_ message: String)
case FailedToUpdateAccessDate(_ message: String)
case PIDLockFailed(_ message: String)
case PIDLockMissing(_ message: String)
@@ -78,8 +75,8 @@ enum RuntimeError : Error {
case SuspendFailed(_ message: String)
case PullFailed(_ message: String)
case VirtualMachineLimitExceeded(_ hint: String)
case VMSocketFailed(_ port: UInt32, _ explanation: String)
case TerminalOperationFailed(_ message: String)
case InvalidProxyString
case FailedToLoadCACertificate(_ message: String)
}
protocol HasExitCode {
@@ -89,8 +86,6 @@ protocol HasExitCode {
extension RuntimeError : CustomStringConvertible {
public var description: String {
switch self {
case .Generic(let message):
return message
case .VMConfigurationError(let message):
return message
case .VMDoesNotExist(let name):
@@ -111,10 +106,6 @@ extension RuntimeError : CustomStringConvertible {
return "failed to open block device \(path): \(explanation)"
case .InvalidDiskSize(let message):
return message
case .FailedToCreateDisk(let message):
return message
case .FailedToResizeDisk(let message):
return message
case .FailedToUpdateAccessDate(let message):
return message
case .PIDLockFailed(let message):
@@ -147,10 +138,10 @@ extension RuntimeError : CustomStringConvertible {
return message
case .VirtualMachineLimitExceeded(let hint):
return "The number of VMs exceeds the system limit\(hint)"
case .VMSocketFailed(let port, let explanation):
return "Failed to establish a VM socket connection to port \(port): \(explanation)"
case .TerminalOperationFailed(let message):
return message
case .InvalidProxyString:
return "Invalid proxy string, should be in the form of host:port"
case .FailedToLoadCACertificate(let message):
return "Failed to load CA certificate: \(message)"
}
}
}
@@ -169,3 +160,14 @@ extension RuntimeError : HasExitCode {
}
}
}
// Customize error description for Sentry[1]
//
// [1]: https://docs.sentry.io/platforms/apple/guides/ios/usage/#customizing-error-descriptions
extension RuntimeError : CustomNSError {
var errorUserInfo: [String : Any] {
[
NSDebugDescriptionErrorKey: description,
]
}
}
+1 -5
View File
@@ -1,11 +1,7 @@
import Foundation
class VMStorageLocal: PrunableStorage {
let baseURL: URL
init() throws {
baseURL = try Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
}
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
private func vmURL(_ name: String) -> URL {
baseURL.appendingPathComponent(name, isDirectory: true)
+16 -26
View File
@@ -1,13 +1,9 @@
import Foundation
import OpenTelemetryApi
import Sentry
import Retry
class VMStorageOCI: PrunableStorage {
let baseURL: URL
init() throws {
baseURL = try Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
}
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
private func vmURL(_ name: RemoteName) -> URL {
baseURL.appendingRemoteName(name)
@@ -31,12 +27,12 @@ class VMStorageOCI: PrunableStorage {
return digest
}
func open(_ name: RemoteName, _ accessDate: Date = Date()) throws -> VMDirectory {
func open(_ name: RemoteName) throws -> VMDirectory {
let vmDir = VMDirectory(baseURL: vmURL(name))
try vmDir.validate(userFriendlyName: name.description)
try vmDir.baseURL.updateAccessDate(accessDate)
try vmDir.baseURL.updateAccessDate()
return vmDir
}
@@ -144,11 +140,10 @@ class VMStorageOCI: PrunableStorage {
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
}
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "oci.image-name",
value: .string(name.description)
)
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool, maxRetries: UInt) async throws {
SentrySDK.configureScope { scope in
scope.setContext(value: ["imageName": name.description], key: "OCI")
}
defaultLogger.appendNewLine("pulling manifest...")
@@ -182,25 +177,18 @@ class VMStorageOCI: PrunableStorage {
}
if !exists(digestName) {
let span = OTel.shared.tracer.spanBuilder(spanName: "pull").setActive(true).startSpan()
defer { span.end() }
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
// Open an existing VM directory corresponding to this name, if any,
// marking it as outdated to speed up the garbage collection process
_ = try? open(name, Date(timeIntervalSince1970: 0))
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
// Try to reclaim some cache space if we know the VM size in advance
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "oci.image-uncompressed-disk-size-bytes",
value: .int(Int(uncompressedDiskSize))
)
SentrySDK.configureScope { scope in
scope.setContext(value: ["imageUncompressedDiskSize": uncompressedDiskSize], key: "OCI")
}
let otherVMFilesSize: UInt64 = 128 * 1024 * 1024
@@ -208,7 +196,7 @@ class VMStorageOCI: PrunableStorage {
}
try await withTaskCancellationHandler(operation: {
try await retry(maxAttempts: 5) {
try await retry(maxAttempts: Int(maxRetries)) {
// Choose the best base image which has the most deduplication ratio
let localLayerCache = try await chooseLocalLayerCache(name, manifest, registry)
@@ -222,7 +210,7 @@ class VMStorageOCI: PrunableStorage {
}
}
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache, deduplicate: deduplicate)
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache, deduplicate: deduplicate, maxRetries: maxRetries)
} recoverFromFailure: { error in
if error is URLError {
print("Error pulling image: \"\(error.localizedDescription)\", attempting to re-try...")
@@ -233,7 +221,9 @@ class VMStorageOCI: PrunableStorage {
return .throw
}
try move(digestName, from: tmpVMDir)
transaction.finish()
}, onCancel: {
transaction.finish(status: SentrySpanStatus.cancelled)
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
} else {
@@ -1,63 +0,0 @@
import XCTest
@testable import tart
final class DiskImageFormatTests: XCTestCase {
func testASIFFormatSupport() throws {
// ASIF should be supported on macOS 26+
if #available(macOS 26, *) {
XCTAssertTrue(DiskImageFormat.asif.isSupported)
} else {
XCTAssertFalse(DiskImageFormat.asif.isSupported)
}
}
func testFormatFromString() throws {
XCTAssertEqual(DiskImageFormat(rawValue: "raw"), .raw)
XCTAssertEqual(DiskImageFormat(rawValue: "asif"), .asif)
XCTAssertNil(DiskImageFormat(rawValue: "invalid"))
}
func testCaseInsensitivity() throws {
XCTAssertEqual(DiskImageFormat(argument: "ASIF"), .asif) // case insensitive
XCTAssertEqual(DiskImageFormat(argument: "Raw"), .raw) // case insensitive
}
func testAllValueStrings() throws {
let allValues = DiskImageFormat.allValueStrings
XCTAssertTrue(allValues.contains("raw"))
XCTAssertTrue(allValues.contains("asif"))
XCTAssertEqual(allValues.count, 2)
}
func testVMConfigDiskFormatSerialization() throws {
// Test that VMConfig properly serializes and deserializes disk format
let config = VMConfig(
platform: Linux(),
cpuCountMin: 2,
memorySizeMin: 1024 * 1024 * 1024,
diskFormat: .asif
)
XCTAssertEqual(config.diskFormat, .asif)
// Test JSON encoding/decoding
let encoder = JSONEncoder()
let data = try encoder.encode(config)
let decoder = JSONDecoder()
let decodedConfig = try decoder.decode(VMConfig.self, from: data)
XCTAssertEqual(decodedConfig.diskFormat, .asif)
}
func testVMConfigDefaultDiskFormat() throws {
// Test that VMConfig defaults to raw format
let config = VMConfig(
platform: Linux(),
cpuCountMin: 2,
memorySizeMin: 1024 * 1024 * 1024
)
XCTAssertEqual(config.diskFormat, .raw)
}
}
-21
View File
@@ -1,21 +0,0 @@
import XCTest
@testable import tart
final class DiskutilTests: XCTestCase {
func testDiskutilInfo() throws {
// Create a temporary directory
let tempDirURL = FileManager.default.temporaryDirectory.appendingPathComponent("tart-diskutil-tests-\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: tempDirURL, withIntermediateDirectories: true)
addTeardownBlock {
try? FileManager.default.removeItem(at: tempDirURL)
}
// Create a 123 GB ASIF disk
let diskURL = tempDirURL.appendingPathComponent("disk.asif")
try Diskutil.imageCreate(diskURL: diskURL, sizeGB: 123)
// Retrieve its information and ensure that it does indeed take 123 GB
let info = try Diskutil.imageInfo(diskURL)
XCTAssertEqual(123 * 1000 * 1000 * 1000, try info.totalBytes())
}
}
-18
View File
@@ -1,18 +0,0 @@
import XCTest
@testable import tart
final class VMConfigTests: XCTestCase {
func testVMDisplayConfig() throws {
// Defaults units (points)
var vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678")
XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: nil), vmDisplayConfig)
// Explicit units (points)
vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678pt")
XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: .point), vmDisplayConfig)
// Explicit units (pixels)
vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678px")
XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: .pixel), vmDisplayConfig)
}
}
+28 -12
View File
@@ -13,7 +13,7 @@ brew install go
Finally, run the following command from this (`benchmark/`) directory:
```shell
go run cmd/main.go fio --image ghcr.io/cirruslabs/macos-sequoia-base:latest --prepare 'sudo purge && sync'
go run cmd/main.go fio --image ghcr.io/cirruslabs/macos-sonoma-base:latest --prepare 'sudo purge && sync'
```
You can also enable the debugging output to diagnose issues:
@@ -186,25 +186,41 @@ sync test Tart (--root-disk-opts="caching=cached"
sync test Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 17 MB/s 0 IOPS 7.39 kIOPS 0s ± 0s 21.23µs ± 81.749µs 113.239µs ± 191.266µs
```
### March 23, 2025
### Jan 16, 2025
Host:
* Hardware: Mac mini (Apple M2 Pro, 8 performance and 4 efficiency cores, 32 GB RAM, `Mac14,12`)
* OS: macOS Sequoia 15.3.2
* Xcode: 16.2
* OS: macOS Sequoia 15.2
Guest:
* Hardware: [Virtualization.Framework](https://developer.apple.com/documentation/virtualization)
* OS: macOS Sonoma 15.3.2
* Xcode: 16.2
* OS: macOS Sonoma 14.6
```
Name Executor Time
XcodeBenchmark (d869315) local 2m19s
XcodeBenchmark (d869315) Tart 3m59s
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none") 3m48s
XcodeBenchmark (d869315) Tart (--root-disk-opts="caching=cached") 3m35s
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none,caching=cached") 3m14s
Name Executor Time
XcodeBenchmark (d869315) local 2m15s
XcodeBenchmark (d869315) Tart 4m22s
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none") 4m21s
XcodeBenchmark (d869315) Tart (--root-disk-opts="caching=cached") 4m15s
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none,caching=cached") 4m16s
```
```
Name Executor Time
XcodeBenchmark (d869315) local 2m7s
XcodeBenchmark (d869315) Tart 4m37s
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none") 4m35s
XcodeBenchmark (d869315) Tart (--root-disk-opts="caching=cached") 4m19s
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none,caching=cached") 4m16s
```
```
Name Executor Time
XcodeBenchmark (d869315) local 2m6s
XcodeBenchmark (d869315) Tart 4m24s
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none") 4m22s
XcodeBenchmark (d869315) Tart (--root-disk-opts="caching=cached") 4m18s
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none,caching=cached") 4m17s
```
+3 -8
View File
@@ -1,25 +1,21 @@
module github.com/cirruslabs/tart/benchmark
go 1.23.0
toolchain go1.24.1
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/shirou/gopsutil v3.21.11+incompatible
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.35.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/go-ole/go-ole v1.2.6 // 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
@@ -27,8 +23,7 @@ require (
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
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/sys v0.18.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+6 -13
View File
@@ -7,8 +7,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
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/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
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=
@@ -28,31 +26,26 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
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/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
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=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
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.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
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=
@@ -8,6 +8,6 @@ type Benchmark struct {
var benchmarks = []Benchmark{
{
Name: "XcodeBenchmark (d869315)",
Command: "git clone https://github.com/devMEremenko/XcodeBenchmark.git && cd XcodeBenchmark && git reset --hard d86931529ada1df2a1c6646dd85958c360954065 && xcrun simctl list && sh benchmark.sh",
Command: "git clone https://github.com/devMEremenko/XcodeBenchmark.git && cd XcodeBenchmark && git reset --hard d86931529ada1df2a1c6646dd85958c360954065 && sh benchmark.sh",
},
}
+1 -1
View File
@@ -23,7 +23,7 @@ func NewCommand() *cobra.Command {
}
cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging")
cmd.Flags().StringVar(&image, "image", "ghcr.io/cirruslabs/macos-sequoia-xcode:latest", "image to use for testing")
cmd.Flags().StringVar(&image, "image", "ghcr.io/cirruslabs/macos-sonoma-xcode:latest", "image to use for testing")
cmd.Flags().StringVar(&prepare, "prepare", "", "command to run before running each benchmark")
return cmd
-20
View File
@@ -7,14 +7,11 @@ import (
"fmt"
"github.com/avast/retry-go/v4"
"github.com/google/uuid"
"github.com/shirou/gopsutil/mem"
"go.uber.org/zap"
"go.uber.org/zap/zapio"
"golang.org/x/crypto/ssh"
"io"
"net"
"runtime"
"strconv"
"strings"
"time"
)
@@ -40,23 +37,6 @@ func New(ctx context.Context, image string, runArgsExtra []string, logger *zap.L
return nil, err
}
vmStat, err := mem.VirtualMemory()
if err != nil {
return nil, err
}
cpus := strconv.Itoa(runtime.NumCPU())
memory := strconv.FormatUint(vmStat.Total/1024/1024, 10)
logger.Info("Setting resources", zap.String("cpus", cpus), zap.String("memory", memory))
setResourcesArguments := []string{
"set", tart.vmName,
"--cpu", cpus,
"--memory", memory,
}
if err := Cmd(ctx, tart.logger, setResourcesArguments...); err != nil {
return nil, err
}
vmRunCtx, vmRunCancel := context.WithCancel(ctx)
tart.vmRunCancel = vmRunCancel
+1 -3
View File
@@ -9,6 +9,4 @@
"MD033": false # Inline HTML
"MD041": false # First line in file should be a top level heading
"MD045": false # OK not to have a description for an image
"MD046": false # Code block style [Expected: fenced; Actual: indented]
"MD059": false # It's OK to have "here" links
"MD051": false # MkDocs generates "#-no-pki" anchors, but markdownlint expects "#--no-pki" anchors
"MD046": false # Code block style [Expected: fenced; Actual: indented]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

@@ -60,9 +60,9 @@ device without a physical display connected. For example, a Mac Mini with a HDMI
but a Mac Mini on a desk with a connected physical display is considered a personal computer. **Usage on personal computers
and before reaching the 100 CPU cores limit is royalty-free and does not have the viral properties of AGPL.**
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Tier License](../../licensing.md#license-tiers),
which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](../../licensing.md#license-tiers)
(\$3000 per month) will be required, and for organizations that exceed 3000 CPU cores, a custom [Diamond Tier License](../../licensing.md#license-tiers)
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Tier License](/licensing#license-tiers),
which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](/licensing#license-tiers)
(\$3000 per month) will be required, and for organizations that exceed 3000 CPU cores, a custom [Diamond Tier License](/licensing#license-tiers)
(\$1 per core per month) will be necessary. **All paid license tiers will include priority feature development and SLAs on support with urgent issues.**
## Have we considered alternatives?
+1 -1
View File
@@ -89,6 +89,6 @@ orchard dev
This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/cirruslabs/orchard#creating-virtual-machines)
on how to create your first virtual machine and access it.
In a [separate blog post](2023-04-28-orchard-ssh-over-grpc.md)
In a [separate blog post](/blog/2023/04/28/ssh-over-grpc-or-how-orchard-simplifies-accessing-vms-in-private-networks/)
we’ll cover how Orchard implements seamless SSH access over a gRPC connection. Stay tuned and please don’t hesitate to
[reach out](https://github.com/cirruslabs/orchard/discussions/landing)!
@@ -64,7 +64,7 @@ We’ve also initially considered using [Yamux](https://github.com/hashicorp/yam
First of all, we’ve made the new port-forwarding functionality available for integrations via the Orchard’s REST API:
![OpenAPI documentation for Orchard's port-forwarding endpoint](../../assets/images/orchard-port-forwarding-api.png)
![OpenAPI documentation for Orchard's port-forwarding endpoint](/assets/images/orchard-port-forwarding-api.png)
All you need is to use a WebSocket client when accessing this endpoint to make it work.
+1 -1
View File
@@ -43,7 +43,7 @@ allocate time to continue improving Tart which brings us to the section below.
In the last 7 months we've had 12 feature releases that brought a lot of features requested by the community. Here are just
a few of them to highlight:
-[Custom GitLab Runner Executor](../../integrations/gitlab-runner.md).
-[Custom GitLab Runner Executor](/integrations/gitlab-runner/).
-[Cluster Management via Orchard](2023-04-25-orchard-ga.md).
-Numerous compatibility improvements for all kinds of OCI-registries.
-Sonoma Support (see details [below](#macos-sonoma-updates)).
+1 -1
View File
@@ -17,7 +17,7 @@ with preconfigured Tart installation that is optimized to work within AWS infras
EC2 Mac Instances is a gem of engineering powered by AWS Nitro devices. Just imagine there is a physical Mac Mini with
a plugged in Nitro device that can push the physical power button!
![EC2 M2 Pro](../images/ec2-mac2-m2pro.png)
![EC2 M2 Pro](/blog/images/ec2-mac2-m2pro.png)
This clever synergy between Apple Hardware and Nitro System allows seamless integration with VPC networking and booting macOS from an EBS volume.
@@ -34,7 +34,7 @@ than recently announced Apple Silicon GitHub-manged runners that cost $0.16 per
Now lets take a look at the new Cirrus Runners dashboard of a real customers that run their workflows on Cirrus Runners
and **practically pushing the price performance pretty close to the theoretical minimum**.
![Cirrus Runners Dashboard](../images/runners-price-performance-2.png)
![Cirrus Runners Dashboard](/blog/images/runners-price-performance-2.png)
As you can see above Cirrus Runners Dashboard focuses on 4 core metrics:
@@ -50,7 +50,7 @@ we can see that the downside of such great price performance is that jobs are wa
Here is another example of Cirrus Runners Dashboard for a different customer that has a slightly higher price performance of $0.017 per minute
but at the same time doesn't experience queue time at all. **Note that $0.017 is still 10 times cheaper than GitHub-managed Apple Silicon runners**.
![Cirrus Runners Dashboard](../images/runners-price-performance-3.png)
![Cirrus Runners Dashboard](/blog/images/runners-price-performance-3.png)
## Conclusion
@@ -1,72 +0,0 @@
---
draft: false
date: 2025-06-01
search:
exclude: true
authors:
- edigaryev
categories:
- announcement
---
# Bridging the gaps with the Tart Guest Agent
We're introducing a new improvement for the Tart usability experience: a [Tart Guest Agent](https://github.com/cirruslabs/tart-guest-agent).
This agent provides automatic disk resizing, seamless clipboard sharing for macOS guests (a [long-awaited](https://github.com/cirruslabs/tart/issues/14) feature), and the ability to run commands, without SSH and networking, using the new `tart exec` command.
As of recently, we include this agent in all non-vanilla Cirrus Labs images, so you likely won't need to do anything to benefit from these usability improvements.
Read on to learn why we chose to implement the agent from scratch in Golang, and which features we plan to add next.
<!-- more -->
## Existing solutions
Tart uses the Virtualization.Framework, and the latter implemented a SPICE client some time ago, however, one piece was missing: the agent that runs inside the guest.
The original [SPICE `vdagent` implementation](https://gitlab.freedesktop.org/spice/linux/vd_agent) only supports Linux. While [a fork](https://github.com/utmapp/vd_agent) from the UTM project adds macOS support, the long-term viability of maintaining this fork without upstreaming changes is uncertain.
Moreover, if we were to add some extra functionality (as we did), there would be more than one agent binary to ship and install, which complicates maintenance and makes it harder to explain to users why we need a bunch of agent binaries.
In the end, we decided to go with our own solution, one that would easily accomodate future ideas.
## Rolling our own agent
After carefully inspecting the [`vdagent` protocol](https://www.spice-space.org/agent-protocol.html) we've realized that the clipboard sharing is actually a small subset of the whole protocol, making it relatively simple to implement.
Thanks to Golang, we were able to implement the protocol much faster than we could have with a lower-level language like C (with all due respect), which requires manual memory management and complex event loops.
As for the command execution via `tart exec`, we've decided to go with gRPC with a rather simple protocol:
![An visualization of gRPC protocol used by the Tart Guest Agent](../images/tart-guest-agent-grpc-protocol.png)
For each `tart exec` invocation a new gRPC `Exec` bidirectional stream is established with the agent running inside a VM. After the gRPC stream is established, `tart exec` sends a command to execute to the guest and streams the I/O. Once the command terminates, `tart exec` collects the process exit code and quits with exactly that exit code.
Using gRPC simplifies `tart exec` implementation because of code generation and forms a nice bridge between the host and the guest which allows us to easily expand the protocol later down the road when we decide to introduce new features.
Thanks to [gRPC Swift](https://github.com/grpc/grpc-swift), which is built on top of [SwiftNIO](https://github.com/apple/swift-nio), we get [`async/await`](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/) support for free, further simplifying the `tart exec` logic.
As for the Tart Guest Agent, the final result is a Golang binary that [can be customized](https://github.com/cirruslabs/tart-guest-agent?tab=readme-ov-file#guest-agent-for-tart-vms) depending on the execution context:
* launchd global daemon — runs as a privileged user (`root`), has no clipboard access
* `--resize-disk` — resizes the disk when there's a free space at the end of a disk (assuming that one previously ran `tart set --disk-size`)
* launchd global agent — runs as a normal user (`admin`), has clipboard access
* `--run-vdagent` — clipboard sharing
* `--run-rpc` — `tart exec` and new functionality in the future
We’ve also introduced `--run-daemon` (which implies `--resize-disk`) and `--run-agent` (which implies both `--run-vdagent` and `--run-rpc`) to help run the most appropriate functionality based on the given context.
## Future plans
First, we'd like to thank our paid clients, without whom this feature wouldn't have been possible.
[Become one now](../../licensing.md) and enjoy higher allowances for Tart VMs and Orchard workers—while helping ensure that our roadmap aligns with your company's needs.
In the near future we plan to implement:
* Linux support — to provide seamless experience for Linux guests too
* a new `tart ip` resolver — to provide a more robust IP retrieval facility for Linux guests, which often struggle to populate the host's ARP table with their network activity
* `tart cp` command — to copy files from/to guest VMs
Stay tuned, and feel free to send us feedback on [GitHub](https://github.com/cirruslabs/tart) and [Twitter](https://x.com/cirrus_labs)!
@@ -1,44 +0,0 @@
---
draft: false
date: 2025-10-27
search:
exclude: true
authors:
- fkorotkov
categories:
- announcement
---
# Press Release: Cirrus Labs Successfully Enforces Its Fair Source License
**New York City, NY – October 27th, 2025 – Cirrus Labs, Inc.**, a leading provider of platforms for digital transformation, today announced that it has reached a settlement agreement regarding a violation of its Fair Source License.
<!-- more -->
Cirrus Labs makes its Tart Virtualization Toolset, a leading virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon,
freely available on GitHub under the Fair Source License, a source-available license. Tart is used by tens of thousands of engineers at no charge within its generous free‑use limits.
Many large enterprises that need to exceed those limits support continued development through paid licenses. Cirrus Labs also uses Tart to power [Cirrus Runners](https://cirrus-runners.app/)
— a drop‑in replacement for macOS and Linux runners for GitHub Actions — offered at a fixed monthly price for unlimited usage.
Cirrus Labs discovered that, **despite a prior licensing request that was declined due to a conflict of interest**, another company used Tart in a manner that exceeded the license’s free‑use limits,
in order to create a competing product.
After several months of negotiations, the matter was settled and a settlement payment to Cirrus Labs was agreed upon.
!!! quote "Comment by Fedor Korotkov, CEO of Cirrus Labs"
As a company we embrace healthy competition that ultimately benefits the end user. Most of our users have no trouble complying with our license,
and even when they need something more than our free use limits, we can almost always grant them a license that fits their needs. **This was an exceptional case.**
We are pleased to have reached this settlement, which validates our source-available licensing strategy and reinforces our commitment to protecting our company and serving our community.
Cirrus Labs was represented in this matter by [Jordan Raphael](https://byronraphael.com/attorneys/jordan-raphael/) of Byron Raphael LLP, a boutique intellectual property law firm,
and [Heather Meeker](https://www.techlawpartners.com/heather), a well-known specialist in open source and source available licensing.
The specific financial terms of the settlement and the identity of the counterparty remain confidential.
**About Cirrus Labs:** Cirrus Labs, Inc. is a bootstrapped developer-infrastructure company founded in 2017. Our offerings among others include Tart and Cirrus Runners,
and our software is used by teams at category-leading companies including Atlassian, Figma, Zendesk, Sentry and many more.
Learn more at [https://tart.run/](https://tart.run/) and [https://cirrus-runners.app/](https://cirrus-runners.app/).
**Contact:** [hello@cirruslabs.org](mailto:hello@cirruslabs.org)
+5 -110
View File
@@ -5,42 +5,6 @@ title: Frequently Asked Questions
description: Advanced configuration and troubleshooting tips for advanced configurations.
---
## Headless machines
Starting from macOS 15 (Sequoia), there's an undocumented requirement from [Virtualization.Framework](https://developer.apple.com/documentation/virtualization) (which Tart uses) to have an unlocked `login.keychain` available at the times when running a VM.
Without an existing and unlocked `login.keychain`, the VM won't start with errors like:
* `SecKeyCreateRandomKey_ios failed`
* `Failed to generate keypair`
* `Interaction is not allowed with the Security Server`
Below you'll find a couple of workarounds for this behavior.
### Log in via GUI at least once
Connect to the headless machine via [Screen Sharing](https://support.apple.com/guide/mac-help/share-the-screen-of-another-mac-mh14066/mac) and log in to a Mac user account. If you haven't done already, you can enable Screen Sharing [via the terminal](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/connect-to-mac-instance.html#mac-instance-vnc).
Logging in graphically will automatically create the `login.keychain`. Afterward, you have two options:
* configure [automatic log in to a Mac user account](https://support.apple.com/en-us/102316)
* this will maintain a running user session (GUI) even after the machine reboots
* moreover, you can still lock the screen (either manually [or automatically](https://support.apple.com/guide/mac-help/change-lock-screen-settings-on-mac-mh11784/mac)), however, the security benefit of this is questionable
* use `security unlock-keychain login.keychain` to unlock the login keychain via the terminal
* this command also supports the `-p` command-line argument, which allows you to supply a password and unlock non-interactively
### Create and unlock the login keychain via the terminal
Compared to the previous approach, this one is fully automated, but might stop working at some point in the future:
```shell
security create-keychain -p '' login.keychain
security unlock-keychain -p '' login.keychain
security login-keychain -s login.keychain
```
Note that this will create a `login.keychain` with an empty password. Consider supplying a different value to `-p` or omitting the `-p` to enter the password interactively.
## Troubleshooting crashes
If you experience a crash or encounter another error while using the tart executable, you can collect debug information to assist with troubleshooting. Run the following command in a separate terminal window to gather logs from the Tart process and the macOS Virtualization subsystem:
@@ -70,7 +34,7 @@ Then from within a virtual machine you can access the service using the router's
or by running the following command in the Terminal:
```shell
netstat -nr | awk '/default/{print $2; exit}'
netstat -nr | grep default | head -n 1 | awk '{print $2}'
```
Note: that accessing host is only possible with the default NAT network. If you are running your virtual machines with
@@ -179,14 +143,14 @@ This is because Tart uses [Keychain](https://en.wikipedia.org/wiki/Keychain_(sof
To unlock the Keychain in an SSH session, run the following command, which will ask for your user's password:
```shell
security unlock-keychain login.keychain
security unlock-keychain
```
This command also supports the `-p` command-line argument that allows you to supply a password and unlock non-interactively, which is great for scripts.
This command also supports the `-p` command-line argument that allows you to supply the password and unlock non-interactively, which is great for scripts.
Alternatively, you can pass the credentials via the environment variables, see [Registry Authorization](integrations/vm-management.md#registry-authorization) for more details on how to do that.
If that doesn't work for you for some reason, you can pass the credentials via the environment variables, see [Registry Authorization](integrations/vm-management.md#registry-authorization) for more details on how to do that.
## How is Tart different from Anka?
## How Tart is different from Anka?
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
@@ -202,8 +166,6 @@ Tart does have an analogue of Anka Controller for managing VMs across a cluster
In case there's not enough space to fit the newly pulled or cloned VM image, Tart will remove the least recently accessed VMs from OCI cache and `.ipsw` files from IPSW cache until enough free space is available.
The `tart clone` command limits this automatic pruning to 100 GB by default to avoid removing too many cached items. You can change this limit with the `--prune-limit` option (in gigabytes).
To disable this functionality, set the `TART_NO_AUTO_PRUNE` environment variable either globally:
```shell
@@ -215,70 +177,3 @@ export TART_NO_AUTO_PRUNE=
```shell
TART_NO_AUTO_PRUNE= tart pull ...
```
## Disk resizing
Disk resizing works on most cloud-ready Linux distributions out-of-the box (e.g. Ubuntu Cloud Images have the `cloud-initramfs-growroot` package installed that runs on boot) and on the rest of the distributions by running the `growpart` or `resize2fs` commands.
For macOS, however, things are a bit more complicated, and you generally have two options: automated and manual resizing.
For the automated option, you can use [Packer](https://www.packer.io/) with the [Packer builder for Tart VMs](https://developer.hashicorp.com/packer/integrations/cirruslabs/tart/latest/components/builder/tart). The latter has two has configuration directives related to the disk resizing behavior:
* [`disk_size_gb`](https://developer.hashicorp.com/packer/integrations/cirruslabs/tart/latest/components/builder/tart#configuration-reference) — controls the target disk size in gigabytes
* [`recovery_partition`](https://developer.hashicorp.com/packer/integrations/cirruslabs/tart/latest/components/builder/tart#configuration-reference) — controls what to do with the recovery partition when resizing the disk
* you can either keep, delete or relocate it to the end of the disk
For the manual approach, you have to remove the recovery partition first, repair the disk and the resize the APFS container.
To do this, first we'll need to identify the primary disk and the APFS containers by running the command below from within a VM:
```shell
diskutil list physical
```
For example, the output might look like this:
```plain
/dev/disk0 (internal, physical):
#: TYPE NAME SIZE IDENTIFIER
0: GUID_partition_scheme *100.0 GB disk0
1: Apple_APFS_ISC Container disk1 524.3 MB disk0s1
2: Apple_APFS Container disk3 44.1 GB disk0s2
3: Apple_APFS_Recovery Container disk2 5.4 GB disk0s3
(free space) 50.0 GB -
```
In the output, you'll normally see:
* a single physical disk (`disk0`)
* APFS container with the system partition which we're going to resize (`disk0s2`)
* APFS container with the recovery partition which we're going to delete (`disk0s3`)
* `(free space)` which we'll put to use
To proceed, boot the VM in recovery mode using `tart run --recovery` and choose the "Options" item:
![](assets/images/faq/tart-run-recovery-options.png){width="640" .center}
When the recovery OS boots, open the Terminal app:
![](assets/images/faq/tart-run-recovery-terminal.png){width="720" .center}
In Terminal app, invoke the command below to remove the recovery partition:
```shell
diskutil eraseVolume free free disk0s3
```
Now, repair the disk:
```shell
yes | diskutil repairDisk disk0
```
Finally, resize the system APFS container to take all the remaining space:
```shell
diskutil apfs resizeContainer disk0s2 0
```
Now, you can shut down and `tart run` as you'd normally do.
+3 -3
View File
@@ -7,7 +7,7 @@ description: Run pipeline steps in isolated ephemeral Tart Virtual Machines.
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):
![](../assets/images/BuildkiteTartPlugin.png)
![](/assets/images/BuildkiteTartPlugin.png)
## Configuration
@@ -18,9 +18,9 @@ steps:
- command: uname -a
plugins:
- cirruslabs/tart#main:
image: ghcr.io/cirruslabs/macos-sequoia-base:latest
image: ghcr.io/cirruslabs/macos-sonoma-base:latest
```
This will run `uname -r` in a macOS Tart VM cloned from `ghcr.io/cirruslabs/macos-sequoia-base:latest`.
This will run `uname -r` in a macOS Tart VM cloned from `ghcr.io/cirruslabs/macos-sonoma-base:latest`.
See plugin's [Configuration section](https://github.com/cirruslabs/tart-buildkite-plugin#configuration) for the full list of available options.
+3 -3
View File
@@ -18,7 +18,7 @@ task:
name: hello
macos_instance:
# can be a remote or a local virtual machine
image: ghcr.io/cirruslabs/macos-sequoia-base:latest
image: ghcr.io/cirruslabs/macos-sonoma-base:latest
hello_script:
- echo "Hello from within a Tart VM!"
- echo "Here is my CPU info:"
@@ -33,7 +33,7 @@ brew install cirruslabs/cli/cirrus
cirrus run
```
![](../assets/images/TartCirrusCLI.gif)
![](/assets/images/TartCirrusCLI.gif)
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
@@ -50,7 +50,7 @@ exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tas
task:
name: Build
macos_instance:
image: ghcr.io/cirruslabs/macos-sequoia-xcode:latest
image: ghcr.io/cirruslabs/macos-sonoma-xcode:latest
build_script: swift build --product tart
binary_artifacts:
path: .build/debug/tart
+1 -1
View File
@@ -42,7 +42,7 @@ Now you can use Tart Images in your `.gitlab-ci.yml`:
```yaml
# You can use any remote Tart Image.
# Tart Executor will pull it from the registry and use it for creating ephemeral VMs.
image: ghcr.io/cirruslabs/macos-sequoia-base:latest
image: ghcr.io/cirruslabs/macos-sonoma-base:latest
test:
tags:
+4 -4
View File
@@ -16,8 +16,8 @@ Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` fi
use `latest` instead of a path to `*.ipsw` to download the latest available version:
```bash
tart create --from-ipsw=latest sequoia-vanilla
tart run sequoia-vanilla
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:
@@ -72,8 +72,8 @@ packer {
}
source "tart-cli" "tart" {
vm_base_name = "ghcr.io/cirruslabs/macos-sequoia-base:latest"
vm_name = "my-custom-sequoia"
vm_base_name = "ghcr.io/cirruslabs/macos-sonoma-base:latest"
vm_name = "my-custom-sonoma"
cpu_count = 4
memory_gb = 8
disk_size_gb = 70
+7 -9
View File
@@ -1,20 +1,18 @@
## Architecture
Orchard cluster consists of three components:
Orchard cluster consists of two components:
* Controller — responsible for managing the cluster and scheduling of resources
* Controller — responsible for managing the cluster and scheduling of resources
* Worker — responsible for executing the VMs
* Client — responsible for creating, modifying and removing the resources on the Controller, can either be an [Orchard CLI](using-orchard-cli.md) or [an API consumer](integration-guide.md)
* Client — responsible for creating, modifying and removing the resources on the Controller, can either be an Orchard CLI or [an API consumer](/orchard/integration-guide)
At the moment, only one Controller instance is currently supported, while you can deploy one or more Workers and run any number of Clients.
In terms of networking requirements, only Controller needs to be directly accessible from Workers and Clients, while Workers and Clients can be deployed and run anywhere (e.g. behind a NAT).
Normally you deploy a single Controller that needs to be accessible to both the Clients and Workers. Then you can deploy the Workers, which can reside anywhere and be inaccessible to Clients directly, e.g. behind a NAT.
## Security
When an Orchard Client or a Worker connects to the Controller, they need to establish trust and verify that they're talking to the right Controller, so that no [man-in-the-middle attack](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) is possible.
Similarly to web-browsers (that rely on the [public key infrastructure](https://en.wikipedia.org/wiki/Public_key_infrastructure)) and SSH (which relies on semi-automated fingerprint verification), Orchard combines these two traits in a hybrid approach by defaulting to automatic PKI verification (can be disabled by [`--no-pki`](#-no-pki-override)) and falling-back to a manual verification for self-signed certificates.
Similarly to web-browsers (that rely on the [public key infrastructure](https://en.wikipedia.org/wiki/Public_key_infrastructure)) and SSH (which relies on semi-automated fingerprint verification), Orchard combines these two traits in a hybrid approach by defaulting to automatic PKI verification (can be disabled by [`--no-pki`](#--no-pki-override)) and falling-back to a manual verification for self-signed certificates.
This hybrid approach is needed because the Controller can be configured in two ways:
@@ -29,7 +27,7 @@ Below we'll explain how Orchard client and Worker secure the connection when acc
Client is associated with the Controller using a `orchard context create` command, which works as follows:
* Client attempts to connect to the Controller and validate its certificate using host's root CA set (can be disabled with [`--no-pki`](#-no-pki-override))
* Client attempts to connect to the Controller and validate its certificate using host's root CA set (can be disabled with [`--no-pki`](#--no-pki-override))
* if the Client encounters a *Controller with a publicly valid certificate*, that would be the last step and the association would succeed
* if the Client is dealing with *Controller with a self-signed certificate*, the Client will do another connection attempt to probe the Controller's certificate
* the probed Controller's certificate fingerprint is then presented to the user, and if the user agrees to trust it, the Client then considers that certificate to be trusted for a given context
@@ -53,7 +51,7 @@ The way Worker connects to the Controller using the `orchard worker run` command
* when the Bootstrap Token contains the Controller's certificate:
* the Orchard Worker will try to connect to the Controller with a trusted CA set containing only that certificate
* when the Bootstrap Token has no Controller's certificate:
* the Orchard Worker will try the PKI approach (can be disabled with [`--no-pki`](#-no-pki-override) to effectively prevent the Worker from connecting) and fail if certificate verification using PKI is not possible
* the Orchard Worker will try the PKI approach (can be disabled with [`--no-pki`](#--no-pki-override) to effectively prevent the Worker from connecting) and fail if certificate verification using PKI is not possible
### `--no-pki` override
-41
View File
@@ -14,47 +14,6 @@ For example to use a secure, random value:
ORCHARD_BOOTSTRAP_ADMIN_TOKEN=$(openssl rand -hex 32) orchard controller run
```
## Customization
Note that all the [Deployment Methods](#deployment-methods) essentially boil down to starting an `orchard controller run` command and keeping it alive.
This means that by introducing additional command-line arguments, you can customize the Orchard Controller's behavior. Below, we list some of the common scenarios.
### Customizing listening port
* `--listen` — address to listen on (default `:6120`)
### Customizing TLS
* `--controller-cert` — use the controller certificate from the specified path instead of the auto-generated one (requires --controller-key)
* `--controller-key` — use the controller certificate key from the specified path instead of the auto-generated one (requires --controller-cert)
* `--insecure-no-tls` — disable TLS, making all connections to the controller unencrypted
* useful when deploying Orchard Controller behind a load balancer/ingress controller
### Built-in SSH server
Orchard Controller can act as a simple SSH server that port-forwards connections to the VMs running in the Orchard Cluster.
This way you can completely skip the Orchard API when connecting to a given VM and only use the SSH client:
```shell
ssh -J <service account name>@orchard-controller.example.com <VM name>
```
To enable this functionality, pass `--listen-ssh` command-line argument to the `orchard controller run` command, for example:
```ssh
orchard controller run --listen-ssh 6122
```
Here's other command-line arguments associated with this functionality:
* `--ssh-host-key` — use the SSH private host key from the specified path instead of the auto-generated one
* `--insecure-ssh-no-client-auth` — allow SSH clients to connect to the controller's SSH server without authentication, thus only authenticating on the target worker/VM's SSH server
* useful when you already have strong credentials on your VMs, and you want to share these VMs to others without additionally giving out Orchard Cluster credentials
Check out our [Jumping through the hoops: SSH jump host functionality in Orchard](../blog/posts/2024-06-20-jumping-through-the-hoops.md) blog post for more information.
## Deployment Methods
While you can always start `orchard controller run` manually with the required arguments, this method is not recommended due to lack of persistence.
+2 -2
View File
@@ -51,6 +51,8 @@ Then, create a launchd job definition in `/Library/LaunchDaemons/org.cirruslabs.
<dict>
<key>Label</key>
<string>org.cirruslabs.orchard.worker</string>
<key>UserName</key>
<string>admin</string>
<key>Program</key>
<string>/opt/homebrew/bin/orchard</string>
<key>ProgramArguments</key>
@@ -58,8 +60,6 @@ Then, create a launchd job definition in `/Library/LaunchDaemons/org.cirruslabs.
<string>/opt/homebrew/bin/orchard</string>
<string>worker</string>
<string>run</string>
<string>--user</string>
<string>admin</string>
<string>--bootstrap-token</string>
<string>${BOOTSTRAP_TOKEN}</string>
<string>orchard.example.com</string>
+3 -3
View File
@@ -2,7 +2,7 @@ Orchard has a REST API that follows [OpenAPI specification](https://swagger.io/s
You can run `orchard dev` locally and navigate to `http://127.0.0.1:6120/v1/` for interactive documentation.
![](../assets/images/orchard/orchard-api-documentation-browser.png)
![](/assets/images/orchard/orchard-api-documentation-browser.png)
## Using the API
@@ -85,7 +85,7 @@ def main():
# Create VM
response = requests.post("http://127.0.0.1:6120/v1/vms", auth=basic_auth, json={
"name": vm_name,
"image": "ghcr.io/cirruslabs/macos-sequoia-base:latest",
"image": "ghcr.io/cirruslabs/macos-sonoma-base:latest",
"cpu": 4,
"memory": 4096,
"startup_script": {
@@ -144,7 +144,7 @@ func main() {
Meta: v1.Meta{
Name: vmName,
},
Image: "ghcr.io/cirruslabs/macos-sequoia-base:latest",
Image: "ghcr.io/cirruslabs/macos-sonoma-base:latest",
CPU: 4,
Memory: 4096,
StartupScript: &v1.VMScript{
+15 -15
View File
@@ -4,7 +4,7 @@ a couple of VMs is not enough anymore for your needs? This is where [Orchard](ht
comes in to play!
It allows you to orchestrate multiple Tart-capable hosts from either an Orchard CLI (which we demonstrate below)
or [through the API](integration-guide.md).
or [through the API](/orchard/integration-guide).
The easiest way to start is to run Orchard in local development mode:
@@ -18,7 +18,7 @@ test both the CLI functionality and the API from a tool like cURL or programming
authenticate requests.
Note that in production deployments, these two components are started separately and enable security by default. Please
refer to [Deploying Controller](deploying-controller.md) and [Deploying Workers](deploying-workers.md) for
refer to [Deploying Controller](/orchard/deploying-controller) and [Deploying Workers](/orchard/deploying-workers) for
more information.
## Creating Virtual Machines
@@ -26,7 +26,7 @@ more information.
Now, let's create a Virtual Machine:
```shell
orchard create vm --image ghcr.io/cirruslabs/macos-sequoia-base:latest sequoia-base
orchard create vm --image ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
```
You can check a list of VM resources to see if the Virtual Machine we've created above is already running:
@@ -48,7 +48,7 @@ instance. Orchard Controller instance is secured by default and all API calls ar
To SSH into a VM, use the `orchard ssh` command:
```shell
orchard ssh vm sequoia-base
orchard ssh vm sonoma-base
```
You can specify the `--username` and `--password` flags to specify the username/password pair to use for the SSH
@@ -58,14 +58,14 @@ You can also execute remote commands instead of spawning a login shell, similarl
a command argument:
```shell
orchard ssh vm sequoia-base "uname -a"
orchard ssh vm sonoma-base "uname -a"
```
You can execute scripts remotely this way, by telling the remote command-line interpreter to read from the standard
input and using the redirection operator as follows:
```shell
orchard ssh vm sequoia-base "bash -s" < script.sh
orchard ssh vm sonoma-base "bash -s" < script.sh
```
### VNC
@@ -73,7 +73,7 @@ orchard ssh vm sequoia-base "bash -s" < script.sh
Similarly to `ssh` command, you can use `vnc` command to open Screen Sharing into a remote VM:
```shell
orchard vnc vm sequoia-base
orchard vnc vm sonoma-base
```
You can specify the `--username` and `--password` flags to specify the username/password pair to use for the VNC
@@ -84,7 +84,7 @@ protocol. By default, `admin`/`admin` is used.
The following command will delete the VM we've created above and clean-up the resources associated with it:
```shell
orchard delete vm sequoia-base
orchard delete vm sonoma-base
```
## Environment variables
@@ -92,10 +92,10 @@ orchard delete vm sequoia-base
In addition to controlling the Orchard via the CLI arguments, there are environment variables that may be beneficial
both when automating Orchard and in daily use:
| Variable name | Description |
|---------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ORCHARD_HOME` | Override Orchard's home directory. Useful when running multiple Orchard instances on the same host and when testing. |
| `ORCHARD_LICENSE_TIER` | The default license limit only allows connecting 4 Orchard Workers to the Orchard Controller. If you've purchased a [Gold Tier License](../licensing.md), set this variable to `gold` to increase the limit to 20 Orchard Workers. And if you've purchased a [Platinum Tier License](../licensing.md), set this variable to `platinum` to increase the limit to 200 Orchard Workers. |
| `ORCHARD_URL` | Override controller URL on per-command basis. |
| `ORCHARD_SERVICE_ACCOUNT_NAME` | Override service account name (used for controller API auth) on per-command basis. |
| `ORCHARD_SERVICE_ACCOUNT_TOKEN` | Override service account token (used for controller API auth) on per-command basis. |
| Variable name | Description |
|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ORCHARD_HOME` | Override Orchard's home directory. Useful when running multiple Orchard instances on the same host and when testing. |
| `ORCHARD_LICENSE_TIER` | The default license limit only allows connecting 4 Orchard Workers to the Orchard Controller. If you've purchased a [Gold Tier License](/licensing/), set this variable to `gold` to increase the limit to 20 Orchard Workers. And if you've purchased a [Platinum Tier License](/licensing/), set this variable to `platinum` to increase the limit to 200 Orchard Workers. |
| `ORCHARD_URL` | Override controller URL on per-command basis. |
| `ORCHARD_SERVICE_ACCOUNT_NAME` | Override service account name (used for controller API auth) on per-command basis. |
| `ORCHARD_SERVICE_ACCOUNT_TOKEN` | Override service account token (used for controller API auth) on per-command basis. |
-86
View File
@@ -1,86 +0,0 @@
## Installation
The easiest way to install Orchard CLI is through the [Homebrew](https://brew.sh/):
```shell
brew install cirruslabs/cli/orchard
```
Binaries and packages for other architectures can be found in [GitHub Releases](https://github.com/cirruslabs/orchard/releases).
## Setting up a context
The first step after installing the Orchard CLI is to configure its context. Configuring context is like pairing with the specified Orchard Controller, so that the commands like `orchard create vm`, `orchard ssh vm` will work.
To configure a context, `orchard context` has a subfamily of commands:
* `orchard context create <CONTROLLER ADDRESS>` — creates a new context to communicate with Orchard Controller available on the specified address
* `orchard context default <CONTROLLER ADDRESS>` — sets a context with a given Orchard Controller address as default (in case there's more than one context configured)
* `orchard context list` — lists all the configured contexts, indicating the default one
* `orchard context delete <CONTROLLER ADDRESS>` — deletes a context for the specified Orchard Controller address
Most of the time, you'll only need the `orchard context create`. For example, if you've deployed your Orchard Controller to `orchard-controller.example.com`, a new context can be configured like so:
```shell
orchard context create orchard-controller.example.com
```
`orchard context create` assumes port 6120 by default, so if you use a different port for the Orchard Controller, simply specify the port explicitly:
```shell
orchard context create orchard-controller.example.com:8080
```
When creating a new context you will be prompted for the service account name and token, which can be obtained from:
* `orchard controller run` logs
* if this is a first start
* `orchard get service-account`
* from an already configured Orchard CLI
## Using labels when creating VMs
Labels are useful if you want to restrict scheduling of a VM to workers whose labels include a subset of the VM's specified labels.
For example, you might have an Orchard Cluster consisting of the following workers:
* Mac Minis (`orchard worker run --labels location=DC1-R12-S4,model=macmini`)
* Mac Studios (`orchard worker run --labels location=DC1-R18-S8,model=macstudio`)
To create and run a VM specifically on Mac Studio machines, pass the `--labels` command-line argument to `orchard create vm` when creating a VM:
```shell
orchard create vm --labels model=macstudio <NAME>
```
When processing this VM, the scheduler will only place it on available Mac Studio workers.
## Using resources when creating VMs
Resources are useful if you want to restrict scheduling of a VM to workers that still have enough of the specified resource to fit the VM's requirements.
The difference between the labels is that the resources are finite and are automatically accounted by the scheduler.
To illustrate this with an example, let's say you have an Orchard Cluster consisting of the following workers:
* Mac Mini with 1 Gbps bandwidth (`orchard worker run --resources bandwidth-mbps=1000`)
* Mac Studio with 10 Gbps bandwidth (`orchard worker run --resources bandwidth-mbps=10000`)
VM created using the command below will only be scheduled on a Mac Studio with 10 Gbps bandwidth:
```shell
orchard create vm --resources bandwidth-mbps=7500 <NAME>
```
However, after this VM is scheduled, the 10 Gbps Mac Studio will only be able to accommodate one more VM (due to internal Apple EULA limit for macOS virtualization) with `bandwidth-mbps=2500` or less.
After the VM finishes, the unused resources will be available again.
## Automatic resources
In addition to manually specifying resources when starting a worker, the following resources are discovered and set automatically by the worker for convenience:
* `org.cirruslabs.logical-cores` — number of logical cores on the host
* `org.cirruslabs.memory-mib` — total memory in MiB (mebibytes) on the host
Note that the values for these resources are scraped only once at worker startup.
+7 -11
View File
@@ -9,8 +9,8 @@ Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura)
```bash
brew install cirruslabs/cli/tart
tart clone ghcr.io/cirruslabs/macos-sequoia-base:latest sequoia-base
tart run sequoia-base
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
tart run sonoma-base
```
??? info "Manual installation from a release archive"
@@ -19,8 +19,8 @@ tart run sequoia-base
```bash
curl -LO https://github.com/cirruslabs/tart/releases/latest/download/tart.tar.gz
tar -xzvf tart.tar.gz
./tart.app/Contents/MacOS/tart clone ghcr.io/cirruslabs/macos-sequoia-base:latest sequoia-base
./tart.app/Contents/MacOS/tart run sequoia-base
./tart.app/Contents/MacOS/tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
./tart.app/Contents/MacOS/tart run sonoma-base
```
Please note that `./tart.app/Contents/MacOS/tart` binary is required to be used in order to trick macOS
@@ -34,10 +34,6 @@ tart run sequoia-base
The following macOS images are currently available:
* macOS 15 (Sequoia)
* `ghcr.io/cirruslabs/macos-sequoia-vanilla:latest`
* `ghcr.io/cirruslabs/macos-sequoia-base:latest`
* `ghcr.io/cirruslabs/macos-sequoia-xcode:latest`
* macOS 14 (Sonoma)
* `ghcr.io/cirruslabs/macos-sonoma-vanilla:latest`
* `ghcr.io/cirruslabs/macos-sonoma-base:latest`
@@ -86,7 +82,7 @@ These credentials work both for logging in via GUI, console (Linux) and SSH.
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
```bash
ssh admin@$(tart ip sequoia-base)
ssh admin@$(tart ip sonoma-base)
```
!!! tip "Running scripts inside Tart virtual machines"
@@ -95,8 +91,8 @@ ssh admin@$(tart ip sequoia-base)
```bash
brew install cirruslabs/cli/sshpass
sshpass -p admin ssh -o "StrictHostKeyChecking no" -o "UserKnownHostsFile=/dev/null" admin@$(tart ip sequoia-base) "uname -a"
sshpass -p admin ssh -o "StrictHostKeyChecking no" -o "UserKnownHostsFile=/dev/null" admin@$(tart ip sequoia-base) < script.sh
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sonoma-base) "uname -a"
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sonoma-base) < script.sh
```
## Mounting directories
-24
View File
@@ -1,24 +0,0 @@
module integration
go 1.25
require (
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/proto/otlp v1.9.0
google.golang.org/protobuf v1.36.11
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
google.golang.org/grpc v1.78.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-59
View File
@@ -1,59 +0,0 @@
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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/grpc-ecosystem/grpc-gateway/v2 v2.27.5 h1:jP1RStw811EvUDzsUQ9oESqw2e4RqCjSAD9qIL8eMns=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5/go.mod h1:WXNBZ64q3+ZUemCMXD9kYnr56H7CgZxDBHCVwstfl3s=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-109
View File
@@ -1,109 +0,0 @@
package integration_test
import (
"compress/gzip"
"encoding/hex"
"integration/tart"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/require"
semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
tracepkg "go.opentelemetry.io/proto/otlp/collector/trace/v1"
v1 "go.opentelemetry.io/proto/otlp/common/v1"
"google.golang.org/protobuf/proto"
)
func TestOpenTelemetry(t *testing.T) {
// Start a mock OpenTelemetry collector server
var traces []*tracepkg.ExportTraceServiceRequest
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
var trace tracepkg.ExportTraceServiceRequest
reader := request.Body
var err error
if request.Header.Get("Content-Encoding") == "gzip" {
reader, err = gzip.NewReader(reader)
require.NoError(t, err)
}
requestBytes, err := io.ReadAll(reader)
require.NoError(t, err)
switch request.Header.Get("Content-Type") {
case "application/x-protobuf":
require.NoError(t, proto.Unmarshal(requestBytes, &trace))
default:
require.FailNowf(t, "unsupported content type",
"we do not support %q yet", request.Header.Get("Content-Type"))
}
traces = append(traces, &trace)
var response tracepkg.ExportTraceServiceResponse
responseBytes, err := proto.Marshal(&response)
require.NoError(t, err)
writer.Header().Set("Content-Type", "application/x-protobuf")
_, err = writer.Write(responseBytes)
require.NoError(t, err)
}))
// Start a "tart list" command
serverURL, err := url.Parse(server.URL)
require.NoError(t, err)
t.Setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", serverURL.JoinPath("v1/traces").String())
t.Setenv("CIRRUS_SENTRY_TAGS", "A=B,C=D")
t.Setenv("TRACEPARENT", "00-00000000000000000000000000000001-0000000000000001-01")
_, _, err = tart.Tart(t, "list")
require.NoError(t, err)
// Ensure that the mock OpenTelemetry collector received a trace from "tart list"
require.Len(t, traces, 1)
resourceSpans := traces[0].GetResourceSpans()
require.Len(t, resourceSpans, 1)
// Ensure that service name and version resources are set
resourceSpan := resourceSpans[0]
stringAttributes := stringAttributesToMap(resourceSpan.GetResource().GetAttributes())
require.Equal(t, "tart", stringAttributes[string(semconv.ServiceNameKey)])
require.Equal(t, "SNAPSHOT", stringAttributes[string(semconv.ServiceVersionKey)])
scopeSpans := resourceSpan.GetScopeSpans()
require.Len(t, scopeSpans, 1)
spans := scopeSpans[0].GetSpans()
require.Len(t, spans, 1)
// Ensure that the root span is correctly named
span := spans[0]
require.Equal(t, "list", span.Name)
// Ensure that CIRRUS_SENTRY_TAGS are propagated
stringAttributes = stringAttributesToMap(span.GetAttributes())
require.Equal(t, stringAttributes["A"], "B")
require.Equal(t, stringAttributes["C"], "D")
// Ensure that W3C Trace Context is propagated
require.Equal(t, "00000000000000000000000000000001", hex.EncodeToString(span.GetTraceId()))
require.Equal(t, "0000000000000001", hex.EncodeToString(span.GetParentSpanId()))
}
func stringAttributesToMap(attributes []*v1.KeyValue) map[string]string {
result := map[string]string{}
for _, attribute := range attributes {
result[attribute.GetKey()] = attribute.GetValue().GetStringValue()
}
return result
}
+1 -1
View File
@@ -1,6 +1,6 @@
pytest
testcontainers
requests
requests == 2.31.0 # work around https://github.com/psf/requests/issues/6707
bitmath
pytest-dependency
paramiko
-56
View File
@@ -1,56 +0,0 @@
package tart
import (
"bytes"
"errors"
"fmt"
"os/exec"
"strings"
"testing"
)
const tartCommandName = "tart"
var (
ErrTartNotFound = errors.New("tart command not found")
ErrTartFailed = errors.New("tart command returned non-zero exit code")
)
func Tart(t *testing.T, args ...string) (string, string, error) {
t.Helper()
cmd := exec.CommandContext(t.Context(), tartCommandName, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
return "", "", fmt.Errorf("%w: %s command not found in PATH, make sure Tart is installed",
ErrTartNotFound, tartCommandName)
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
// Tart command failed, redefine the error to be the Tart-specific output
err = fmt.Errorf("%w: %q", ErrTartFailed, firstNonEmptyLine(stderr.String(), stdout.String()))
}
}
return stdout.String(), stderr.String(), err
}
func firstNonEmptyLine(outputs ...string) string {
for _, output := range outputs {
for _, line := range strings.Split(output, "\n") {
if line != "" {
return line
}
}
}
return ""
}
+1 -1
View File
@@ -9,7 +9,7 @@ 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-tahoe-base:latest", vm_name])
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] + run_opts)
-2
View File
@@ -46,7 +46,6 @@ plugins:
match_path: blog/posts/.*
date_from_meta:
as_creation: date
abstract_chars_count: -1
- social:
cards_layout_dir: docs/layouts
cards_layout: custom
@@ -103,7 +102,6 @@ nav:
- "Architecture and Security": orchard/architecture-and-security.md
- "Deploying Controller": orchard/deploying-controller.md
- "Deploying Workers": orchard/deploying-workers.md
- "Using Orchard CLI": orchard/using-orchard-cli.md
- "Managing the Cluster": orchard/managing-cluster.md
- "Integrating with the API": orchard/integration-guide.md
- "FAQ": faq.md
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/sh
# helper script to build and run a signed tart binary
# usage: ./scripts/run-signed.sh run sequoia-base
# usage: ./scripts/run-signed.sh run sonoma-base
set -e