mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-02 04:01:12 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ab5c3af4 | ||
|
|
280a31f707 | ||
|
|
8d49404337 | ||
|
|
64a3999a58 | ||
|
|
5c1f5a61c1 | ||
|
|
1310220f05 | ||
|
|
1fe2f1ff88 | ||
|
|
df3de33f1a | ||
|
|
1560e4d312 | ||
|
|
318202fa81 | ||
|
|
cb92a3fa67 | ||
|
|
9c30638079 | ||
|
|
a4edc6af50 | ||
|
|
2890dda847 | ||
|
|
2d55f3b9fa | ||
|
|
d3104c71b9 | ||
|
|
3ddad55372 | ||
|
|
72a81ca84a | ||
|
|
d8945503d6 | ||
|
|
a0fd5435de | ||
|
|
4cf68fc061 | ||
|
|
b626ed415b | ||
|
|
dd7bace92d | ||
|
|
94376ca355 | ||
|
|
60a481857f | ||
|
|
876271dceb | ||
|
|
6dd43abf03 | ||
|
|
04c6df2efb | ||
|
|
5a8b48a392 | ||
|
|
b96ea087f5 | ||
|
|
eaec015edf |
+8
-9
@@ -1,7 +1,7 @@
|
||||
use_compute_credits: true
|
||||
|
||||
task:
|
||||
name: Test on Sonoma
|
||||
name: Test on Sequoia
|
||||
alias: test
|
||||
persistent_worker:
|
||||
labels:
|
||||
@@ -17,8 +17,7 @@ task:
|
||||
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
|
||||
# Run integration tests
|
||||
- cd integration-tests
|
||||
- HOMEBREW_NO_AUTO_UPDATE=1 brew install virtualenv
|
||||
- virtualenv venv
|
||||
- python3 -m venv --symlinks venv
|
||||
- source venv/bin/activate
|
||||
- pip install -r requirements.txt
|
||||
- pytest --verbose --junit-xml=pytest-junit.xml
|
||||
@@ -38,7 +37,7 @@ task:
|
||||
name: Lint
|
||||
alias: lint
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||
image: ghcr.io/cirruslabs/macos-runner:sequoia
|
||||
lint_script:
|
||||
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
||||
always:
|
||||
@@ -55,7 +54,7 @@ task:
|
||||
name: Build ($BUILD_ARCH)
|
||||
alias: build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||
image: ghcr.io/cirruslabs/macos-runner:sequoia
|
||||
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:
|
||||
@@ -68,7 +67,7 @@ task:
|
||||
- lint
|
||||
- build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||
image: ghcr.io/cirruslabs/macos-runner:sequoia
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
@@ -103,7 +102,7 @@ task:
|
||||
- test
|
||||
- build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||
image: ghcr.io/cirruslabs/macos-runner:sequoia
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
@@ -121,7 +120,7 @@ task:
|
||||
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
|
||||
- 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:
|
||||
install_script:
|
||||
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
|
||||
- brew install mitchellh/gon/gon
|
||||
info_script:
|
||||
@@ -153,7 +152,7 @@ task:
|
||||
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
|
||||
env:
|
||||
DEPLOY_TOKEN: ENCRYPTED[!45ed45666558902ed1c2400add734ec063103bec31841847e8c8764802fca229bfa6d85c690e16ad159e047574b48793!]
|
||||
deploy_script:
|
||||
deploy_script:
|
||||
- git config --global user.name "Cirrus CI"
|
||||
- git config --global user.name "hello@cirruslabs.org"
|
||||
- git remote set-url origin https://$DEPLOY_TOKEN@github.com/cirruslabs/tart/
|
||||
|
||||
+18
-5
@@ -1,11 +1,12 @@
|
||||
version: 2
|
||||
|
||||
project_name: tart
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- .ci/set-version.sh
|
||||
- swift build --arch x86_64 -c release --product tart
|
||||
- swift build --arch arm64 -c release --product tart
|
||||
- gon gon.hcl
|
||||
- swift build --arch arm64 --configuration release --product tart
|
||||
- swift build --arch x86_64 --configuration release --product tart
|
||||
|
||||
builds:
|
||||
- id: tart
|
||||
@@ -20,8 +21,14 @@ builds:
|
||||
prebuilt:
|
||||
path: '.build/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}-apple-macosx/release/tart'
|
||||
|
||||
universal_binaries:
|
||||
- name_template: tart.app/Contents/MacOS/tart
|
||||
replace: true
|
||||
hooks:
|
||||
post: gon gon.hcl
|
||||
|
||||
archives:
|
||||
- name_template: "{{ .ProjectName }}-{{ .Arch }}"
|
||||
- name_template: "{{ .ProjectName }}"
|
||||
files:
|
||||
- src: Resources/embedded.provisionprofile
|
||||
dst: tart.app/Contents
|
||||
@@ -42,7 +49,13 @@ brews:
|
||||
repository:
|
||||
owner: cirruslabs
|
||||
name: homebrew-cli
|
||||
caveats: See the GitHub repository for more information
|
||||
caveats: |
|
||||
Tart has been installed. You might want to reduce the default DHCP lease time
|
||||
from 86,400 to 600 seconds to avoid DHCP shortage when running lots of VMs daily:
|
||||
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.InternetSharing.default.plist bootpd -dict DHCPLeaseTimeSecs -int 600
|
||||
|
||||
See https://tart.run/faq/#changing-the-default-dhcp-lease-time for more details.
|
||||
homepage: https://github.com/cirruslabs/tart
|
||||
license: "Fair Source"
|
||||
description: Run macOS and Linux VMs on Apple Hardware
|
||||
|
||||
@@ -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-sonoma-base:latest sonoma-base
|
||||
tart run sonoma-base
|
||||
tart clone ghcr.io/cirruslabs/macos-sequoia-base:latest sequoia-base
|
||||
tart run sequoia-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)
|
||||
|
||||
@@ -21,5 +21,7 @@
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Access to OCI registries on the local network</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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"))
|
||||
@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())
|
||||
var fromIPSW: String?
|
||||
|
||||
@Flag(help: "create a Linux VM")
|
||||
|
||||
@@ -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.")
|
||||
@Argument(help: "Path to the destination file.", completion: .file())
|
||||
var path: String?
|
||||
|
||||
func run() async throws {
|
||||
|
||||
@@ -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\".")
|
||||
@Argument(help: "Path to a file created with \"tart export\".", completion: .file())
|
||||
var path: String
|
||||
|
||||
@Argument(help: "Destination VM name.")
|
||||
@Argument(help: "Destination VM name.", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
func validate() throws {
|
||||
|
||||
@@ -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")
|
||||
@Option(help: "Output format: text or json", completion: .list(["text", "json"]))
|
||||
var format: Format = .text
|
||||
|
||||
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
|
||||
|
||||
@@ -7,7 +7,7 @@ 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."))
|
||||
@Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs."), completion: .list(["caches", "vms"]))
|
||||
var entries: String = "caches"
|
||||
|
||||
@Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago",
|
||||
|
||||
@@ -26,6 +26,11 @@ 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"
|
||||
|
||||
@@ -81,7 +86,8 @@ struct Push: AsyncParsableCommand {
|
||||
references: references,
|
||||
chunkSizeMb: chunkSize,
|
||||
diskFormat: diskFormat,
|
||||
concurrency: concurrency
|
||||
concurrency: concurrency,
|
||||
labels: parseLabels()
|
||||
)
|
||||
// Populate the local cache (if requested)
|
||||
if populateCache {
|
||||
@@ -115,6 +121,28 @@ 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 {
|
||||
|
||||
@@ -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: "Only works with Linux-based guest operating systems."))
|
||||
discussion: "Clipboard sharing requires spice-vdagent package on Linux and https://github.com/cirruslabs/tart-guest-agent on macOS."))
|
||||
var noClipboard: Bool = false
|
||||
|
||||
#if arch(arm64)
|
||||
@@ -116,8 +116,12 @@ struct Run: AsyncParsableCommand {
|
||||
#endif
|
||||
var vncExperimental: Bool = false
|
||||
|
||||
// Note that the valueName here should really be "path[:options]" instead of just "path",
|
||||
// see ArgumentParser issue[1] for more details.
|
||||
//
|
||||
// [1]: https://github.com/apple/swift-argument-parser/issues/761
|
||||
@Option(help: ArgumentHelp("""
|
||||
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")
|
||||
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")
|
||||
""", discussion: """
|
||||
The disk attachment can be a:
|
||||
|
||||
@@ -138,8 +142,8 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
To work around this pass TART_HOME explicitly:
|
||||
|
||||
sudo TART_HOME="$HOME/.tart" tart run sonoma --disk=/dev/disk0
|
||||
""", valueName: "path[:options]"))
|
||||
sudo TART_HOME="$HOME/.tart" tart run sequoia --disk=/dev/disk0
|
||||
""", valueName: "path"), completion: .file())
|
||||
var disk: [String] = []
|
||||
|
||||
#if arch(arm64)
|
||||
@@ -158,7 +162,11 @@ struct Run: AsyncParsableCommand {
|
||||
#endif
|
||||
var rosettaTag: String?
|
||||
|
||||
@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: """
|
||||
// Note that the valueName here should really be "[name:]path[:options]" instead of just "path",
|
||||
// see ArgumentParser issue[1] for more details.
|
||||
//
|
||||
// [1]: https://github.com/apple/swift-argument-parser/issues/761
|
||||
@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: """
|
||||
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:
|
||||
@@ -170,7 +178,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]"))
|
||||
""", valueName: "path"), completion: .directory)
|
||||
var dir: [String] = []
|
||||
|
||||
@Flag(help: ArgumentHelp("Enable nested virtualization if possible"))
|
||||
@@ -183,13 +191,49 @@ struct Run: AsyncParsableCommand {
|
||||
""", valueName: "interface name"))
|
||||
var netBridged: [String] = []
|
||||
|
||||
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
|
||||
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
|
||||
@Flag(help: ArgumentHelp("Use software networking provided by Softnet instead of the default shared (NAT) networking",
|
||||
discussion: """
|
||||
Softnet provides better network isolation and alleviates DHCP shortage on production systems. Tart invokes Softnet when this option is specified as a sub-process and communicates with it over socketpair(2).
|
||||
|
||||
It is essentially a userspace packet filter which restricts the VM networking and prevents a class of security issues, such as ARP spoofing. By default, the VM will only be able to:
|
||||
|
||||
* send traffic from its own MAC-address
|
||||
* send traffic from the IP-address assigned to it by the DHCP
|
||||
* send traffic to globally routable IPv4 addresses
|
||||
* send traffic to gateway IP of the vmnet bridge (this would normally be \"bridge100\" interface)
|
||||
* receive any incoming traffic
|
||||
|
||||
In addition, Softnet tunes macOS built-in DHCP server to decrease its lease time from the default 86,400 seconds (one day) to 600 seconds (10 minutes). This is especially important when you use Tart to clone and run a lot of ephemeral VMs over a period of one day.
|
||||
|
||||
More on Softnet 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"))
|
||||
@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 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 TCP ports to expose (e.g. --net-softnet-expose 2222:22,8080:80)", discussion: """
|
||||
Options are comma-separated and are as follows:
|
||||
|
||||
* EXTERNAL_PORT:INTERNAL_PORT — forward TCP traffic from the EXTERNAL_PORT on a host's egress interface (automatically detected and could be Wi-Fi, Ethernet and a VPN interface) to the INTERNAL_PORT on guest's IP (as reported by "tart ip")
|
||||
|
||||
Note that for the port forwarding to work correctly:
|
||||
|
||||
* the software in guest listening on INTERNAL_PORT should either listen on 0.0.0.0 or on an IP address assigned to that guest
|
||||
* connection to the EXTERNAL_PORT should be performed from the local network that the host is attached to or from the internet, it's not possible to connect to that forwarded port from the host itself
|
||||
|
||||
Another thing to keep in mind is that regular Softnet restrictions will still apply even to port forwarding. So if you're planning to access your VM from local network, and your local network is 192.168.0.0/24, for example, then add --net-softnet-allow=192.168.0.0/24. If you only need port forwarding, to completely disable Softnet restrictions you can use --net-softnet-allow=0.0.0.0/0.
|
||||
|
||||
Implies --net-softnet.
|
||||
""", valueName: "comma-separated port specifications"))
|
||||
var netSoftnetExpose: String?
|
||||
|
||||
@Flag(help: ArgumentHelp("Restrict network access to the host-only network"))
|
||||
var netHost: Bool = false
|
||||
|
||||
@@ -224,12 +268,22 @@ 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
|
||||
|
||||
mutating func validate() throws {
|
||||
if vnc && vncExperimental {
|
||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||
}
|
||||
|
||||
// check that not more than one network option is specified
|
||||
// Automatically enable --net-softnet when any of its related options are specified
|
||||
if netSoftnetAllow != nil || netSoftnetExpose != nil {
|
||||
netSoftnet = true
|
||||
}
|
||||
|
||||
// Check that no more than one network option is specified
|
||||
var netFlags = 0
|
||||
if netBridged.count > 0 { netFlags += 1 }
|
||||
if netSoftnet { netFlags += 1 }
|
||||
@@ -249,7 +303,7 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
if nested {
|
||||
if #unavailable(macOS 15) {
|
||||
throw ValidationError("Nested virtualization is supported on hosts starting with macOS 15 (Sequia), and later.")
|
||||
throw ValidationError("Nested virtualization is supported on hosts starting with macOS 15 (Sequoia), and later.")
|
||||
} else if !VZGenericPlatformConfiguration.isNestedVirtualizationSupported {
|
||||
throw ValidationError("Nested virtualization is available for Mac with the M3 chip, and later.")
|
||||
}
|
||||
@@ -269,6 +323,17 @@ struct Run: AsyncParsableCommand {
|
||||
if dir.count > 0 {
|
||||
throw ValidationError("Suspending VMs with shared directories is not supported")
|
||||
}
|
||||
|
||||
if noTrackpad {
|
||||
throw ValidationError("--no-trackpad 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")
|
||||
}
|
||||
}
|
||||
|
||||
for disk in disk {
|
||||
@@ -332,7 +397,8 @@ struct Run: AsyncParsableCommand {
|
||||
audio: !noAudio,
|
||||
clipboard: !noClipboard,
|
||||
sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw),
|
||||
caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw)
|
||||
caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw),
|
||||
noTrackpad: noTrackpad
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
@@ -527,6 +593,10 @@ struct Run: AsyncParsableCommand {
|
||||
softnetExtraArguments += ["--allow", netSoftnetAllow]
|
||||
}
|
||||
|
||||
if let netSoftnetExpose = netSoftnetExpose {
|
||||
softnetExtraArguments += ["--expose", netSoftnetExpose]
|
||||
}
|
||||
|
||||
if netSoftnet {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
@@ -1029,7 +1099,7 @@ struct DirectoryShare {
|
||||
process.standardInput = inPipe
|
||||
process.launch()
|
||||
|
||||
inPipe.fileHandleForWriting.write(response!.data)
|
||||
try inPipe.fileHandleForWriting.write(contentsOf: response!.data)
|
||||
try inPipe.fileHandleForWriting.close()
|
||||
process.waitUntilExit()
|
||||
|
||||
|
||||
@@ -33,15 +33,7 @@ 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: """
|
||||
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
|
||||
See https://tart.run/faq/#disk-resizing for more details.
|
||||
"""))
|
||||
var diskSize: UInt16?
|
||||
|
||||
@@ -73,8 +65,7 @@ struct Set: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
#if arch(arm64)
|
||||
if randomSerial {
|
||||
let oldPlatform = vmConfig.platform as! Darwin
|
||||
if randomSerial, let oldPlatform = vmConfig.platform as? Darwin {
|
||||
vmConfig.platform = Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: oldPlatform.hardwareModel)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -36,7 +36,11 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
||||
|
||||
process.launch()
|
||||
|
||||
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
||||
do {
|
||||
try inPipe.fileHandleForWriting.write(contentsOf: "\(host)\n".data(using: .utf8)!)
|
||||
} catch {
|
||||
throw CredentialsProviderError.Failed(message: "Failed to write host to Docker helper!")
|
||||
}
|
||||
inPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
let outputData = try outPipe.fileHandleForReading.readToEnd()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
fileprivate var urlSessionConfiguration: URLSessionConfiguration {
|
||||
fileprivate var urlSession: URLSession = {
|
||||
let config = URLSessionConfiguration.default
|
||||
|
||||
// Harbor expects a CSRF token to be present if the HTTP client
|
||||
@@ -13,14 +13,15 @@ fileprivate var urlSessionConfiguration: URLSessionConfiguration {
|
||||
// [2]: https://github.com/cirruslabs/tart/issues/295
|
||||
config.httpShouldSetCookies = false
|
||||
|
||||
return config
|
||||
}
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
class Fetcher {
|
||||
static func fetch(_ request: URLRequest, viaFile: Bool = false, progress: Progress? = nil) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
|
||||
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
|
||||
let task = urlSession.dataTask(with: request)
|
||||
|
||||
let delegate = Delegate()
|
||||
let session = URLSession(configuration: urlSessionConfiguration, delegate: delegate, delegateQueue: nil)
|
||||
let task = session.dataTask(with: request)
|
||||
task.delegate = delegate
|
||||
|
||||
let stream = AsyncThrowingStream<Data, Error> { continuation in
|
||||
delegate.streamContinuation = continuation
|
||||
|
||||
@@ -57,7 +57,7 @@ class DiskV1: Disk {
|
||||
// Decompress the layers onto the disk in a single stream
|
||||
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||
if let data = data {
|
||||
disk.write(data)
|
||||
try disk.write(contentsOf: data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class DiskV2: Disk {
|
||||
let compressedData = try (data as NSData).compressed(using: .lz4) as Data
|
||||
let compressedDataDigest = Digest.hash(compressedData)
|
||||
|
||||
try await retry(maxAttempts: 5, backoff: .exponentialWithFullJitter(baseDelay: .seconds(5), maxDelay: .seconds(60))) {
|
||||
try await retry(maxAttempts: 5) {
|
||||
if try await !registry.blobExists(compressedDataDigest) {
|
||||
_ = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb, digest: compressedDataDigest)
|
||||
}
|
||||
@@ -208,11 +208,26 @@ class DiskV2: Disk {
|
||||
diskWritingOffset = try zeroSkippingWrite(disk, rdisk, fsBlockSize, diskWritingOffset, data)
|
||||
}
|
||||
|
||||
try await registry.pullBlob(diskLayer.digest) { data in
|
||||
try filter.write(data)
|
||||
var rangeStart: Int64 = 0
|
||||
|
||||
// Update the progress
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
try await retry(maxAttempts: 5) {
|
||||
try await registry.pullBlob(diskLayer.digest, rangeStart: rangeStart) { data in
|
||||
try filter.write(data)
|
||||
|
||||
// Update the progress
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
|
||||
// Update the current range start
|
||||
rangeStart += Int64(data.count)
|
||||
}
|
||||
} recoverFromFailure: { error in
|
||||
if error is URLError {
|
||||
print("Error pulling disk layer \(index + 1): \"\(error.localizedDescription)\", attempting to re-try...")
|
||||
|
||||
return .retry
|
||||
}
|
||||
|
||||
return .throw
|
||||
}
|
||||
|
||||
try filter.finalize()
|
||||
@@ -253,7 +268,7 @@ class DiskV2: Disk {
|
||||
|
||||
if chunk != actualContentsOnDisk {
|
||||
try disk.seek(toOffset: offset)
|
||||
disk.write(chunk)
|
||||
try disk.write(contentsOf: chunk)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +282,7 @@ class DiskV2: Disk {
|
||||
// is zeroed via truncate(2)
|
||||
if chunk != zeroChunk {
|
||||
try disk.seek(toOffset: offset)
|
||||
disk.write(chunk)
|
||||
try disk.write(contentsOf: chunk)
|
||||
}
|
||||
|
||||
offset += UInt64(chunk.count)
|
||||
|
||||
@@ -66,6 +66,11 @@ 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)
|
||||
|
||||
@@ -20,6 +20,7 @@ enum HTTPCode: Int {
|
||||
case Ok = 200
|
||||
case Created = 201
|
||||
case Accepted = 202
|
||||
case PartialContent = 206
|
||||
case Unauthorized = 401
|
||||
case NotFound = 404
|
||||
}
|
||||
@@ -28,14 +29,26 @@ extension Data {
|
||||
func asText() -> String {
|
||||
String(decoding: self, as: UTF8.self)
|
||||
}
|
||||
|
||||
func asTextPreview(limit: Int = 1000) -> String {
|
||||
guard count > limit else {
|
||||
return asText()
|
||||
}
|
||||
|
||||
return "\(asText().prefix(limit))..."
|
||||
}
|
||||
}
|
||||
|
||||
extension AsyncThrowingStream<Data, Error> {
|
||||
func asData() async throws -> Data {
|
||||
func asData(limitBytes: Int64? = nil) async throws -> Data {
|
||||
var result = Data()
|
||||
|
||||
for try await chunk in self {
|
||||
result += chunk
|
||||
|
||||
if let limitBytes, result.count > limitBytes {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -159,7 +172,7 @@ class Registry {
|
||||
body: manifestJSON)
|
||||
if response.statusCode != HTTPCode.Created.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
|
||||
details: data.asText())
|
||||
details: data.asTextPreview())
|
||||
}
|
||||
|
||||
return Digest.hash(manifestJSON)
|
||||
@@ -170,7 +183,7 @@ class Registry {
|
||||
headers: ["Accept": ociManifestMediaType])
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
|
||||
details: data.asText())
|
||||
details: data.asTextPreview())
|
||||
}
|
||||
|
||||
let manifest = try OCIManifest(fromJSON: data)
|
||||
@@ -196,7 +209,7 @@ class Registry {
|
||||
headers: ["Content-Length": "0"])
|
||||
if postResponse.statusCode != HTTPCode.Accepted.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
|
||||
details: data.asText())
|
||||
details: data.asTextPreview())
|
||||
}
|
||||
|
||||
// Figure out where to upload the blob
|
||||
@@ -217,7 +230,7 @@ class Registry {
|
||||
)
|
||||
if response.statusCode != HTTPCode.Created.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
|
||||
code: response.statusCode, details: data.asText())
|
||||
code: response.statusCode, details: data.asTextPreview())
|
||||
}
|
||||
return digest
|
||||
}
|
||||
@@ -240,7 +253,7 @@ class Registry {
|
||||
// always accept both statuses since AWS ECR is not following specification
|
||||
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
|
||||
code: response.statusCode, details: data.asText())
|
||||
code: response.statusCode, details: data.asTextPreview())
|
||||
}
|
||||
uploadedBytes += chunk.count
|
||||
// Update location for the next chunk
|
||||
@@ -259,14 +272,26 @@ class Registry {
|
||||
case HTTPCode.NotFound.rawValue:
|
||||
return false
|
||||
default:
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "checking blob", code: response.statusCode, details: data.asText())
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "checking blob", code: response.statusCode, details: data.asTextPreview())
|
||||
}
|
||||
}
|
||||
|
||||
public func pullBlob(_ digest: String, handler: (Data) async throws -> Void) async throws {
|
||||
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
let body = try await channel.asData().asText()
|
||||
public func pullBlob(_ digest: String, rangeStart: Int64 = 0, handler: (Data) async throws -> Void) async throws {
|
||||
var expectedStatusCode = HTTPCode.Ok
|
||||
var headers: [String: String] = [:]
|
||||
|
||||
// Send Range header and expect HTTP 206 in return
|
||||
//
|
||||
// However, do not send Range header at all when rangeStart is 0,
|
||||
// because it makes no sense and we might get HTTP 200 in return
|
||||
if rangeStart != 0 {
|
||||
expectedStatusCode = HTTPCode.PartialContent
|
||||
headers["Range"] = "bytes=\(rangeStart)-"
|
||||
}
|
||||
|
||||
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), headers: headers, viaFile: true)
|
||||
if response.statusCode != expectedStatusCode.rawValue {
|
||||
let body = try await channel.asData(limitBytes: 4096).asTextPreview()
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
|
||||
details: body)
|
||||
}
|
||||
@@ -329,7 +354,6 @@ class Registry {
|
||||
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile, doAuth: doAuth)
|
||||
|
||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||
_ = try await channel.asData()
|
||||
try await auth(response: response)
|
||||
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile, doAuth: doAuth)
|
||||
}
|
||||
@@ -391,7 +415,7 @@ class Registry {
|
||||
let (data, response) = try await dataRequest(.GET, authenticateURL, headers: headers, doAuth: false)
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.statusCode) "
|
||||
+ "while retrieving an authentication token", details: data.asText())
|
||||
+ "while retrieving an authentication token", details: data.asTextPreview())
|
||||
}
|
||||
|
||||
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
|
||||
|
||||
@@ -127,6 +127,11 @@ 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()]
|
||||
|
||||
@@ -42,4 +42,9 @@ struct Linux: Platform {
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] {
|
||||
// Linux doesn't support trackpad, so just return the regular pointing devices
|
||||
return pointingDevices()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ protocol Platform: Codable {
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||
func keyboards() -> [VZKeyboardConfiguration]
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration]
|
||||
}
|
||||
|
||||
protocol PlatformSuspendable: Platform {
|
||||
|
||||
+23
-18
@@ -50,7 +50,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true,
|
||||
sync: VZDiskImageSynchronizationMode = .full,
|
||||
caching: VZDiskImageCachingMode? = nil
|
||||
caching: VZDiskImageCachingMode? = nil,
|
||||
noTrackpad: Bool = false
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
@@ -71,7 +72,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
audio: audio,
|
||||
clipboard: clipboard,
|
||||
sync: sync,
|
||||
caching: caching
|
||||
caching: caching,
|
||||
noTrackpad: noTrackpad
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -99,16 +101,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Download the IPSW
|
||||
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
|
||||
|
||||
let downloadProgress = Progress(totalUnitCount: 100)
|
||||
ProgressObserver(downloadProgress).log(defaultLogger)
|
||||
|
||||
let request = URLRequest(url: remoteURL)
|
||||
let (channel, response) = try await Fetcher.fetch(request, viaFile: true, progress: downloadProgress)
|
||||
let (channel, response) = try await Fetcher.fetch(request, viaFile: true)
|
||||
|
||||
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)
|
||||
|
||||
let progress = Progress(totalUnitCount: response.expectedContentLength)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
FileManager.default.createFile(atPath: temporaryLocation.path, contents: nil)
|
||||
let lock = try FileLock(lockURL: temporaryLocation)
|
||||
@@ -118,10 +117,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
let digest = Digest()
|
||||
|
||||
for try await chunk in channel {
|
||||
let chunkAsData = Data(chunk)
|
||||
fileHandle.write(chunkAsData)
|
||||
digest.update(chunkAsData)
|
||||
digestProgress.completedUnitCount += Int64(chunk.count)
|
||||
try fileHandle.write(contentsOf: chunk)
|
||||
digest.update(chunk)
|
||||
progress.completedUnitCount += Int64(chunk.count)
|
||||
}
|
||||
|
||||
try fileHandle.close()
|
||||
@@ -302,7 +300,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true,
|
||||
sync: VZDiskImageSynchronizationMode = .full,
|
||||
caching: VZDiskImageCachingMode? = nil
|
||||
caching: VZDiskImageCachingMode? = nil,
|
||||
noTrackpad: Bool = false
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -343,7 +342,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
|
||||
} else {
|
||||
configuration.keyboards = vmConfig.platform.keyboards()
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
if noTrackpad {
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevicesSimplified()
|
||||
} else {
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
}
|
||||
}
|
||||
|
||||
// Networking
|
||||
@@ -355,17 +358,19 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
// Clipboard sharing via Spice agent
|
||||
if clipboard && vmConfig.os == .linux {
|
||||
if clipboard {
|
||||
let spiceAgentConsoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||
let spiceAgentPort = VZVirtioConsolePortConfiguration()
|
||||
spiceAgentPort.name = VZSpiceAgentPortAttachment.spiceAgentPortName
|
||||
spiceAgentPort.attachment = VZSpiceAgentPortAttachment()
|
||||
let spiceAgentPortAttachment = VZSpiceAgentPortAttachment()
|
||||
spiceAgentPortAttachment.sharesClipboard = true
|
||||
spiceAgentPort.attachment = spiceAgentPortAttachment
|
||||
spiceAgentConsoleDevice.ports[0] = spiceAgentPort
|
||||
configuration.consoleDevices.append(spiceAgentConsoleDevice)
|
||||
}
|
||||
|
||||
// Storage
|
||||
let attachment: VZDiskImageStorageDeviceAttachment = try VZDiskImageStorageDeviceAttachment(
|
||||
var attachment = try VZDiskImageStorageDeviceAttachment(
|
||||
url: diskURL,
|
||||
readOnly: false,
|
||||
// When not specified, use "cached" caching mode for Linux VMs to prevent file-system corruption[1]
|
||||
|
||||
@@ -24,7 +24,7 @@ extension VMDirectory {
|
||||
}
|
||||
let configFile = try FileHandle(forWritingTo: configURL)
|
||||
try await registry.pullBlob(configLayers.first!.digest) { data in
|
||||
configFile.write(data)
|
||||
try configFile.write(contentsOf: data)
|
||||
}
|
||||
try configFile.close()
|
||||
|
||||
@@ -79,7 +79,7 @@ extension VMDirectory {
|
||||
}
|
||||
let nvram = try FileHandle(forWritingTo: nvramURL)
|
||||
try await registry.pullBlob(nvramLayers.first!.digest) { data in
|
||||
nvram.write(data)
|
||||
try nvram.write(contentsOf: data)
|
||||
}
|
||||
try nvram.close()
|
||||
|
||||
@@ -87,7 +87,7 @@ extension VMDirectory {
|
||||
try manifest.toJSON().write(to: manifestURL)
|
||||
}
|
||||
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String, concurrency: UInt) async throws -> RemoteName {
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String, concurrency: UInt, labels: [String: String] = [:]) async throws -> RemoteName {
|
||||
var layers = Array<OCIManifestLayer>()
|
||||
|
||||
// Read VM's config and push it as blob
|
||||
@@ -121,7 +121,8 @@ extension VMDirectory {
|
||||
layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest))
|
||||
|
||||
// Craft a stub OCI config for Docker Hub compatibility
|
||||
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
||||
let ociConfigContainer = OCIConfig.ConfigContainer(Labels: labels)
|
||||
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os, config: ociConfigContainer).toJSON()
|
||||
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
|
||||
let manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||
|
||||
@@ -196,7 +196,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await retry(maxAttempts: 5, backoff: .exponentialWithFullJitter(baseDelay: .seconds(5), maxDelay: .seconds(60))) {
|
||||
try await retry(maxAttempts: 5) {
|
||||
// Choose the best base image which has the most deduplication ratio
|
||||
let localLayerCache = try await chooseLocalLayerCache(name, manifest, registry)
|
||||
|
||||
@@ -213,8 +213,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache, deduplicate: deduplicate)
|
||||
} recoverFromFailure: { error in
|
||||
if error is URLError {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
print("Attempting to re-try...")
|
||||
print("Error pulling image: \"\(error.localizedDescription)\", attempting to re-try...")
|
||||
|
||||
return .retry
|
||||
}
|
||||
|
||||
+24
-1
@@ -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-sonoma-base:latest --prepare 'sudo purge && sync'
|
||||
go run cmd/main.go fio --image ghcr.io/cirruslabs/macos-sequoia-base:latest --prepare 'sudo purge && sync'
|
||||
```
|
||||
|
||||
You can also enable the debugging output to diagnose issues:
|
||||
@@ -185,3 +185,26 @@ sync test Tart (--root-disk-opts="sync=none")
|
||||
sync test Tart (--root-disk-opts="caching=cached") 0 B/s 35 MB/s 0 IOPS 15.67 kIOPS 0s ± 0s 11.319µs ± 24.771µs 51.731µs ± 42.208µs
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
Guest:
|
||||
|
||||
* Hardware: [Virtualization.Framework](https://developer.apple.com/documentation/virtualization)
|
||||
* OS: macOS Sonoma 15.3.2
|
||||
* Xcode: 16.2
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
+6
-2
@@ -1,21 +1,24 @@
|
||||
module github.com/cirruslabs/tart/benchmark
|
||||
|
||||
go 1.22.1
|
||||
toolchain go1.24.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.21.0
|
||||
golang.org/x/crypto v0.35.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
|
||||
@@ -23,7 +26,8 @@ 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.18.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+13
-6
@@ -7,6 +7,8 @@ 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=
|
||||
@@ -26,26 +28,31 @@ 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.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
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/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=
|
||||
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=
|
||||
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=
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
executorpkg "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"
|
||||
@@ -46,57 +44,12 @@ func run(cmd *cobra.Command, args []string) error {
|
||||
_ = logger.Sync()
|
||||
}()
|
||||
|
||||
var executorInitializers = []struct {
|
||||
Name string
|
||||
Fn func() (executorpkg.Executor, error)
|
||||
}{
|
||||
{
|
||||
Name: "local",
|
||||
Fn: func() (executorpkg.Executor, error) {
|
||||
return local.New(logger)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Tart",
|
||||
Fn: func() (executorpkg.Executor, error) {
|
||||
return tart.New(cmd.Context(), image, nil, logger)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Tart (--root-disk-opts=\"sync=none\")",
|
||||
Fn: func() (executorpkg.Executor, error) {
|
||||
return tart.New(cmd.Context(), image, []string{
|
||||
"--root-disk-opts",
|
||||
"sync=none",
|
||||
}, logger)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Tart (--root-disk-opts=\"caching=cached\")",
|
||||
Fn: func() (executorpkg.Executor, error) {
|
||||
return tart.New(cmd.Context(), image, []string{
|
||||
"--root-disk-opts",
|
||||
"caching=cached",
|
||||
}, logger)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Tart (--root-disk-opts=\"sync=none,caching=cached\")",
|
||||
Fn: func() (executorpkg.Executor, error) {
|
||||
return tart.New(cmd.Context(), image, []string{
|
||||
"--root-disk-opts",
|
||||
"sync=none,caching=cached",
|
||||
}, logger)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
table := uitable.New()
|
||||
table.AddRow("Name", "Executor", "B/W (read)", "B/W (write)", "I/O (read)", "I/O (write)",
|
||||
"Latency (read)", "Latency (write)", "Latency (sync)")
|
||||
|
||||
for _, benchmark := range benchmarks {
|
||||
for _, executorInitializer := range executorInitializers {
|
||||
for _, executorInitializer := range executorpkg.DefaultInitializers(cmd.Context(), image, logger) {
|
||||
if prepare != "" {
|
||||
shell := "/bin/sh"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package command
|
||||
|
||||
import (
|
||||
"github.com/cirruslabs/tart/benchmark/internal/command/fio"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/command/xcode"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -14,6 +15,7 @@ func NewCommand() *cobra.Command {
|
||||
|
||||
cmd.AddCommand(
|
||||
fio.NewCommand(),
|
||||
xcode.NewCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package xcode
|
||||
|
||||
type Benchmark struct {
|
||||
Name string
|
||||
Command string
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package xcode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Output struct {
|
||||
Started time.Time
|
||||
Ended time.Time
|
||||
}
|
||||
|
||||
func ParseOutput(s string) (*Output, error) {
|
||||
// Ensure that the build has succeeded
|
||||
matched, err := regexp.MatchString("(?m)^\\*\\* BUILD SUCCEEDED \\*\\*.*$", s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse output: regexp failed: %v", err)
|
||||
}
|
||||
if !matched {
|
||||
return nil, fmt.Errorf("failed to parse output: \"** BUILD SUCCEEDED **\" string " +
|
||||
"not found on a separate line, make sure you have Xcode installed")
|
||||
}
|
||||
|
||||
re := regexp.MustCompile("Started\\s+(?P<started>.*)\\n.*Ended\\s+(?P<ended>.*)\\n")
|
||||
|
||||
matches := re.FindStringSubmatch(s)
|
||||
|
||||
if len(matches) != re.NumSubexp()+1 {
|
||||
return nil, fmt.Errorf("failed to parse output: cannot find Started and Ended times")
|
||||
}
|
||||
|
||||
startedRaw := matches[re.SubexpIndex("started")]
|
||||
started, err := time.Parse(time.TimeOnly, startedRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse started time %q: unsupported format", startedRaw)
|
||||
}
|
||||
|
||||
endedRaw := matches[re.SubexpIndex("ended")]
|
||||
ended, err := time.Parse(time.TimeOnly, endedRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse ended time %q: unsupported format", startedRaw)
|
||||
}
|
||||
|
||||
return &Output{
|
||||
Started: started,
|
||||
Ended: ended,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package xcode_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/command/xcode"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseOutput(t *testing.T) {
|
||||
result, err := xcode.ParseOutput(`** BUILD SUCCEEDED ** [219.713 sec]
|
||||
|
||||
System Version: 14.6
|
||||
Xcode 15.4
|
||||
Hardware Overview
|
||||
Model Name: Apple Virtual Machine 1
|
||||
Model Identifier: VirtualMac2,1
|
||||
Total Number of Cores: 4
|
||||
Memory: 8 GB
|
||||
|
||||
✅ XcodeBenchmark has completed
|
||||
1️⃣ Take a screenshot of this window (Cmd + Shift + 4 + Space) and resize to include:
|
||||
- Build Time (See ** BUILD SUCCEEDED ** [XYZ sec])
|
||||
- System Version
|
||||
- Xcode Version
|
||||
- Hardware Overview
|
||||
- Started 13:46:20
|
||||
- Ended 13:50:02
|
||||
- Date Thu Jan 16 13:50:02 UTC 2025
|
||||
|
||||
2️⃣ Share your results at https://github.com/devMEremenko/XcodeBenchmark
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
fmt.Println(result)
|
||||
require.Equal(t, 222*time.Second, result.Ended.Sub(result.Started))
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package xcode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
executorpkg "github.com/cirruslabs/tart/benchmark/internal/executor"
|
||||
"github.com/gosuri/uitable"
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapio"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
var debug bool
|
||||
var image string
|
||||
var prepare string
|
||||
|
||||
func NewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "xcode",
|
||||
Short: "run XCode benchmarks",
|
||||
RunE: run,
|
||||
}
|
||||
|
||||
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(&prepare, "prepare", "", "command to run before running each benchmark")
|
||||
|
||||
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()
|
||||
}()
|
||||
|
||||
table := uitable.New()
|
||||
table.AddRow("Name", "Executor", "Time")
|
||||
|
||||
for _, benchmark := range benchmarks {
|
||||
for _, executorInitializer := range executorpkg.DefaultInitializers(cmd.Context(), image, logger) {
|
||||
if prepare != "" {
|
||||
shell := "/bin/sh"
|
||||
|
||||
if shellFromEnv, ok := os.LookupEnv("SHELL"); ok {
|
||||
shell = shellFromEnv
|
||||
}
|
||||
|
||||
logger.Sugar().Infof("running prepare command %q using shell %q",
|
||||
prepare, shell)
|
||||
|
||||
cmd := exec.CommandContext(cmd.Context(), shell, "-c", prepare)
|
||||
|
||||
loggerWriter := &zapio.Writer{Log: logger, Level: zap.DebugLevel}
|
||||
|
||||
cmd.Stdout = loggerWriter
|
||||
cmd.Stderr = loggerWriter
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to run prepare command %q: %v", prepare, err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Sugar().Infof("initializing executor %s", executorInitializer.Name)
|
||||
|
||||
executor, err := executorInitializer.Fn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Sugar().Infof("running benchmark %q on %s executor", benchmark.Name,
|
||||
executorInitializer.Name)
|
||||
|
||||
stdout, err := executor.Run(cmd.Context(), benchmark.Command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output, err := ParseOutput(string(stdout))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
duration := output.Ended.Sub(output.Started)
|
||||
|
||||
logger.Sugar().Infof("Xcode benchmark duration: %s", duration)
|
||||
|
||||
table.AddRow(benchmark.Name, executorInitializer.Name, duration)
|
||||
|
||||
if err := executor.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close executor %s: %w",
|
||||
executorInitializer.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println(table.String())
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor/local"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor/tart"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Initializer struct {
|
||||
Name string
|
||||
Fn func() (Executor, error)
|
||||
}
|
||||
|
||||
func DefaultInitializers(ctx context.Context, image string, logger *zap.Logger) []Initializer {
|
||||
return []Initializer{
|
||||
{
|
||||
Name: "local",
|
||||
Fn: func() (Executor, error) {
|
||||
return local.New(logger)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Tart",
|
||||
Fn: func() (Executor, error) {
|
||||
return tart.New(ctx, image, nil, logger)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Tart (--root-disk-opts=\"sync=none\")",
|
||||
Fn: func() (Executor, error) {
|
||||
return tart.New(ctx, image, []string{
|
||||
"--root-disk-opts",
|
||||
"sync=none",
|
||||
}, logger)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Tart (--root-disk-opts=\"caching=cached\")",
|
||||
Fn: func() (Executor, error) {
|
||||
return tart.New(ctx, image, []string{
|
||||
"--root-disk-opts",
|
||||
"caching=cached",
|
||||
}, logger)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Tart (--root-disk-opts=\"sync=none,caching=cached\")",
|
||||
Fn: func() (Executor, error) {
|
||||
return tart.New(ctx, image, []string{
|
||||
"--root-disk-opts",
|
||||
"sync=none,caching=cached",
|
||||
}, logger)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,14 @@ 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"
|
||||
)
|
||||
@@ -37,6 +40,23 @@ 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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
+132
-4
@@ -5,6 +5,16 @@ title: Frequently Asked Questions
|
||||
description: Advanced configuration and troubleshooting tips for advanced configurations.
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
```shell
|
||||
log stream --predicate='process=="tart" OR process CONTAINS "Virtualization"' > tart.log
|
||||
```
|
||||
|
||||
While the events are being streamed, attempt to reproduce the issue. Once the issue is reproduced, stop the streaming by pressing Ctrl+C. Then, attach the tart.log file to your report.
|
||||
|
||||
## VM location on disk
|
||||
|
||||
Tart stores all its files in `~/.tart/` directory. Local images that you can run are stored in `~/.tart/vms/`.
|
||||
@@ -13,12 +23,12 @@ Remote images are pulled into `~/.tart/cache/OCIs/`.
|
||||
## Nested virtualization support?
|
||||
|
||||
Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
|
||||
doesn't support nested virtualization.
|
||||
supports nested virtualization only on M3 or M4 chips running macOS 15 (Sequoia). By default, it is disabled, but can be enabled by passing the `--nested` flag to `tart run`.
|
||||
|
||||
## Connecting to a service running on host
|
||||
|
||||
To connect from within a virtual machine to a service running on the host machine
|
||||
please first make sure that the service is binded to `0.0.0.0`.
|
||||
please first make sure that the service is bound to `0.0.0.0`.
|
||||
|
||||
Then from within a virtual machine you can access the service using the router's IP address that you can get either from `Preferences -> Network`
|
||||
or by running the following command in the Terminal:
|
||||
@@ -29,7 +39,7 @@ 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
|
||||
[Softnet](https://github.com/cirruslabs/softnet) (via `tart run --net-softnet <VM NAME>)`, then the network isolation
|
||||
is stricter and it's not only possible to access the host.
|
||||
is stricter and it's not possible to access the host.
|
||||
|
||||
## Changing the default NAT subnet
|
||||
|
||||
@@ -67,6 +77,57 @@ sudo rm /var/db/dhcpd_leases
|
||||
|
||||
And no worries, this file will be re-created on the next `tart run`.
|
||||
|
||||
## Unsupported DHCP client identifiers
|
||||
|
||||
Due to the limitations of the macOS built-in DHCP server, `tart ip` is unable to correctly report the IP addresses for VMs using DHCP client identifiers that are not based on VMs link-layer addresses (MAC addresses).
|
||||
|
||||
By default, when [no `--resolver=arp` is specified](#resolving-the-vms-ip-when-using-bridged-networking), `tart ip` reads the `/var/db/dhcpd_leases` file and tries to find the freshest entry that matches the VM's MAC address (based on the `hw_address` field).
|
||||
|
||||
However, things starts to break when the VM uses a [DUID-EN](https://metebalci.com/blog/a-note-on-dhcpv6-duid-and-prefix-delegation#duid-types) identifier, for example. One of the notorious examples of this being Ubuntu, using this type of identifier by default on latest versions. This results in the `/var/db/dhcpd_leases` entry for Ubuntu appearing as follows:
|
||||
|
||||
```ini
|
||||
{
|
||||
name=ubuntu
|
||||
ip_address=192.168.64.3
|
||||
hw_address=ff,f1:f5:dd:7f:0:2:0:0:ab:11:cb:fb:30:b0:97:b6:3a:67
|
||||
identifier=ff,f1:f5:dd:7f:0:2:0:0:ab:11:cb:fb:30:b0:97:b6:3a:67
|
||||
lease=0x678e2ce7
|
||||
}
|
||||
```
|
||||
|
||||
Because the macOS built-in DHCP server overwrites the `hw_address` with the `identifier`, it leaves no information about the VM's MAC address to the `tart ip`.
|
||||
|
||||
To avoid this issue, make sure that your VM only sends a DHCP client identifier (option 61) with link-layer address (MAC address) or that it doesn't send this option at all.
|
||||
|
||||
For the aforementioned Ubuntu, the solution is outlined in the section [How to integrate with Windows DHCP Server](https://netplan.readthedocs.io/en/stable/examples/#how-to-integrate-with-windows-dhcp-server) of Canonical Netplan's documentation:
|
||||
|
||||
```yaml
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
enp3s0:
|
||||
dhcp4: yes
|
||||
dhcp-identifier: mac
|
||||
```
|
||||
|
||||
## Resolving the VM's IP when using bridged networking
|
||||
|
||||
When running `tart run` with `--net-bridged`, you need to invoke `tart ip` differently, because the macOS built-in DHCP server won't have any information about the VM's IP-address:
|
||||
|
||||
```shell
|
||||
tart ip --resolver=arp <VM>
|
||||
```
|
||||
|
||||
This causes the `tart ip` to consult the host's ARP table instead of the `/var/db/dhcpd_leases` file.
|
||||
|
||||
Note that this method of resolving the IP heavily relies on the level of VM's activity on the network, namely, exchanging ARP requests between the guest and the host.
|
||||
|
||||
This is normally not an issue for macOS VMs, but on Linux VMs you might need to install Samba, which includes a [NetBIOS name server](https://www.samba.org/samba/docs/current/man-html/nmbd.8.html) and exhibits the same behavior as macOS, resulting in the population of the ARP table of the host OS:
|
||||
|
||||
```shell
|
||||
sudo apt-get install samba
|
||||
```
|
||||
|
||||
## Running login/clone/pull/push commands over SSH
|
||||
|
||||
When invoking the Tart in an SSH session, you might get error like this:
|
||||
@@ -97,7 +158,7 @@ or features supported. If there is some feature missing please don't hesitate to
|
||||
Instead of Anka Registry, Tart can work with any OCI-compatible container registry. This provides a much more consistent
|
||||
and scalable experience for distributing virtual machines.
|
||||
|
||||
Tart doesn't yet have an analogue of Anka Controller for managing long living VMs but [soon will be](https://github.com/cirruslabs/tart/issues/372).
|
||||
Tart does have an analogue of Anka Controller for managing VMs across a cluster of Mac hosts called [Orchard](orchard/quick-start.md).
|
||||
|
||||
## Automatic pruning
|
||||
|
||||
@@ -116,3 +177,70 @@ 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:
|
||||
|
||||
{width="640" .center}
|
||||
|
||||
When the recovery OS boots, open the Terminal app:
|
||||
|
||||
{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.
|
||||
|
||||
@@ -18,9 +18,9 @@ steps:
|
||||
- command: uname -a
|
||||
plugins:
|
||||
- cirruslabs/tart#main:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-base:latest
|
||||
image: 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`.
|
||||
This will run `uname -r` in a macOS Tart VM cloned from `ghcr.io/cirruslabs/macos-sequoia-base:latest`.
|
||||
|
||||
See plugin's [Configuration section](https://github.com/cirruslabs/tart-buildkite-plugin#configuration) for the full list of available options.
|
||||
|
||||
@@ -18,7 +18,7 @@ task:
|
||||
name: hello
|
||||
macos_instance:
|
||||
# can be a remote or a local virtual machine
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-base:latest
|
||||
image: ghcr.io/cirruslabs/macos-sequoia-base:latest
|
||||
hello_script:
|
||||
- echo "Hello from within a Tart VM!"
|
||||
- echo "Here is my CPU info:"
|
||||
@@ -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-sonoma-xcode:latest
|
||||
image: ghcr.io/cirruslabs/macos-sequoia-xcode:latest
|
||||
build_script: swift build --product tart
|
||||
binary_artifacts:
|
||||
path: .build/debug/tart
|
||||
|
||||
@@ -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-sonoma-base:latest
|
||||
image: ghcr.io/cirruslabs/macos-sequoia-base:latest
|
||||
|
||||
test:
|
||||
tags:
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Use Packer to build custom VM images, configure VMs and work with r
|
||||
|
||||
## Creating from scratch
|
||||
|
||||
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regarding of the underlying OS a particular VM image has.
|
||||
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regardless of the underlying OS a particular VM image has.
|
||||
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
|
||||
|
||||
### Creating a macOS VM image from scratch
|
||||
@@ -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 sonoma-vanilla
|
||||
tart run sonoma-vanilla
|
||||
tart create --from-ipsw=latest sequoia-vanilla
|
||||
tart run sequoia-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-sonoma-base:latest"
|
||||
vm_name = "my-custom-sonoma"
|
||||
vm_base_name = "ghcr.io/cirruslabs/macos-sequoia-base:latest"
|
||||
vm_name = "my-custom-sequoia"
|
||||
cpu_count = 4
|
||||
memory_gb = 8
|
||||
disk_size_gb = 70
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
## Architecture
|
||||
|
||||
Orchard cluster consists of two components:
|
||||
Orchard cluster consists of three 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 or [an API consumer](/orchard/integration-guide)
|
||||
* Client — responsible for creating, modifying and removing the resources on the Controller, can either be an [Orchard CLI](/orchard/using-orchard-cli) or [an API consumer](/orchard/integration-guide)
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -14,6 +14,47 @@ 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/2024/06/20/jumping-through-the-hoops-ssh-jump-host-functionality-in-orchard/) 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.
|
||||
@@ -31,7 +72,13 @@ gcloud compute addresses create orchard-ip --region=us-central1
|
||||
export ORCHARD_IP=$(gcloud compute addresses describe orchard-ip --format='value(address)' --region=us-central1)
|
||||
```
|
||||
|
||||
Once we have the IP address, we can create a new instance with Orchard Controller running inside a container:
|
||||
Then, ensure that there exist a firewall rule targeting `https-server` tag and allowing access to TCP port 443. If that's not the case, create one:
|
||||
|
||||
```shell
|
||||
gcloud compute firewall-rules create default-allow-https --direction=INGRESS --priority=1000 --network=default --action=ALLOW --rules=tcp:443 --source-ranges=0.0.0.0/0 --target-tags=https-server
|
||||
```
|
||||
|
||||
Once we have the IP address and the firewall rule set up, we can create a new instance with Orchard Controller running inside a container:
|
||||
|
||||
```bash
|
||||
gcloud compute instances create-with-container orchard-controller \
|
||||
|
||||
@@ -51,8 +51,6 @@ 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>
|
||||
@@ -60,6 +58,8 @@ 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>
|
||||
|
||||
@@ -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-sonoma-base:latest",
|
||||
"image": "ghcr.io/cirruslabs/macos-sequoia-base:latest",
|
||||
"cpu": 4,
|
||||
"memory": 4096,
|
||||
"startup_script": {
|
||||
@@ -144,7 +144,7 @@ func main() {
|
||||
Meta: v1.Meta{
|
||||
Name: vmName,
|
||||
},
|
||||
Image: "ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||
Image: "ghcr.io/cirruslabs/macos-sequoia-base:latest",
|
||||
CPU: 4,
|
||||
Memory: 4096,
|
||||
StartupScript: &v1.VMScript{
|
||||
|
||||
@@ -26,7 +26,7 @@ more information.
|
||||
Now, let's create a Virtual Machine:
|
||||
|
||||
```shell
|
||||
orchard create vm --image ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
|
||||
orchard create vm --image ghcr.io/cirruslabs/macos-sequoia-base:latest sequoia-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 sonoma-base
|
||||
orchard ssh vm sequoia-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 sonoma-base "uname -a"
|
||||
orchard ssh vm sequoia-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 sonoma-base "bash -s" < script.sh
|
||||
orchard ssh vm sequoia-base "bash -s" < script.sh
|
||||
```
|
||||
|
||||
### VNC
|
||||
@@ -73,7 +73,7 @@ orchard ssh vm sonoma-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 sonoma-base
|
||||
orchard vnc vm sequoia-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 sonoma-base
|
||||
orchard delete vm sequoia-base
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
## 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.
|
||||
+13
-9
@@ -9,18 +9,18 @@ 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-sonoma-base:latest sonoma-base
|
||||
tart run sonoma-base
|
||||
tart clone ghcr.io/cirruslabs/macos-sequoia-base:latest sequoia-base
|
||||
tart run sequoia-base
|
||||
```
|
||||
|
||||
??? info "Manual installation from a release archive"
|
||||
It's also possible to manually install `tart` binary from the latest released archive:
|
||||
|
||||
```bash
|
||||
curl -LO https://github.com/cirruslabs/tart/releases/latest/download/tart-arm64.tar.gz
|
||||
tar -xzvf tart-arm64.tar.gz
|
||||
./tart.app/Contents/MacOS/tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
|
||||
./tart.app/Contents/MacOS/tart run sonoma-base
|
||||
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
|
||||
```
|
||||
|
||||
Please note that `./tart.app/Contents/MacOS/tart` binary is required to be used in order to trick macOS
|
||||
@@ -34,6 +34,10 @@ tart run sonoma-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`
|
||||
@@ -82,7 +86,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 sonoma-base)
|
||||
ssh admin@$(tart ip sequoia-base)
|
||||
```
|
||||
|
||||
!!! tip "Running scripts inside Tart virtual machines"
|
||||
@@ -91,8 +95,8 @@ ssh admin@$(tart ip sonoma-base)
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/sshpass
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sonoma-base) "uname -a"
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sonoma-base) < script.sh
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sequoia-base) "uname -a"
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sequoia-base) < script.sh
|
||||
```
|
||||
|
||||
## Mounting directories
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
source = [
|
||||
".build/x86_64-apple-macosx/release/tart",
|
||||
".build/arm64-apple-macosx/release/tart"
|
||||
]
|
||||
source = [ "dist/tart_darwin_all/tart.app/Contents/MacOS/tart" ]
|
||||
bundle_id = "com.github.cirruslabs.tart"
|
||||
|
||||
apple_id {
|
||||
|
||||
@@ -102,6 +102,7 @@ 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,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
# helper script to build and run a signed tart binary
|
||||
# usage: ./scripts/run-signed.sh run sonoma-base
|
||||
# usage: ./scripts/run-signed.sh run sequoia-base
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
Reference in New Issue
Block a user