mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-01 19:51:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
985db24474 | ||
|
|
1d01bf63fb | ||
|
|
3ff3850da2 | ||
|
|
c6e8d0bfd7 | ||
|
|
755aad4d7c | ||
|
|
9f38441a42 | ||
|
|
3d46c4e6c2 | ||
|
|
e59221f6a0 | ||
|
|
c6e99345cd | ||
|
|
8bc2e99f63 | ||
|
|
f36f86b61c | ||
|
|
79084555f6 | ||
|
|
896d03ce0b | ||
|
|
99c91cbf87 | ||
|
|
da8afa1348 | ||
|
|
97b7ffef52 | ||
|
|
13e7794bfc | ||
|
|
b7b3b702ac | ||
|
|
560dba79e4 | ||
|
|
2b33b8f9e6 | ||
|
|
d8b010c79c | ||
|
|
5cd83c38cd | ||
|
|
1a3b862631 | ||
|
|
ae2d59e5c2 | ||
|
|
7eac45702b | ||
|
|
e06d89f95d | ||
|
|
0602d6e0e1 | ||
|
|
ac5d0baa0c | ||
|
|
ee27cc57bb |
+1
-1
@@ -1,7 +1,7 @@
|
||||
use_compute_credits: true
|
||||
|
||||
env:
|
||||
XCODE_TAG: 15.2
|
||||
XCODE_TAG: latest
|
||||
|
||||
task:
|
||||
name: Test on Sonoma
|
||||
|
||||
@@ -26,6 +26,12 @@ archives:
|
||||
- src: Resources/embedded.provisionprofile
|
||||
dst: tart.app/Contents
|
||||
strip_parent: true
|
||||
- src: Resources/Info.plist
|
||||
dst: tart.app/Contents
|
||||
strip_parent: true
|
||||
- src: Resources/AppIcon.png
|
||||
dst: tart.app/Contents/Resources
|
||||
strip_parent: true
|
||||
- LICENSE
|
||||
|
||||
release:
|
||||
@@ -46,5 +52,6 @@ brews:
|
||||
install: |
|
||||
libexec.install Dir["*"]
|
||||
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
|
||||
generate_completions_from_executable(libexec/"tart.app/Contents/MacOS/tart", "--generate-completion-script")
|
||||
custom_block: |
|
||||
depends_on :macos => :ventura
|
||||
|
||||
+43
-15
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"originHash" : "6d48639bc0ea02002de0b4f38fe3fce0ddc9d174f2e56180c2ffcbedb7391ef8",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "antlr4",
|
||||
@@ -18,13 +19,22 @@
|
||||
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "semaphore",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/groue/Semaphore",
|
||||
"state" : {
|
||||
"revision" : "f1c4a0acabeb591068dea6cffdd39660b86dec28",
|
||||
"version" : "0.0.8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sentry-cocoa",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "d277532e1c8af813981ba01f591b15bbdd735615",
|
||||
"version" : "8.8.0"
|
||||
"revision" : "ef4fec9dfb8dd5027b09a4a5c9362feafd118e1a",
|
||||
"version" : "8.24.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -32,8 +42,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-algorithms",
|
||||
"state" : {
|
||||
"revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
|
||||
"version" : "1.0.0"
|
||||
"revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42",
|
||||
"version" : "1.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -41,8 +51,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "f3c9084a71ef4376f2fabbdf1d3d90a49f1fabdb",
|
||||
"version" : "1.1.2"
|
||||
"revision" : "46989693916f56d1186bd59ac15124caef896560",
|
||||
"version" : "1.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -59,8 +69,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
|
||||
"version" : "1.0.2"
|
||||
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
|
||||
"version" : "1.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -72,6 +82,15 @@
|
||||
"version" : "1.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5",
|
||||
"version" : "1.5.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-numerics",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -81,13 +100,22 @@
|
||||
"version" : "1.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-retry",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/fumoboy007/swift-retry",
|
||||
"state" : {
|
||||
"revision" : "9f133487ffc2ab4539688c29efe57bb1ba31d7b0",
|
||||
"version" : "0.2.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-sysctl",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sersoft-gmbh/swift-sysctl.git",
|
||||
"state" : {
|
||||
"revision" : "71fd64ee84819bb19fbecfb36d5a4503726b6fb7",
|
||||
"version" : "1.6.0"
|
||||
"revision" : "a91be36de6803ebe48f678699dfd0694c2200d2f",
|
||||
"version" : "1.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -95,8 +123,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/malcommac/SwiftDate",
|
||||
"state" : {
|
||||
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
||||
"version" : "6.3.1"
|
||||
"revision" : "5d943224c3bb173e6ecf27295611615eba90c80e",
|
||||
"version" : "7.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -104,8 +132,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/nicklockwood/SwiftFormat",
|
||||
"state" : {
|
||||
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
|
||||
"version" : "0.50.6"
|
||||
"revision" : "9df3b01f477163b33d5e63c5e2e5b9f946a49c56",
|
||||
"version" : "0.53.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -127,5 +155,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
"version" : 3
|
||||
}
|
||||
|
||||
+13
-9
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version:5.7
|
||||
// swift-tools-version:5.10
|
||||
|
||||
import PackageDescription
|
||||
let package = Package(
|
||||
@@ -10,18 +10,20 @@ let package = Package(
|
||||
.executable(name: "tart", targets: ["tart"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"),
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"),
|
||||
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
|
||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
|
||||
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
|
||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
|
||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
|
||||
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.8.0"),
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
|
||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.24.0"),
|
||||
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.0.0"),
|
||||
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.0")
|
||||
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"),
|
||||
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"),
|
||||
.package(url: "https://github.com/groue/Semaphore", from: "0.0.8"),
|
||||
.package(url: "https://github.com/fumoboy007/swift-retry", from: "0.2.3"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
@@ -36,6 +38,8 @@ let package = Package(
|
||||
.product(name: "TextTable", package: "TextTable"),
|
||||
.product(name: "Sysctl", package: "swift-sysctl"),
|
||||
.product(name: "SwiftRadix", package: "SwiftRadix"),
|
||||
.product(name: "Semaphore", package: "Semaphore"),
|
||||
.product(name: "DMRetry", package: "swift-retry"),
|
||||
], exclude: [
|
||||
"OCI/Reference/Makefile",
|
||||
"OCI/Reference/Reference.g4",
|
||||
|
||||
@@ -14,5 +14,10 @@
|
||||
<array>
|
||||
<string>AppIcon.png</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -18,7 +18,7 @@ struct Clone: AsyncParsableCommand {
|
||||
"""
|
||||
)
|
||||
|
||||
@Argument(help: "source VM name")
|
||||
@Argument(help: "source VM name", completion: .custom(completeMachines))
|
||||
var sourceName: String
|
||||
|
||||
@Argument(help: "new VM name")
|
||||
@@ -63,7 +63,7 @@ struct Clone: AsyncParsableCommand {
|
||||
try lock.lock()
|
||||
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
&& sourceVM.state() != "suspended"
|
||||
&& sourceVM.state() != .Suspended
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
|
||||
@@ -5,7 +5,7 @@ import SwiftUI
|
||||
struct Delete: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Delete a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeMachines))
|
||||
var name: [String]
|
||||
|
||||
func run() async throws {
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
struct Export: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Export VM to a compressed .tvm file")
|
||||
|
||||
@Argument(help: "Source VM name.")
|
||||
@Argument(help: "Source VM name.", completion: .custom(completeMachines))
|
||||
var name: String
|
||||
|
||||
@Argument(help: "Path to the destination file.")
|
||||
|
||||
@@ -5,7 +5,7 @@ import SystemConfiguration
|
||||
struct FQN: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Get a fully-qualified VM name", shouldDisplay: false)
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeMachines))
|
||||
var name: String
|
||||
|
||||
func run() async throws {
|
||||
|
||||
@@ -2,6 +2,7 @@ import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
fileprivate struct VMInfo: Encodable {
|
||||
let OS: OS
|
||||
let CPU: Int
|
||||
let Memory: UInt64
|
||||
let Disk: Int
|
||||
@@ -14,7 +15,7 @@ fileprivate struct VMInfo: Encodable {
|
||||
struct Get: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
||||
|
||||
@Argument(help: "VM name.")
|
||||
@Argument(help: "VM name.", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
@Option(help: "Output format: text or json")
|
||||
@@ -25,8 +26,7 @@ struct Get: AsyncParsableCommand {
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||
|
||||
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000),
|
||||
Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state())
|
||||
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
|
||||
print(format.renderSingle(info))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
||||
struct IP: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
@Option(help: "Number of seconds to wait for a potential VM booting")
|
||||
@@ -61,7 +61,7 @@ struct IP: AsyncParsableCommand {
|
||||
return ip
|
||||
}
|
||||
case .dhcp:
|
||||
if let leases = try Leases(), let ip = try leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,13 +38,13 @@ struct List: AsyncParsableCommand {
|
||||
|
||||
if source == nil || source == "local" {
|
||||
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
||||
try VMInfo(Source: "local", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
try VMInfo(Source: "local", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||
})
|
||||
}
|
||||
|
||||
if source == nil || source == "oci" {
|
||||
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
||||
try VMInfo(Source: "oci", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
try VMInfo(Source: "oci", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import Compression
|
||||
struct Push: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
|
||||
|
||||
@Argument(help: "local or remote VM name")
|
||||
@Argument(help: "local or remote VM name", completion: .custom(completeMachines))
|
||||
var localName: String
|
||||
|
||||
@Argument(help: "remote VM name(s)")
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
struct Rename: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Rename a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
@Argument(help: "new VM name")
|
||||
|
||||
+163
-107
@@ -5,6 +5,7 @@ import Dispatch
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
import Sentry
|
||||
import System
|
||||
|
||||
var vm: VM?
|
||||
|
||||
@@ -14,7 +15,7 @@ struct IPNotFound: Error {
|
||||
struct Run: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Run a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
@@ -33,12 +34,17 @@ struct Run: AsyncParsableCommand {
|
||||
))
|
||||
var serialPath: String?
|
||||
|
||||
@Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .hidden))
|
||||
@Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .private))
|
||||
var graphics: Bool = false
|
||||
|
||||
@Flag(help: "Disable audio pass-through to host.")
|
||||
var noAudio: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Disable clipboard sharing between host and guest.",
|
||||
discussion: "Only works with Linux-based guest operating systems."))
|
||||
var noClipboard: Bool = false
|
||||
|
||||
#if arch(arm64)
|
||||
@Flag(help: "Boot into recovery mode")
|
||||
#endif
|
||||
@@ -54,22 +60,23 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
#if arch(arm64)
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Use Virtualization.Framework's VNC server instead of the build-in UI.",
|
||||
"Use Virtualization.Framework's VNC server instead of the built-in UI.",
|
||||
discussion: "Useful since this type of VNC is available in recovery mode and in macOS installation.\n"
|
||||
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
||||
#endif
|
||||
var vncExperimental: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\" --disk=\"/dev/disk0\")
|
||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\" --disk=\"/dev/disk0\" --disk=\"nbd://localhost:10809/myDisk\")
|
||||
""", discussion: """
|
||||
Can be either a disk image file or a block device like a local SSD on AWS EC2 Mac instances.
|
||||
Can be either a disk image file, a block device like a local SSD on AWS EC2 Mac instances or a Network Block Device (NBD).
|
||||
|
||||
Learn how to create a disk image using Disk Utility here:
|
||||
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
|
||||
|
||||
To work with block devices 'tart' binary must be executed as root which affects locating Tart VMs.
|
||||
To workaround this issue pass TART_HOME explicitly:
|
||||
To work with block devices, the easiest way is to modify their permissions (e.g. by using "sudo chown $USER /dev/diskX") or to run the Tart binary as root, which affects locating Tart VMs.
|
||||
|
||||
To work around this pass TART_HOME explicitly:
|
||||
|
||||
sudo TART_HOME="$HOME/.tart" tart run sonoma --disk=/dev/disk0
|
||||
""", valueName: "path[:ro]"))
|
||||
@@ -117,6 +124,9 @@ struct Run: AsyncParsableCommand {
|
||||
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
|
||||
var netSoftnet: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Comma-separated list of CIDRs to allow the traffic to when using Softnet isolation\n(e.g. --net-softnet-allow=192.168.0.0/24)", valueName: "comma-separated CIDRs"))
|
||||
var netSoftnetAllow: String?
|
||||
|
||||
@Flag(help: ArgumentHelp("Restrict network access to the host-only network"))
|
||||
var netHost: Bool = false
|
||||
|
||||
@@ -146,8 +156,8 @@ struct Run: AsyncParsableCommand {
|
||||
throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive")
|
||||
}
|
||||
|
||||
if graphics {
|
||||
print("--graphics is deprecated and will be removed in the future.\n")
|
||||
if graphics && noGraphics {
|
||||
throw ValidationError("--graphics and --no-graphics are mutually exclusive")
|
||||
}
|
||||
|
||||
if (noGraphics || vnc || vncExperimental) && captureSystemKeys {
|
||||
@@ -156,11 +166,15 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
let localStorage = VMStorageLocal()
|
||||
let vmDir = try localStorage.open(name)
|
||||
if try vmDir.state() == "suspended" {
|
||||
if try vmDir.state() == .Suspended {
|
||||
suspendable = true
|
||||
}
|
||||
|
||||
if suspendable {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
if !(config.platform is PlatformSuspendable) {
|
||||
throw ValidationError("You can only suspend macOS VMs")
|
||||
}
|
||||
if dir.count > 0 {
|
||||
throw ValidationError("Suspending VMs with shared directories is not supported")
|
||||
}
|
||||
@@ -179,18 +193,16 @@ struct Run: AsyncParsableCommand {
|
||||
let vmDir = try localStorage.open(name)
|
||||
|
||||
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
if try vmDir.state() == "suspended" {
|
||||
try storageLock.lock() // lock before checking
|
||||
let needToGenerateNewMac = try localStorage.list().contains {
|
||||
// check if there is a running VM with the same MAC but different name
|
||||
try $1.running() && $1.macAddress() == vmDir.macAddress() && $1.name != vmDir.name
|
||||
}
|
||||
|
||||
if needToGenerateNewMac {
|
||||
print("There is already a running VM with the same MAC address!")
|
||||
print("Resetting VM to assign a new MAC address...")
|
||||
try vmDir.regenerateMACAddress()
|
||||
}
|
||||
try storageLock.lock()
|
||||
// check if there is a running VM with the same MAC address
|
||||
let hasRunningMACCollision = try localStorage.list().contains {
|
||||
// check if there is a running VM with the same MAC but different name
|
||||
try $1.running() && $1.macAddress() == vmDir.macAddress() && $1.name != vmDir.name
|
||||
}
|
||||
if hasRunningMACCollision {
|
||||
print("There is already a running VM with the same MAC address!")
|
||||
print("Resetting VM to assign a new MAC address...")
|
||||
try vmDir.regenerateMACAddress()
|
||||
}
|
||||
|
||||
if (netSoftnet || netHost) && isInteractiveSession() {
|
||||
@@ -224,7 +236,8 @@ struct Run: AsyncParsableCommand {
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable,
|
||||
audio: !noAudio
|
||||
audio: !noAudio,
|
||||
clipboard: !noClipboard
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
@@ -277,7 +290,7 @@ struct Run: AsyncParsableCommand {
|
||||
try await vm!.start(recovery: recovery, resume: resume)
|
||||
|
||||
if let vncImpl = vncImpl {
|
||||
let vncURL = try await vncImpl.waitForURL()
|
||||
let vncURL = try await vncImpl.waitForURL(netBridged: !netBridged.isEmpty)
|
||||
|
||||
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
|
||||
print("VNC server is running at \(vncURL)")
|
||||
@@ -346,10 +359,11 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
sigusr1Src.activate()
|
||||
|
||||
if noGraphics {
|
||||
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||
if noGraphics || useVNCWithoutGraphics {
|
||||
// enter the main even loop, without bringing up any UI,
|
||||
// and just wait for the VM to exit.
|
||||
NSApp.run()
|
||||
NSApplication.shared.run()
|
||||
} else {
|
||||
runUI(suspendable, captureSystemKeys)
|
||||
}
|
||||
@@ -372,7 +386,14 @@ struct Run: AsyncParsableCommand {
|
||||
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
|
||||
if netSoftnet {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
return try Softnet(vmMACAddress: config.macAddress.string)
|
||||
|
||||
var extraArguments: [String] = []
|
||||
|
||||
if let netSoftnetAllow = netSoftnetAllow {
|
||||
extraArguments += ["--allow", netSoftnetAllow]
|
||||
}
|
||||
|
||||
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: extraArguments)
|
||||
}
|
||||
|
||||
if netHost {
|
||||
@@ -418,36 +439,61 @@ struct Run: AsyncParsableCommand {
|
||||
for rawDisk in expandedDiskPaths {
|
||||
let diskReadOnly = rawDisk.hasSuffix(readOnlySuffix)
|
||||
let diskPath = diskReadOnly ? String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count)) : rawDisk
|
||||
let diskURL = URL(fileURLWithPath: diskPath)
|
||||
|
||||
// check if `diskPath` is a block device or a directory
|
||||
if pathHasMode(diskPath, mode: S_IFBLK) || pathHasMode(diskPath, mode: S_IFDIR) {
|
||||
print("Using block device\n")
|
||||
let diskURL = URL(string: diskPath)
|
||||
if (["nbd", "nbds", "nbd+unix", "nbds+unix"].contains(diskURL?.scheme)) {
|
||||
guard #available(macOS 14, *) else {
|
||||
throw UnsupportedOSError("attaching Network Block Devices", "are")
|
||||
}
|
||||
|
||||
let nbdAttachment = try VZNetworkBlockDeviceStorageDeviceAttachment(
|
||||
url: diskURL!,
|
||||
timeout: 30,
|
||||
isForcedReadOnly: diskReadOnly,
|
||||
synchronizationMode: VZDiskSynchronizationMode.none
|
||||
)
|
||||
result.append(VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment))
|
||||
continue
|
||||
}
|
||||
|
||||
let diskFileURL = URL(fileURLWithPath: diskPath)
|
||||
|
||||
if pathHasMode(diskPath, mode: S_IFBLK) {
|
||||
guard #available(macOS 14, *) else {
|
||||
throw UnsupportedOSError("attaching block devices", "are")
|
||||
}
|
||||
let fileHandle = FileHandle(forUpdatingAtPath: diskPath)
|
||||
guard fileHandle != nil else {
|
||||
if ProcessInfo.processInfo.userName != "root" {
|
||||
throw RuntimeError.VMConfigurationError("need to run as root to work with block devices")
|
||||
|
||||
let fd = open(diskPath, diskReadOnly ? O_RDONLY : O_RDWR)
|
||||
if fd == -1 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
switch details.rawValue {
|
||||
case EBUSY:
|
||||
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "already in use, try umounting it via \"diskutil unmountDisk\" (when the whole disk) or \"diskutil umount\" (when mounting a single partition)")
|
||||
case EACCES:
|
||||
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "permission denied, consider changing the disk's owner using \"sudo chown $USER \(diskFileURL.url.path)\" or run Tart as a superuser (see --disk help for more details on how to do that correctly)")
|
||||
default:
|
||||
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "\(details)")
|
||||
}
|
||||
throw RuntimeError.VMConfigurationError("block device \(diskURL.url.path) seems to be already in use, unmount it first via 'diskutil unmount'")
|
||||
}
|
||||
let attachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: fileHandle!, readOnly: diskReadOnly, synchronizationMode: .full)
|
||||
result.append(VZVirtioBlockDeviceConfiguration(attachment: attachment))
|
||||
} else {
|
||||
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
|
||||
// see https://github.com/cirruslabs/tart/issues/323 for more details.
|
||||
if try !diskReadOnly && !FileLock(lockURL: diskURL).trylock() {
|
||||
throw RuntimeError.DiskAlreadyInUse("disk \(diskURL.url.path) seems to be already in use, unmount it first in Finder")
|
||||
}
|
||||
|
||||
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(
|
||||
url: diskURL,
|
||||
readOnly: diskReadOnly
|
||||
)
|
||||
result.append(VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment))
|
||||
let blockAttachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: FileHandle(fileDescriptor: fd, closeOnDealloc: true),
|
||||
readOnly: diskReadOnly, synchronizationMode: .full)
|
||||
result.append(VZVirtioBlockDeviceConfiguration(attachment: blockAttachment))
|
||||
continue
|
||||
}
|
||||
|
||||
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
|
||||
// see https://github.com/cirruslabs/tart/issues/323 for more details.
|
||||
if try !diskReadOnly && !FileLock(lockURL: diskFileURL).trylock() {
|
||||
throw RuntimeError.DiskAlreadyInUse("disk \(diskFileURL.url.path) seems to be already in use, unmount it first in Finder")
|
||||
}
|
||||
|
||||
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(
|
||||
url: diskFileURL,
|
||||
readOnly: diskReadOnly
|
||||
)
|
||||
result.append(VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment))
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -523,71 +569,69 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool) {
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
MainApp.suspendable = suspendable
|
||||
MainApp.capturesSystemKeys = captureSystemKeys
|
||||
MainApp.main()
|
||||
}
|
||||
}
|
||||
|
||||
struct MainApp: App {
|
||||
static var disappearSignal: Int32 = SIGINT
|
||||
static var capturesSystemKeys: Bool = false
|
||||
struct MainApp: App {
|
||||
static var suspendable: Bool = false
|
||||
static var capturesSystemKeys: Bool = false
|
||||
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
Group {
|
||||
VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}.onDisappear {
|
||||
let ret = kill(getpid(), MainApp.disappearSignal)
|
||||
if ret != 0 {
|
||||
// Fallback to the old termination method that doesn't
|
||||
// propagate the cancellation to Task's in case graceful
|
||||
// termination via kill(2) is not successful
|
||||
NSApplication.shared.terminate(self)
|
||||
}
|
||||
}
|
||||
}.frame(
|
||||
minWidth: CGFloat(vm!.config.display.width),
|
||||
idealWidth: CGFloat(vm!.config.display.width),
|
||||
maxWidth: .infinity,
|
||||
minHeight: CGFloat(vm!.config.display.height),
|
||||
idealHeight: CGFloat(vm!.config.display.height),
|
||||
maxHeight: .infinity
|
||||
)
|
||||
}.commands {
|
||||
// Remove some standard menu options
|
||||
CommandGroup(replacing: .help, addition: {})
|
||||
CommandGroup(replacing: .newItem, addition: {})
|
||||
CommandGroup(replacing: .pasteboard, addition: {})
|
||||
CommandGroup(replacing: .textEditing, addition: {})
|
||||
CommandGroup(replacing: .undoRedo, addition: {})
|
||||
CommandGroup(replacing: .windowSize, addition: {})
|
||||
// Replace some standard menu options
|
||||
CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) }
|
||||
CommandMenu("Control") {
|
||||
Button("Start") {
|
||||
Task { try await vm!.virtualMachine.start() }
|
||||
}
|
||||
Button("Stop") {
|
||||
Task { try await vm!.virtualMachine.stop() }
|
||||
}
|
||||
Button("Request Stop") {
|
||||
Task { try vm!.virtualMachine.requestStop() }
|
||||
}
|
||||
if #available(macOS 14, *) {
|
||||
Button("Suspend") {
|
||||
kill(getpid(), SIGUSR1)
|
||||
}
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
Group {
|
||||
VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}.onDisappear {
|
||||
let ret = kill(getpid(), MainApp.suspendable ? SIGUSR1 : SIGINT)
|
||||
if ret != 0 {
|
||||
// Fallback to the old termination method that doesn't
|
||||
// propagate the cancellation to Task's in case graceful
|
||||
// termination via kill(2) is not successful
|
||||
NSApplication.shared.terminate(self)
|
||||
}
|
||||
}
|
||||
}.frame(
|
||||
minWidth: CGFloat(vm!.config.display.width),
|
||||
idealWidth: CGFloat(vm!.config.display.width),
|
||||
maxWidth: .infinity,
|
||||
minHeight: CGFloat(vm!.config.display.height),
|
||||
idealHeight: CGFloat(vm!.config.display.height),
|
||||
maxHeight: .infinity
|
||||
)
|
||||
}.commands {
|
||||
// Remove some standard menu options
|
||||
CommandGroup(replacing: .help, addition: {})
|
||||
CommandGroup(replacing: .newItem, addition: {})
|
||||
CommandGroup(replacing: .pasteboard, addition: {})
|
||||
CommandGroup(replacing: .textEditing, addition: {})
|
||||
CommandGroup(replacing: .undoRedo, addition: {})
|
||||
CommandGroup(replacing: .windowSize, addition: {})
|
||||
// Replace some standard menu options
|
||||
CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) }
|
||||
CommandMenu("Control") {
|
||||
Button("Start") {
|
||||
Task { try await vm!.virtualMachine.start() }
|
||||
}
|
||||
Button("Stop") {
|
||||
Task { try await vm!.virtualMachine.stop() }
|
||||
}
|
||||
Button("Request Stop") {
|
||||
Task { try vm!.virtualMachine.requestStop() }
|
||||
}
|
||||
if #available(macOS 14, *) {
|
||||
if (MainApp.suspendable) {
|
||||
Button("Suspend") {
|
||||
kill(getpid(), SIGUSR1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
|
||||
MainApp.capturesSystemKeys = captureSystemKeys
|
||||
MainApp.main()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,6 +641,18 @@ class MinimalMenuAppDelegate: NSObject, NSApplicationDelegate, ObservableObject
|
||||
|
||||
func applicationDidFinishLaunching(_ : Notification) {
|
||||
NSApplication.shared.mainMenu?.removeItem(at: indexOfEditMenu)
|
||||
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
if (kill(getpid(), MainApp.suspendable ? SIGUSR1 : SIGINT) == 0) {
|
||||
return .terminateLater
|
||||
} else {
|
||||
return .terminateNow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,5 +862,5 @@ func pathHasMode(_ path: String, mode: mode_t) -> Bool {
|
||||
guard statRes != -1 else {
|
||||
return false
|
||||
}
|
||||
return (Int32(st.st_mode) & Int32(mode)) == Int32(mode)
|
||||
return (st.st_mode & S_IFMT) == mode
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
struct Set: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
@Option(help: "Number of VM CPUs")
|
||||
@@ -16,6 +17,14 @@ struct Set: AsyncParsableCommand {
|
||||
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||
var display: VMDisplayConfig?
|
||||
|
||||
@Flag(help: ArgumentHelp("Generate a new random MAC address for the VM."))
|
||||
var randomMAC: Bool = false
|
||||
|
||||
#if arch(arm64)
|
||||
@Flag(help: ArgumentHelp("Generate a new random serial number for the macOS VM."))
|
||||
#endif
|
||||
var randomSerial: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Resize the VMs disk to the specified size in GB (note that the disk size can only be increased to avoid losing data)",
|
||||
discussion: """
|
||||
Disk resizing works on most cloud-ready Linux distributions out-of-the box (e.g. Ubuntu Cloud Images
|
||||
@@ -51,6 +60,17 @@ struct Set: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
if randomMAC {
|
||||
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||
}
|
||||
|
||||
#if arch(arm64)
|
||||
if randomSerial {
|
||||
let oldPlatform = vmConfig.platform as! Darwin
|
||||
vmConfig.platform = Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: oldPlatform.hardwareModel)
|
||||
}
|
||||
#endif
|
||||
|
||||
try vmConfig.save(toURL: vmDir.configURL)
|
||||
|
||||
if diskSize != nil {
|
||||
|
||||
@@ -6,7 +6,7 @@ import SwiftDate
|
||||
struct Stop: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
|
||||
var name: String
|
||||
|
||||
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
|
||||
@@ -15,12 +15,12 @@ struct Stop: AsyncParsableCommand {
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
switch try vmDir.state() {
|
||||
case "suspended":
|
||||
case .Suspended:
|
||||
try stopSuspended(vmDir)
|
||||
case "running":
|
||||
case .Running:
|
||||
try await stopRunning(vmDir)
|
||||
default:
|
||||
return
|
||||
case .Stopped:
|
||||
throw RuntimeError.VMNotRunning(name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import SwiftDate
|
||||
struct Suspend: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
|
||||
var name: String
|
||||
|
||||
func run() async throws {
|
||||
@@ -14,7 +14,7 @@ struct Suspend: AsyncParsableCommand {
|
||||
let lock = try vmDir.lock()
|
||||
|
||||
// Find the VM's PID
|
||||
var pid = try lock.pid()
|
||||
let pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
||||
kSecAttrLabel as String: "Tart Credentials",
|
||||
]
|
||||
let value: [String: Any] = [kSecAttrAccount as String: user,
|
||||
kSecValueData as String: passwordData,
|
||||
kSecValueData as String: passwordData as Any,
|
||||
]
|
||||
|
||||
let status = SecItemCopyMatching(key as CFDictionary, nil)
|
||||
|
||||
+24
-11
@@ -3,19 +3,32 @@ import AsyncAlgorithms
|
||||
|
||||
fileprivate let urlSession = createURLSession()
|
||||
|
||||
class Fetcher {
|
||||
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
if viaFile {
|
||||
return try await fetchViaFile(request)
|
||||
}
|
||||
|
||||
return try await fetchViaMemory(request)
|
||||
class DownloadDelegate: NSObject, URLSessionTaskDelegate {
|
||||
let progress: Progress
|
||||
init(_ progress: Progress) throws {
|
||||
self.progress = progress
|
||||
}
|
||||
|
||||
private static func fetchViaMemory(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) {
|
||||
self.progress.addChild(task.progress, withPendingUnitCount: self.progress.totalUnitCount)
|
||||
}
|
||||
}
|
||||
|
||||
class Fetcher {
|
||||
static func fetch(_ request: URLRequest, viaFile: Bool = false, progress: Progress? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
let delegate = progress != nil ? try DownloadDelegate(progress!) : nil
|
||||
|
||||
if viaFile {
|
||||
return try await fetchViaFile(request, delegate: delegate)
|
||||
}
|
||||
|
||||
return try await fetchViaMemory(request, delegate: delegate)
|
||||
}
|
||||
|
||||
private static func fetchViaMemory(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
let (data, response) = try await urlSession.data(for: request, delegate: delegate)
|
||||
|
||||
Task {
|
||||
await dataCh.send(data)
|
||||
@@ -26,10 +39,10 @@ class Fetcher {
|
||||
return (dataCh, response as! HTTPURLResponse)
|
||||
}
|
||||
|
||||
private static func fetchViaFile(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
private static func fetchViaFile(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||
|
||||
let (fileURL, response) = try await urlSession.download(for: request)
|
||||
let (fileURL, response) = try await urlSession.download(for: request, delegate: delegate)
|
||||
|
||||
// Acquire a handle to the downloaded file and then remove it.
|
||||
//
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Virtualization
|
||||
import Semaphore
|
||||
|
||||
protocol Network {
|
||||
func attachments() -> [VZNetworkDeviceAttachment]
|
||||
func run(_ sema: DispatchSemaphore) throws
|
||||
func run(_ sema: AsyncSemaphore) throws
|
||||
func stop() async throws
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Semaphore
|
||||
import Virtualization
|
||||
|
||||
class NetworkBridged: Network {
|
||||
@@ -12,7 +13,7 @@ class NetworkBridged: Network {
|
||||
interfaces.map { VZBridgedNetworkDeviceAttachment(interface: $0) }
|
||||
}
|
||||
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
func run(_ sema: AsyncSemaphore) throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Semaphore
|
||||
import Virtualization
|
||||
|
||||
class NetworkShared: Network {
|
||||
@@ -6,7 +7,7 @@ class NetworkShared: Network {
|
||||
[VZNATNetworkDeviceAttachment()]
|
||||
}
|
||||
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
func run(_ sema: AsyncSemaphore) throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
import Atomics
|
||||
import Foundation
|
||||
import Semaphore
|
||||
import System
|
||||
import Virtualization
|
||||
|
||||
enum SoftnetError: Error {
|
||||
case InitializationFailed(why: String)
|
||||
@@ -44,7 +45,7 @@ class Softnet: Network {
|
||||
return executableURL
|
||||
}
|
||||
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
func run(_ sema: AsyncSemaphore) throws {
|
||||
try process.run()
|
||||
|
||||
monitorTask = Task {
|
||||
|
||||
@@ -10,7 +10,7 @@ class DiskV2: Disk {
|
||||
var pushedLayers: [OCIManifestLayer] = []
|
||||
|
||||
// Open the disk file
|
||||
var mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
|
||||
// Compress the disk file as multiple individually decompressible streams,
|
||||
// each equal ``Self.layerLimitBytes`` bytes or slightly larger due to the
|
||||
|
||||
@@ -42,9 +42,6 @@ extension AsyncThrowingChannel<Data, Error> {
|
||||
}
|
||||
|
||||
struct TokenResponse: Decodable, Authentication {
|
||||
let defaultIssuedAt = Date()
|
||||
let defaultExpiresIn = 60
|
||||
|
||||
var token: String?
|
||||
var accessToken: String?
|
||||
var expiresIn: Int?
|
||||
@@ -66,7 +63,8 @@ struct TokenResponse: Decodable, Authentication {
|
||||
return dateFormatter.date(from: dateString) ?? Date()
|
||||
}
|
||||
|
||||
let response = try decoder.decode(TokenResponse.self, from: fromData)
|
||||
var response = try decoder.decode(TokenResponse.self, from: fromData)
|
||||
response.issuedAt = response.issuedAt ?? Date()
|
||||
|
||||
guard response.token != nil || response.accessToken != nil else {
|
||||
throw DecodingError.keyNotFound(CodingKeys.token, .init(codingPath: [], debugDescription: "Missing token or access_token. One must be present."))
|
||||
@@ -85,7 +83,7 @@ struct TokenResponse: Decodable, Authentication {
|
||||
//
|
||||
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
|
||||
|
||||
(issuedAt ?? defaultIssuedAt) + TimeInterval(expiresIn ?? defaultExpiresIn)
|
||||
(issuedAt ?? Date()) + TimeInterval(expiresIn ?? 60)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ struct Root: AsyncParsableCommand {
|
||||
}
|
||||
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
||||
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setExtra(value: ProcessInfo.processInfo.arguments, key: "Command-line arguments")
|
||||
}
|
||||
|
||||
// Enrich future events with Cirrus CI-specific tags
|
||||
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
||||
SentrySDK.configureScope { scope in
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
fileprivate func normalizeName(_ name: String) -> String {
|
||||
// Colons are misinterpreted by Zsh completion
|
||||
return name.replacingOccurrences(of: ":", with: "\\:")
|
||||
}
|
||||
|
||||
func completeMachines(_ arguments: [String]) -> [String] {
|
||||
let localVMs = (try? VMStorageLocal().list().map { name, _ in
|
||||
normalizeName(name)
|
||||
}) ?? []
|
||||
let ociVMs = (try? VMStorageOCI().list().map { name, _, _ in
|
||||
normalizeName(name)
|
||||
}) ?? []
|
||||
return (localVMs + ociVMs)
|
||||
}
|
||||
|
||||
func completeLocalMachines(_ arguments: [String]) -> [String] {
|
||||
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||
return localVMs.map { name, _ in normalizeName(name) }
|
||||
}
|
||||
|
||||
func completeRunningMachines(_ arguments: [String]) -> [String] {
|
||||
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||
return localVMs
|
||||
.filter { _, vmDir in (try? vmDir.state() == .Running) ?? false}
|
||||
.map { name, _ in normalizeName(name) }
|
||||
}
|
||||
+49
-22
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
import AsyncAlgorithms
|
||||
import Semaphore
|
||||
|
||||
struct UnsupportedRestoreImageError: Error {
|
||||
}
|
||||
@@ -30,7 +31,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
var configuration: VZVirtualMachineConfiguration
|
||||
|
||||
// Semaphore used to communicate with the VZVirtualMachineDelegate
|
||||
var sema = DispatchSemaphore(value: 0)
|
||||
var sema = AsyncSemaphore(value: 0)
|
||||
|
||||
// VM's config
|
||||
var name: String
|
||||
@@ -46,7 +47,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = [],
|
||||
suspendable: Bool = false,
|
||||
audio: Bool = true
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
@@ -63,7 +65,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable,
|
||||
audio: audio
|
||||
audio: audio,
|
||||
clipboard: clipboard
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -91,12 +94,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Download the IPSW
|
||||
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
|
||||
|
||||
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
|
||||
let downloadProgress = Progress(totalUnitCount: 100)
|
||||
ProgressObserver(downloadProgress).log(defaultLogger)
|
||||
|
||||
let progress = Progress(totalUnitCount: response.expectedContentLength)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
let request = URLRequest(url: remoteURL)
|
||||
let (channel, response) = try await Fetcher.fetch(request, viaFile: true, progress: downloadProgress)
|
||||
|
||||
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw")
|
||||
defaultLogger.appendNewLine("Computing digest for \(temporaryLocation.path)...")
|
||||
let digestProgress = Progress(totalUnitCount: response.expectedContentLength)
|
||||
ProgressObserver(digestProgress).log(defaultLogger)
|
||||
|
||||
FileManager.default.createFile(atPath: temporaryLocation.path, contents: nil)
|
||||
let lock = try FileLock(lockURL: temporaryLocation)
|
||||
try lock.lock()
|
||||
@@ -108,7 +116,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
let chunkAsData = Data(chunk)
|
||||
fileHandle.write(chunkAsData)
|
||||
digest.update(chunkAsData)
|
||||
progress.completedUnitCount += Int64(chunk.count)
|
||||
digestProgress.completedUnitCount += Int64(chunk.count)
|
||||
}
|
||||
|
||||
try fileHandle.close()
|
||||
@@ -191,18 +199,24 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
virtualMachine.delegate = self
|
||||
|
||||
// Run automated installation
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
DispatchQueue.main.async { [ipswURL] in
|
||||
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: ipswURL)
|
||||
try await install(ipswURL)
|
||||
}
|
||||
|
||||
defaultLogger.appendNewLine("Installing OS...")
|
||||
ProgressObserver(installer.progress).log(defaultLogger)
|
||||
@MainActor
|
||||
private func install(_ url: URL) async throws {
|
||||
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: url)
|
||||
defaultLogger.appendNewLine("Installing OS...")
|
||||
ProgressObserver(installer.progress).log(defaultLogger)
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
installer.install { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, onCancel: {
|
||||
installer.progress.cancel()
|
||||
})
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -232,16 +246,18 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
await withTaskCancellationHandler(operation: {
|
||||
// Wait for the VM to finish running
|
||||
// or for the exit condition
|
||||
sema.wait()
|
||||
}, onCancel: {
|
||||
sema.signal()
|
||||
})
|
||||
do {
|
||||
try await sema.waitUnlessCancelled()
|
||||
} catch is CancellationError {
|
||||
// Triggered by "tart stop", Ctrl+C, or closing the
|
||||
// VM window, so shut down the VM gracefully below.
|
||||
}
|
||||
|
||||
if Task.isCancelled {
|
||||
try await stop()
|
||||
if (self.virtualMachine.state == VZVirtualMachine.State.running) {
|
||||
print("Stopping VM...")
|
||||
try await stop()
|
||||
}
|
||||
}
|
||||
|
||||
try await network.stop()
|
||||
@@ -277,7 +293,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||
serialPorts: [VZSerialPortConfiguration],
|
||||
suspendable: Bool = false,
|
||||
audio: Bool = true
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -322,6 +339,16 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
return vio
|
||||
}
|
||||
|
||||
// Clipboard sharing via Spice agent
|
||||
if clipboard && vmConfig.os == .linux {
|
||||
let spiceAgentConsoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||
let spiceAgentPort = VZVirtioConsolePortConfiguration()
|
||||
spiceAgentPort.name = VZSpiceAgentPortAttachment.spiceAgentPortName
|
||||
spiceAgentPort.attachment = VZSpiceAgentPortAttachment()
|
||||
spiceAgentConsoleDevice.ports[0] = spiceAgentPort
|
||||
configuration.consoleDevices.append(spiceAgentConsoleDevice)
|
||||
}
|
||||
|
||||
// Storage
|
||||
let attachment: VZDiskImageStorageDeviceAttachment = vmConfig.os == .linux ?
|
||||
// Use "cached" caching mode for virtio drive to prevent fs corruption on linux
|
||||
|
||||
@@ -3,6 +3,12 @@ import Virtualization
|
||||
import CryptoKit
|
||||
|
||||
struct VMDirectory: Prunable {
|
||||
enum State: String {
|
||||
case Running = "running"
|
||||
case Suspended = "suspended"
|
||||
case Stopped = "stopped"
|
||||
}
|
||||
|
||||
var baseURL: URL
|
||||
|
||||
var configURL: URL {
|
||||
@@ -47,13 +53,13 @@ struct VMDirectory: Prunable {
|
||||
return try lock.pid() != 0
|
||||
}
|
||||
|
||||
func state() throws -> String {
|
||||
func state() throws -> State {
|
||||
if try running() {
|
||||
return "running"
|
||||
return State.Running
|
||||
} else if FileManager.default.fileExists(atPath: stateURL.path) {
|
||||
return "suspended"
|
||||
return State.Suspended
|
||||
} else {
|
||||
return "stopped"
|
||||
return State.Stopped
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ enum RuntimeError : Error {
|
||||
case VMAlreadyRunning(_ message: String)
|
||||
case NoIPAddressFound(_ message: String)
|
||||
case DiskAlreadyInUse(_ message: String)
|
||||
case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
|
||||
case InvalidDiskSize(_ message: String)
|
||||
case FailedToUpdateAccessDate(_ message: String)
|
||||
case PIDLockFailed(_ message: String)
|
||||
@@ -93,6 +94,8 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return message
|
||||
case .DiskAlreadyInUse(let message):
|
||||
return message
|
||||
case .FailedToOpenBlockDevice(let path, let explanation):
|
||||
return "failed to open block device \(path): \(explanation)"
|
||||
case .InvalidDiskSize(let message):
|
||||
return message
|
||||
case .FailedToUpdateAccessDate(let message):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Sentry
|
||||
import Retry
|
||||
|
||||
class VMStorageOCI: PrunableStorage {
|
||||
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
@@ -134,7 +135,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt) async throws {
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["imageName": name], key: "OCI")
|
||||
scope.setContext(value: ["imageName": name.description], key: "OCI")
|
||||
}
|
||||
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
@@ -188,7 +189,14 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency)
|
||||
try await retry(maxAttempts: 5, backoff: .exponentialWithFullJitter(baseDelay: .seconds(5), maxDelay: .seconds(60))) {
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency)
|
||||
} recoverFromFailure: { error in
|
||||
print("Error: \(error.localizedDescription)")
|
||||
print("Attempting to re-try...")
|
||||
|
||||
return .retry
|
||||
}
|
||||
try move(digestName, from: tmpVMDir)
|
||||
transaction.finish()
|
||||
}, onCancel: {
|
||||
|
||||
@@ -15,7 +15,7 @@ class FullFledgedVNC: VNC {
|
||||
vnc.start()
|
||||
}
|
||||
|
||||
func waitForURL() async throws -> URL {
|
||||
func waitForURL(netBridged: Bool) async throws -> URL {
|
||||
while true {
|
||||
// Port is 0 shortly after start(),
|
||||
// but will be initialized later
|
||||
|
||||
@@ -9,9 +9,9 @@ class ScreenSharingVNC: VNC {
|
||||
self.vmConfig = vmConfig
|
||||
}
|
||||
|
||||
func waitForURL() async throws -> URL {
|
||||
func waitForURL(netBridged: Bool) async throws -> URL {
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
let ip = try await IP.resolveIP(vmMACAddress, secondsToWait: 60)
|
||||
let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: netBridged ? .arp : .dhcp, secondsToWait: 60)
|
||||
|
||||
if let ip = ip {
|
||||
return URL(string: "vnc://\(ip)")!
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
protocol VNC {
|
||||
func waitForURL() async throws -> URL
|
||||
func waitForURL(netBridged: Bool) async throws -> URL
|
||||
func stop() throws
|
||||
}
|
||||
|
||||
@@ -3,24 +3,26 @@ import XCTest
|
||||
|
||||
final class TokenResponseTests: XCTestCase {
|
||||
func testBasic() throws {
|
||||
var expectedTokenExpiresAtRange = DateInterval()
|
||||
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
|
||||
XCTAssertEqual(tokenResponse.token, "some token")
|
||||
|
||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
|
||||
expectedTokenExpiresAtRange.end = Date().addingTimeInterval(60)
|
||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||
|
||||
XCTAssertTrue(tokenResponse.isValid())
|
||||
}
|
||||
|
||||
func testExpirationBasic() throws {
|
||||
var expectedTokenExpiresAtRange = DateInterval()
|
||||
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":2}".utf8)
|
||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
|
||||
XCTAssertEqual(tokenResponse.expiresIn, 2)
|
||||
|
||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
|
||||
expectedTokenExpiresAtRange.end = Date().addingTimeInterval(2)
|
||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||
|
||||
XCTAssertTrue(tokenResponse.isValid())
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
root = true
|
||||
@@ -0,0 +1,92 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
enable-all: true
|
||||
|
||||
disable:
|
||||
# Messages like "struct of size 104 bytes could be of size 96 bytes" from a package
|
||||
# that was last updated 2 years ago[1] are barely helpful.
|
||||
#
|
||||
# After all, we're writing the code for other people, so let's trust the compiler here (that's
|
||||
# constantly evolving compared to this linter) and revisit this if memory usage becomes a problem.
|
||||
#
|
||||
# [1]: https://github.com/mdempsky/maligned/commit/6e39bd26a8c8b58c5a22129593044655a9e25959
|
||||
- maligned
|
||||
|
||||
# We don't have high-performance requirements at this moment, so sacrificing
|
||||
# the code readability for marginal performance gains is not worth it.
|
||||
- prealloc
|
||||
|
||||
# New linters that require a lot of codebase churn and noise, but perhaps we can enable them in the future.
|
||||
- nlreturn
|
||||
- wrapcheck
|
||||
- errorlint
|
||||
|
||||
# Unfortunately, we use globals due to how spf13/cobra works.
|
||||
- gochecknoglobals
|
||||
|
||||
# That's fine that some Proto objects don't have all fields initialized
|
||||
- exhaustivestruct
|
||||
|
||||
# Style linters that are total nuts.
|
||||
- wsl
|
||||
- gofumpt
|
||||
- goimports
|
||||
- funlen
|
||||
|
||||
# This conflicts with the Protocol Buffers Version 3 design,
|
||||
# which is largely based on default values for struct fields.
|
||||
- exhaustivestruct
|
||||
|
||||
# Enough parallelism for now.
|
||||
- paralleltest
|
||||
|
||||
# Ill-based assumptions about identifiers like fmt.Println without taking context into account.
|
||||
- forbidigo
|
||||
|
||||
# Advantages of using t.Helper() are too small to waste developer's cognitive stamina on it.
|
||||
- thelper
|
||||
|
||||
# Too restrictive defaults, plus there's already a gocyclo linter in place.
|
||||
- cyclop
|
||||
|
||||
# Gives false positives for textbook examples[1][2]
|
||||
# [1]: https://github.com/charithe/durationcheck/issues/7
|
||||
# [2]: https://golang.org/pkg/time/ (see "To convert an integer number of units to a Duration, multiply:")
|
||||
- durationcheck
|
||||
|
||||
# No way to disable the "exported" check for the whole project[1]
|
||||
# [1]: https://github.com/mgechev/revive/issues/244#issuecomment-560512162
|
||||
- revive
|
||||
|
||||
# Unfortunately too much false-positives, e.g. for a 0700 umask or number 10 when using strconv.FormatInt()
|
||||
- gomnd
|
||||
|
||||
# Needs package whitelists
|
||||
- depguard
|
||||
|
||||
# Generates absolutely useless errors, e.g.
|
||||
# "string `.yml` has 3 occurrences, make it a constant"
|
||||
- goconst
|
||||
|
||||
# It's OK to not sort imports
|
||||
- gci
|
||||
|
||||
# It's OK to not initialize some struct fields
|
||||
- exhaustruct
|
||||
|
||||
# This is not a library, so it's OK to use dynamic errors
|
||||
- goerr113
|
||||
|
||||
# fmt.Sprintf() looks a bit nicer than string addition
|
||||
- perfsprint
|
||||
|
||||
# We can control this ourselves
|
||||
- varnamelen
|
||||
- contextcheck
|
||||
|
||||
issues:
|
||||
# Don't hide multiple issues that belong to one class since GitHub annotations can handle them all nicely.
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
@@ -0,0 +1,39 @@
|
||||
# Benchmark
|
||||
|
||||
Tart comes with a Golang-based benchmarking utility that allows one to easily compare host and guest performance.
|
||||
|
||||
Currently, only Flexible I/O tester workloads are supported. To run them, [install Golang](https://go.dev/) and run the following command from this (`benchmark/`) directory:
|
||||
|
||||
```shell
|
||||
go run cmd/main.go fio
|
||||
```
|
||||
|
||||
You can also enable the debugging output to diagnose issues:
|
||||
|
||||
```shell
|
||||
go run cmd/main.go fio --debug
|
||||
```
|
||||
|
||||
## Results
|
||||
|
||||
Host:
|
||||
|
||||
* Hardware: Mac mini (Apple M2 Pro, 8 performance and 4 efficiency cores, 32 GB RAM, `Mac14,12`)
|
||||
* OS: macOS Sonoma 14.4.1
|
||||
|
||||
Guest:
|
||||
|
||||
* Hardware: [Virtualization.Framework](https://developer.apple.com/documentation/virtualization)
|
||||
* OS: macOS Sonoma 14.4.1
|
||||
|
||||
```
|
||||
Name Executor Bandwidth I/O operations
|
||||
Random writing of 1MB local 2.6 GB/s 649.35 kIOPS
|
||||
Random writing of 1MB Tart 2.5 GB/s 620.22 kIOPS
|
||||
Random writing of 10MB local 2.6 GB/s 651.74 kIOPS
|
||||
Random writing of 10MB Tart 2.5 GB/s 615.52 kIOPS
|
||||
Random writing of 100MB local 1.9 GB/s 481.51 kIOPS
|
||||
Random writing of 100MB Tart 2.0 GB/s 493.31 kIOPS
|
||||
Random writing of 1000MB local 1.7 GB/s 414.89 kIOPS
|
||||
Random writing of 1000MB Tart 1.1 GB/s 287.4 kIOPS
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/command"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Set up a signal-interruptible context
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
|
||||
// Run the root command
|
||||
if err := command.NewCommand().ExecuteContext(ctx); err != nil {
|
||||
cancel()
|
||||
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
module github.com/cirruslabs/tart/benchmark
|
||||
|
||||
go 1.22.1
|
||||
|
||||
require (
|
||||
github.com/avast/retry-go/v4 v4.5.1
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gosuri/uitable v0.0.4
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
github.com/avast/retry-go/v4 v4.5.1 h1:AxIx0HGi4VZ3I02jr78j5lZ3M6x1E0Ivxa6b0pUUh7o=
|
||||
github.com/avast/retry-go/v4 v4.5.1/go.mod h1:/sipNsvNB3RRuT5iNcb6h73nw3IBmXJ/H3XrCQYSOpc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
|
||||
github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,29 @@
|
||||
package fio
|
||||
|
||||
type Benchmark struct {
|
||||
Name string
|
||||
Command string
|
||||
}
|
||||
|
||||
var benchmarks = []Benchmark{
|
||||
{
|
||||
Name: "Random writing of 1MB",
|
||||
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||
"--size 1MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||
},
|
||||
{
|
||||
Name: "Random writing of 10MB",
|
||||
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||
"--size 10MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||
},
|
||||
{
|
||||
Name: "Random writing of 100MB",
|
||||
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||
"--size 100MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||
},
|
||||
{
|
||||
Name: "Random writing of 1000MB",
|
||||
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||
"--size 1000MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package fio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor/local"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor/tart"
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/gosuri/uitable"
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var debug bool
|
||||
|
||||
func NewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "fio",
|
||||
Short: "run Flexible I/O tester (fio) benchmarks",
|
||||
RunE: run,
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, args []string) error {
|
||||
config := zap.NewProductionConfig()
|
||||
if debug {
|
||||
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
|
||||
}
|
||||
logger, err := config.Build()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = logger.Sync()
|
||||
}()
|
||||
|
||||
executors, err := initializeExecutors(cmd.Context(), logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
errs := []error{err}
|
||||
|
||||
for _, executor := range executors {
|
||||
if err := executor.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to close executor %s: %w", executor.Name(), err))
|
||||
}
|
||||
}
|
||||
|
||||
err = errors.Join(errs...)
|
||||
}()
|
||||
|
||||
table := uitable.New()
|
||||
table.AddRow("Name", "Executor", "Bandwidth", "I/O operations")
|
||||
|
||||
for _, benchmark := range benchmarks {
|
||||
for _, executor := range executors {
|
||||
logger.Sugar().Infof("running benchmark %q on %s executor", benchmark.Name, executor.Name())
|
||||
|
||||
stdout, err := executor.Run(cmd.Context(), benchmark.Command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var fioResult Result
|
||||
|
||||
if err := json.Unmarshal(stdout, &fioResult); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(fioResult.Jobs) != 1 {
|
||||
return fmt.Errorf("expected exactly 1 job from fio's JSON output, got %d",
|
||||
len(fioResult.Jobs))
|
||||
}
|
||||
|
||||
job := fioResult.Jobs[0]
|
||||
|
||||
writeBandwidth := humanize.Bytes(uint64(job.Write.BW)*humanize.KByte) + "/s"
|
||||
writeIOPS := humanize.SIWithDigits(job.Write.IOPS, 2, "IOPS")
|
||||
|
||||
logger.Sugar().Infof("write bandwidth: %s, write IOPS: %s\n", writeBandwidth, writeIOPS)
|
||||
|
||||
table.AddRow(benchmark.Name, executor.Name(), writeBandwidth, writeIOPS)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println(table.String())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeExecutors(ctx context.Context, logger *zap.Logger) ([]executor.Executor, error) {
|
||||
var result []executor.Executor
|
||||
|
||||
logger.Info("initializing local executor")
|
||||
|
||||
local, err := local.New(logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, local)
|
||||
|
||||
logger.Info("local executor initialized")
|
||||
|
||||
logger.Info("initializing Tart executor")
|
||||
|
||||
tart, err := tart.New(ctx, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, tart)
|
||||
|
||||
logger.Info("Tart executor initialized")
|
||||
|
||||
for _, executor := range result {
|
||||
logger.Sugar().Infof("installing Flexible I/O tester (fio) on %s executor", executor.Name())
|
||||
|
||||
if _, err := executor.Run(ctx, "brew install fio"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package fio
|
||||
|
||||
type Result struct {
|
||||
Jobs []Job `json:"jobs"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
Name string `json:"jobname"`
|
||||
Write Write `json:"write"`
|
||||
}
|
||||
|
||||
type Write struct {
|
||||
BW float64 `json:"bw"`
|
||||
IOPS float64 `json:"iops"`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"github.com/cirruslabs/tart/benchmark/internal/command/fio"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "benchmark",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
fio.NewCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Executor interface {
|
||||
Name() string
|
||||
Run(ctx context.Context, command string) ([]byte, error)
|
||||
Close() error
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapio"
|
||||
"io"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
type Local struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func New(logger *zap.Logger) (*Local, error) {
|
||||
return &Local{
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (local *Local) Name() string {
|
||||
return "local"
|
||||
}
|
||||
|
||||
func (local *Local) Run(ctx context.Context, command string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, "zsh", "-c", command)
|
||||
|
||||
loggerWriter := &zapio.Writer{Log: local.logger, Level: zap.DebugLevel}
|
||||
stdoutBuf := &bytes.Buffer{}
|
||||
|
||||
cmd.Stdout = io.MultiWriter(stdoutBuf, loggerWriter)
|
||||
cmd.Stderr = loggerWriter
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
return stdoutBuf.Bytes(), err
|
||||
}
|
||||
|
||||
func (local *Local) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package local_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor/local"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocal(t *testing.T) {
|
||||
local, err := local.New(zap.NewNop())
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := local.Run(context.Background(), "echo \"this is a test\"")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "this is a test\n", string(output))
|
||||
|
||||
require.NoError(t, local.Close())
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package tart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapio"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const tartBinaryName = "tart"
|
||||
|
||||
func Cmd(ctx context.Context, logger *zap.Logger, args ...string) error {
|
||||
_, err := CmdWithOutput(ctx, logger, args...)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func CmdWithOutput(ctx context.Context, logger *zap.Logger, args ...string) (string, error) {
|
||||
logger.Sugar().Debugf("running %s %s", tartBinaryName, strings.Join(args, " "))
|
||||
|
||||
cmd := exec.CommandContext(ctx, tartBinaryName, args...)
|
||||
|
||||
loggerWriter := &zapio.Writer{Log: logger, Level: zap.DebugLevel}
|
||||
stdoutBuf := &bytes.Buffer{}
|
||||
|
||||
cmd.Stdout = io.MultiWriter(stdoutBuf, loggerWriter)
|
||||
cmd.Stderr = loggerWriter
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
return stdoutBuf.String(), err
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package tart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/avast/retry-go/v4"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const baseImage = "ghcr.io/cirruslabs/macos-sonoma-base:latest"
|
||||
|
||||
type Tart struct {
|
||||
vmRunCancel context.CancelFunc
|
||||
vmName string
|
||||
sshClient *ssh.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func New(ctx context.Context, logger *zap.Logger) (*Tart, error) {
|
||||
tart := &Tart{
|
||||
vmName: fmt.Sprintf("tart-benchmark-%s", uuid.NewString()),
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
if err := Cmd(ctx, tart.logger, "pull", baseImage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := Cmd(ctx, tart.logger, "clone", baseImage, tart.vmName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vmRunCtx, vmRunCancel := context.WithCancel(ctx)
|
||||
tart.vmRunCancel = vmRunCancel
|
||||
|
||||
go func() {
|
||||
_ = Cmd(vmRunCtx, tart.logger, "run", "--no-graphics", tart.vmName)
|
||||
}()
|
||||
|
||||
ip, err := CmdWithOutput(ctx, tart.logger, "ip", "--wait", "60", tart.vmName)
|
||||
if err != nil {
|
||||
return nil, tart.Close()
|
||||
}
|
||||
|
||||
err = retry.Do(func() error {
|
||||
dialer := net.Dialer{
|
||||
Timeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:22", strings.TrimSpace(ip))
|
||||
|
||||
netConn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshConn, chans, reqs, err := ssh.NewClientConn(netConn, addr, &ssh.ClientConfig{
|
||||
User: "admin",
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password("admin"),
|
||||
},
|
||||
HostKeyCallback: func(_ string, _ net.Addr, _ ssh.PublicKey) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tart.sshClient = ssh.NewClient(sshConn, chans, reqs)
|
||||
|
||||
return nil
|
||||
}, retry.RetryIf(func(err error) bool {
|
||||
return !errors.Is(err, context.Canceled)
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, tart.Close()
|
||||
}
|
||||
|
||||
return tart, nil
|
||||
}
|
||||
|
||||
func (tart *Tart) Name() string {
|
||||
return "Tart"
|
||||
}
|
||||
|
||||
func (tart *Tart) Run(ctx context.Context, command string) ([]byte, error) {
|
||||
sshSession, err := tart.sshClient.NewSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Work around x/crypto/ssh not being context.Context-friendly (e.g. https://github.com/golang/go/issues/20288)
|
||||
monitorCtx, monitorCancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
<-monitorCtx.Done()
|
||||
_ = sshSession.Close()
|
||||
}()
|
||||
defer monitorCancel()
|
||||
|
||||
stdoutBuf := &bytes.Buffer{}
|
||||
|
||||
sshSession.Stdin = bytes.NewBufferString(command)
|
||||
sshSession.Stdout = stdoutBuf
|
||||
|
||||
if err := sshSession.Shell(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := sshSession.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stdoutBuf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (tart *Tart) Close() error {
|
||||
if tart.sshClient != nil {
|
||||
_ = tart.sshClient.Close()
|
||||
}
|
||||
|
||||
tart.vmRunCancel()
|
||||
_ = Cmd(context.Background(), tart.logger, "delete", tart.vmName)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package tart_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor/tart"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tart, err := tart.New(ctx, zap.NewNop())
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := tart.Run(ctx, "echo \"this is a test\"")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "this is a test\n", string(output))
|
||||
|
||||
require.NoError(t, tart.Close())
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
# Cirrus Runners for GitHub Actions
|
||||
|
||||
*Cirrus Runners* is the fastest and most cost-efficient way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
|
||||
Your actions will be executed in clean macOS virtual machines with 4 Apple M2 cores.
|
||||
|
||||
## Testimonials from customers
|
||||
|
||||
Mitchell Hashimoto, HashiCorp co-founder:
|
||||
|
||||
> I've been using "Cirrus Runners" since [that tweet](https://twitter.com/mitchellh/status/1731071326201561194) and it has been fantastic. Huge speed increase, huge cost decrease, zero maintenance, exactly what I wanted.
|
||||
|
||||
Max Lapides, Senior Mobile Engineer at [Tonal](https://www.tonal.com/):
|
||||
|
||||
> Previously, we were using the GitHub‑hosted macOS runners and our iOS build took ~30 minutes. Now with Cirrus Runners, the iOS build only takes ~12 minutes. That’s a huge boost to our productivity, and for only $150/month per runner it is much less expensive too.
|
||||
|
||||
John A., Software Engineer at [GitKraken](https://www.gitkraken.com/):
|
||||
|
||||
> GitHub Actions MacOS-x86 runners have become increasingly unreliable, so we're moving our Mac builds over to arm64 because Cirrus Labs' M1 runners are not only ~3 times faster, they've also been far more stable.
|
||||
|
||||
Sebastian Jachec, Mobile Engineer at [Daybridge](https://www.daybridge.com/):
|
||||
|
||||
> It’s been plain-sailing with the Cirrus Runners — they’ve been great! They’re consistently 60+% faster on workflows that we previously used Github Actions’ macOS runners for.
|
||||
|
||||
## Pricing
|
||||
|
||||
Each Cirrus Runner costs $150 a month and there is no limit on the amount of minutes for your actions.
|
||||
We recommend to purchase several Cirrus Runners depending on your team size, so you can run actions in
|
||||
parallel. Note that you can change your subscription at any time via [this page](https://billing.stripe.com/p/login/3cs7vNbzo92p7fy3cc)
|
||||
or by emailing [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
||||
|
||||
### Discounts
|
||||
|
||||
We offer two mutually exclusive discounts:
|
||||
|
||||
- 10% "Volume Discount" for subscriptions of 10 or more Cirrus Runners.
|
||||
- 15% "Annual Discount" for 12 months subscription commitment of any amount of Cirrus Runners.
|
||||
|
||||
Please contact [support@cirruslabs.org](mailto:support@cirruslabs.org) after activating the subscription in order to get the discount applied.
|
||||
|
||||
### Priority Support
|
||||
|
||||
Subscriptions of 20 or more Cirrus Runners include access to [Priority Support](../licensing.md#priority-support).
|
||||
Please contact [sales@cirruslabs.org](mailto:sales@cirruslabs.org) in order to get all the details.
|
||||
|
||||
### CPU and Memory resources of Cirrus Runners
|
||||
|
||||
By default, a single Cirrus Runner is allocated with 4 M2 cores and 12 GB of unified memory which is enough for most of the workloads.
|
||||
For workloads that require more resources it is possible to use XL Cirrus Runners which have twice the resources: a full M2 chip with 8 cores
|
||||
and 24 GB of unified memory. Note that a single XL Cirrus Runner also uses twice the concurrency.
|
||||
|
||||
In order to use an XL Cirrus Runner for a job please append `-xl` suffix to your `runs-on` property. More on that down below.
|
||||
|
||||
## Installation
|
||||
|
||||
Once you configure [Cirrus Runners App](https://github.com/apps/cirrus-runners) for your organization, you'll be redirected
|
||||
to a checkout page powered by Stripe. During the checkout process you'll be able to configure a subscription for
|
||||
a desired amount of parallel Cirrus Runners and try it for free for 10 days.
|
||||
|
||||
Once configured, please follow instruction below. If you have any questions please contact [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
||||
Subscriptions with more than 10 runners also include Priority Support
|
||||
|
||||
## Configuring Cirrus Runners
|
||||
|
||||
In order for Cirrus Runners to be used by your GitHub Actions workflow jobs, specify a desired image in the `runs-on` property.
|
||||
|
||||
=== "Default Cirrus Runner"
|
||||
|
||||
```yaml
|
||||
name: Tests
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ghcr.io/cirruslabs/macos-sonoma-xcode:latest
|
||||
```
|
||||
|
||||
=== "XL Cirrus Runner"
|
||||
|
||||
```yaml
|
||||
name: Integration Tests
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ghcr.io/cirruslabs/macos-sonoma-xcode:latest-xl
|
||||
```
|
||||
|
||||
List of all available images can be found in [this repository](https://github.com/cirruslabs/macos-image-templates).
|
||||
|
||||
Note that Tart VM images don't have the same set of pre-installed packages as the official Intel GitHub runners.
|
||||
If something is missing please [create an issue within this repository](https://github.com/cirruslabs/macos-image-templates/issues/new).
|
||||
|
||||
When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`.
|
||||
Note that Cirrus Runners will get added to the default runner group.
|
||||
|
||||
!!! tip "Using Cirrus Runners with public repositories"
|
||||
|
||||
By default, only private repositories can access runners in a default runner group, but you can override this in your organization's settings:
|
||||
|
||||
```https://github.com/organizations/<YOUR ORGANIZATION NAME>/settings/actions/runner-groups/1```
|
||||
|
||||

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

|
||||
|
||||
## Data handling flow
|
||||
|
||||
By design Cirrus Runners service never sees any of your secrets or source code and acts as compute platform with the lastest
|
||||
Apple Silicon hardware that can quickly allocate CPU/Memory resources for your jobs.
|
||||
|
||||
Here is a high-level overview of how Cirrus Runners service manages runners for your organization:
|
||||
|
||||
- Cirrus Runner GitHub App is subscribed to [`workflow_job`](https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_job).
|
||||
- Upon receiving a new event targeting Cirrus Runners via `runs-on` property the following steps take place:
|
||||
|
||||
- Non-personal information about your job is saved to perform health checking of Cirrus Runners execution.
|
||||
- Cirrus Runners GitHub App has only one permission that allows generating temporary registration tokens for
|
||||
self-hosted GitHub Actions Runners. Note that Cirrus Runners GitHub App itself doesn't have access to contents of
|
||||
repositories in your organization.
|
||||
- Cirrus Runners Service creates a new single use Tart VM, generates a temporary registration tokens for self-hosted runners
|
||||
and passes it without storing inside the VM for the GitHub Actions Runner service to [start a ephemeral runner](https://github.blog/changelog/2021-09-20-github-actions-ephemeral-self-hosted-runners-new-webhooks-for-auto-scaling/).
|
||||
|
||||
- Cirrus Runners service continuously monitors health of the Tart VM executing your job to make sure it runs to completion.
|
||||
- After the job finishes the ephemeral Tart VM is getting destroyed with all the information of the job run.
|
||||
|
||||
If you have any questions or concerns please feel free to reach out to [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
||||
+1
-1
@@ -11,7 +11,7 @@ This page covers Terms of Service only for Cirrus Runners and Tart Documentation
|
||||
|
||||
### The Gist
|
||||
|
||||
Cirrus Labs Inc ("Cirrus Labs") operates the [Cirrus Runners service](../integrations/github-actions.md) which we hope you use.
|
||||
Cirrus Labs Inc ("Cirrus Labs") operates the [Cirrus Runners service](https://cirrus-runners.app/) which we hope you use.
|
||||
If you use it, please use it responsibly. If you don't, we'll have to terminate your subscription.
|
||||
|
||||
For paid plans, you'll be charged on a monthly basis. You can cancel anytime, but there are no refunds.
|
||||
|
||||
@@ -143,7 +143,15 @@ Note: to use the directory mounting feature, the guest VM needs to run macOS 13.
|
||||
To be able to access the shared directories from the Linux guest, you need to manually mount the virtual filesystem first:
|
||||
|
||||
```bash
|
||||
mkdir /mnt/shared
|
||||
mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
|
||||
```
|
||||
|
||||
The directory we've mounted above will be accessible from the `/mnt/shared/project` path inside a guest VM.
|
||||
|
||||
??? info "Auto-mount at boot time"
|
||||
To automatically mount this directory at boot time, add the following line to the `/etc/fstab` file:
|
||||
|
||||
```shell
|
||||
com.apple.virtio-fs.automount /mnt/shared virtiofs rw,relatime 0 0
|
||||
```
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from paramiko.client import SSHClient, AutoAddPolicy
|
||||
|
||||
|
||||
def test_run(tart):
|
||||
@pytest.mark.parametrize("run_opts", [[], ["--no-graphics"]])
|
||||
def test_run(tart, run_opts):
|
||||
vm_name = f"integration-test-run-{uuid.uuid4()}"
|
||||
|
||||
# Instantiate a VM with admin:admin SSH access
|
||||
tart.run(["clone", "ghcr.io/cirruslabs/macos-sonoma-base:latest", vm_name])
|
||||
|
||||
# Run the VM asynchronously
|
||||
tart_run_process = tart.run_async(["run", vm_name])
|
||||
tart_run_process = tart.run_async(["run", vm_name] + run_opts)
|
||||
|
||||
# Obtain the VM's IP
|
||||
stdout, _ = tart.run(["ip", vm_name, "--wait", "120"])
|
||||
|
||||
+2
-2
@@ -89,10 +89,10 @@ nav:
|
||||
- "Home": index.md
|
||||
- "Quick Start": quick-start.md
|
||||
- "Integrations":
|
||||
- "GitHub Actions": integrations/github-actions.md
|
||||
- "Self-hosted CI": integrations/cirrus-cli.md
|
||||
- "GitHub Actions": https://cirrus-runners.app/
|
||||
- "GitLab Runner": integrations/gitlab-runner.md
|
||||
- "Buildkite": integrations/buildkite.md
|
||||
- "Self-hosted CI": integrations/cirrus-cli.md
|
||||
- "Managing VMs": integrations/vm-management.md
|
||||
- "Support & Licensing": licensing.md
|
||||
- "Orchestration": https://github.com/cirruslabs/orchard
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
docker run --pull=always --rm -it -p 8000:8000 -v ${PWD}:/docs ghcr.io/cirruslabs/mkdocs-material-insiders:latest
|
||||
Reference in New Issue
Block a user