Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1afaa7ec7a | ||
|
|
826e508646 | ||
|
|
8653ca4115 | ||
|
|
63b74f407b | ||
|
|
3a2cba6929 | ||
|
|
7592b86663 | ||
|
|
e8dbb86fc0 | ||
|
|
d2ed4ef801 | ||
|
|
2014de7dac | ||
|
|
1f23b24920 | ||
|
|
6ce4a06089 | ||
|
|
1a2f187ac8 | ||
|
|
285bf9b6c2 | ||
|
|
415ed3388d | ||
|
|
870b414994 | ||
|
|
be7011bf11 | ||
|
|
859050cb42 | ||
|
|
4f321ec264 | ||
|
|
1b53ce42f8 | ||
|
|
e89ef32a83 | ||
|
|
62a34bf89f | ||
|
|
0608b2b9d1 | ||
|
|
91e859de9b | ||
|
|
c79da6a12b | ||
|
|
2b7ca12324 | ||
|
|
9016fcfdd4 | ||
|
|
546238d9df | ||
|
|
2b6818c493 | ||
|
|
cf49fd10b6 | ||
|
|
59b3e0c0fb | ||
|
|
7bbdfc06e7 | ||
|
|
637c54e1d1 | ||
|
|
e611d97b69 | ||
|
|
8e11bbe1cd | ||
|
|
6de31de6bf | ||
|
|
37ae7888e6 | ||
|
|
64482f4345 | ||
|
|
4f70d01dd6 | ||
|
|
9098eaf024 | ||
|
|
337d95ac95 | ||
|
|
3eb8ae2aa5 | ||
|
|
b03408f856 | ||
|
|
c749bdeaf1 | ||
|
|
8e75a59d54 | ||
|
|
1d3aa5ac81 | ||
|
|
261c1806df | ||
|
|
92a4b3164d | ||
|
|
3fdf82079a | ||
|
|
a05684157e | ||
|
|
e62e921eec | ||
|
|
e1bb565c3b | ||
|
|
9b26d30c42 | ||
|
|
ab32cb7e60 | ||
|
|
ea6fd814f5 | ||
|
|
e72fcd9b19 | ||
|
|
33a51b7344 | ||
|
|
5da1a085d3 | ||
|
|
f62949b6f4 | ||
|
|
d7561cab0b | ||
|
|
066f585c53 | ||
|
|
d8ac36b3bd | ||
|
|
4a454a3115 | ||
|
|
94e9425db7 | ||
|
|
0ab0fe23c5 | ||
|
|
0a970462e8 | ||
|
|
5b8d1d1168 |
@@ -5,7 +5,7 @@ set -e
|
||||
export VERSION="${CIRRUS_TAG:-0}"
|
||||
|
||||
mkdir -p .ci/pkg/
|
||||
cp .build/arm64-apple-macosx/debug/tart .ci/pkg/tart
|
||||
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
|
||||
cp Resources/embedded.provisionprofile .ci/pkg/embedded.provisionprofile
|
||||
pkgbuild --root .ci/pkg/ --identifier com.github.cirruslabs.tart --version $VERSION \
|
||||
--scripts .ci/pkg/scripts \
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use_compute_credits: true
|
||||
|
||||
env:
|
||||
XCODE_TAG: 15-beta-2
|
||||
|
||||
task:
|
||||
name: Test on Ventura
|
||||
alias: test
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
persistent_worker:
|
||||
labels:
|
||||
name: dev-mini
|
||||
@@ -27,8 +31,9 @@ task:
|
||||
task:
|
||||
name: Lint
|
||||
alias: lint
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||
lint_script:
|
||||
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
||||
always:
|
||||
@@ -37,24 +42,26 @@ task:
|
||||
format: swiftformat
|
||||
|
||||
task:
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
name: Build
|
||||
alias: build
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||
build_script: swift build --product tart
|
||||
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
binary_artifacts:
|
||||
path: .build/debug/tart
|
||||
|
||||
task:
|
||||
name: Release (Dry Run)
|
||||
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
||||
name: Release (Dry Run)
|
||||
depends_on:
|
||||
- lint
|
||||
- build
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
@@ -80,8 +87,6 @@ task:
|
||||
always:
|
||||
dist_artifacts:
|
||||
path: "dist/*"
|
||||
pkg_artifacts:
|
||||
path: ".ci/*.pkg"
|
||||
|
||||
task:
|
||||
name: Release
|
||||
@@ -90,8 +95,9 @@ task:
|
||||
- lint
|
||||
- test
|
||||
- build
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
@@ -118,13 +124,15 @@ task:
|
||||
- swift -version
|
||||
release_script: goreleaser
|
||||
upload_sentry_debug_files_script:
|
||||
- cd .build/arm64-apple-macosx/debug/
|
||||
- cd .build/arm64-apple-macosx/release/
|
||||
# Generate and upload symbols
|
||||
- dsymutil tart
|
||||
- sentry-cli debug-files upload tart.dSYM/
|
||||
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.dSYM/
|
||||
# Bundle and upload sources
|
||||
- sentry-cli debug-files bundle-sources tart.dSYM
|
||||
- sentry-cli debug-files upload tart.src.zip
|
||||
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.src.zip
|
||||
create_sentry_release_script:
|
||||
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
|
||||
- sentry-cli releases new $SENTRY_RELEASE
|
||||
@@ -134,6 +142,7 @@ task:
|
||||
task:
|
||||
name: Deploy Documentation
|
||||
only_if: $CIRRUS_BRANCH == 'main'
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
container:
|
||||
image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest
|
||||
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
|
||||
|
||||
@@ -3,11 +3,10 @@ project_name: tart
|
||||
before:
|
||||
hooks:
|
||||
- .ci/set-version.sh
|
||||
- swift build -c debug --product tart
|
||||
- xattr -cr .build/arm64-apple-macosx/debug/tart
|
||||
- swift build -c release --product tart
|
||||
- gon gon.hcl
|
||||
- codesign --verify --deep --strict .build/arm64-apple-macosx/debug/tart
|
||||
- ./.ci/create-pkg.sh
|
||||
- mkdir -p tart.app/Contents/MacOS
|
||||
- cp .build/arm64-apple-macosx/release/tart tart.app/Contents/MacOS/
|
||||
|
||||
builds:
|
||||
- builder: prebuilt
|
||||
@@ -15,23 +14,20 @@ builds:
|
||||
- darwin
|
||||
goarch:
|
||||
- arm64
|
||||
binary: tart.app/Contents/MacOS/tart
|
||||
prebuilt:
|
||||
path: .build/arm64-apple-macosx/debug/tart
|
||||
path: tart.app/Contents/MacOS/tart
|
||||
|
||||
archives:
|
||||
- name_template: "{{ .ProjectName }}"
|
||||
format: zip
|
||||
files:
|
||||
- src: Resources/embedded.provisionprofile
|
||||
dst: tart.app/Contents
|
||||
strip_parent: true
|
||||
hooks:
|
||||
after:
|
||||
- xcrun notarytool submit "dist/tart.zip" --keychain-profile "notarytool" --wait
|
||||
- LICENSE
|
||||
|
||||
release:
|
||||
prerelease: auto
|
||||
extra_files:
|
||||
- glob: ./.ci/Tart-{{ .Tag }}.pkg
|
||||
|
||||
brews:
|
||||
- name: tart
|
||||
@@ -46,9 +42,7 @@ brews:
|
||||
dependencies:
|
||||
- "cirruslabs/cli/softnet"
|
||||
install: |
|
||||
mkdir_p libexec/"tart.app/Contents/MacOS/"
|
||||
libexec.install "tart" => "tart.app/Contents/MacOS/tart"
|
||||
libexec.install "embedded.provisionprofile" => "tart.app/Contents/embedded.provisionprofile"
|
||||
libexec.install Dir["*"]
|
||||
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
|
||||
custom_block: |
|
||||
depends_on :macos => :monterey
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="sign debug" type="ShConfigurationType">
|
||||
<option name="SCRIPT_TEXT" value="codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart" />
|
||||
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/sign.sh" />
|
||||
<option name="SCRIPT_OPTIONS" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||
<option name="INTERPRETER_PATH" value="/bin/zsh" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||
<option name="EXECUTE_SCRIPT_FILE" value="false" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -1,8 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="tart create" type="SwiftPackageManagerRunConfiguration" factoryName="Swift Package Run" PROGRAM_PARAMS="create latest --from-ipsw=latest" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" PASS_PARENT_ENVS_2="true" PROJECT_NAME="tart" TARGET_NAME="tart" CONFIG_NAME="tart" RUN_TARGET_PROJECT_NAME="tart" RUN_TARGET_NAME="tart" WAS_MODIFIED="">
|
||||
<method v="2">
|
||||
<option name="SPM.BUILD_TASK_PROVIDER" enabled="true" />
|
||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="sign debug" run_configuration_type="ShConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -1,8 +0,0 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="tart run" type="SwiftPackageManagerRunConfiguration" factoryName="Swift Package Run" PROGRAM_PARAMS="run latest" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" PASS_PARENT_ENVS_2="true" PROJECT_NAME="tart" TARGET_NAME="tart" CONFIG_NAME="tart" RUN_TARGET_PROJECT_NAME="tart" RUN_TARGET_NAME="tart" WAS_MODIFIED="">
|
||||
<method v="2">
|
||||
<option name="SPM.BUILD_TASK_PROVIDER" enabled="true" />
|
||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="sign debug" run_configuration_type="ShConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -23,8 +23,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "c3c19e29f775ee95b77aa3168f3e2fd6c20deba6",
|
||||
"version" : "7.31.3"
|
||||
"revision" : "d277532e1c8af813981ba01f591b15bbdd735615",
|
||||
"version" : "8.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -81,6 +81,15 @@
|
||||
"version" : "1.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-sysctl",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sersoft-gmbh/swift-sysctl.git",
|
||||
"state" : {
|
||||
"revision" : "71fd64ee84819bb19fbecfb36d5a4503726b6fb7",
|
||||
"version" : "1.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftdate",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -18,8 +18,9 @@ let package = Package(
|
||||
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "7.31.3"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.8.0"),
|
||||
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.0.0"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
@@ -32,6 +33,7 @@ let package = Package(
|
||||
.product(name: "Atomics", package: "swift-atomics"),
|
||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||
.product(name: "TextTable", package: "TextTable"),
|
||||
.product(name: "Sysctl", package: "swift-sysctl"),
|
||||
], exclude: [
|
||||
"OCI/Reference/Makefile",
|
||||
"OCI/Reference/Reference.g4",
|
||||
|
||||
@@ -31,12 +31,21 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
||||
<a href="https://krisp.ai/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://mullvad.net/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://suran.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://symflower.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://transloadit.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://www.pitsdatarecovery.net/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
|
||||
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
@@ -31,7 +31,6 @@ struct Clone: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
@@ -44,10 +43,17 @@ struct Clone: AsyncParsableCommand {
|
||||
try lock.lock()
|
||||
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
&& sourceVM.state() != "suspended"
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
|
||||
try lock.unlock()
|
||||
|
||||
// APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
|
||||
// is not actually claiming new space until the VM is started and it writes something to disk.
|
||||
// So once we clone the VM let's try to claim a little bit of space for the VM to run.
|
||||
try Prune.reclaimIfNeeded(UInt64(sourceVM.sizeBytes()))
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Export: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Export VM to a file")
|
||||
static var configuration = CommandConfiguration(abstract: "Export VM to a compressed .tvm file")
|
||||
|
||||
@Argument(help: "Source VM name.")
|
||||
var name: String
|
||||
|
||||
@Argument(help: "Path to the destination file.")
|
||||
var path: String
|
||||
var path: String?
|
||||
|
||||
func run() async throws {
|
||||
let correctedPath: String
|
||||
|
||||
if let path = path {
|
||||
correctedPath = path
|
||||
} else {
|
||||
correctedPath = "\(name).tvm"
|
||||
|
||||
if FileManager.default.fileExists(atPath: correctedPath) {
|
||||
while true {
|
||||
if userWantsOverwrite(correctedPath) {
|
||||
break
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("exporting...")
|
||||
try VMStorageHelper.open(name).exportToArchive(path: path)
|
||||
|
||||
try VMStorageHelper.open(name).exportToArchive(path: correctedPath)
|
||||
}
|
||||
|
||||
func userWantsOverwrite(_ filename: String) -> Bool {
|
||||
print("file \(filename) already exists, are you sure you want to overwrite it? (yes, [no])? ", terminator: "")
|
||||
|
||||
let answer = readLine()!
|
||||
|
||||
return answer == "yes"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ fileprivate struct VMInfo: Encodable {
|
||||
let Disk: Int
|
||||
let Display: String
|
||||
let Running: Bool
|
||||
let State: String
|
||||
}
|
||||
|
||||
struct Get: AsyncParsableCommand {
|
||||
@@ -23,9 +24,9 @@ struct Get: AsyncParsableCommand {
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let diskSizeInGb = try vmDir.sizeGB()
|
||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||
let running = try PIDLock(lockURL: vmDir.configURL).pid() > 0
|
||||
|
||||
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb, Display: vmConfig.display.description, Running: running)
|
||||
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb,
|
||||
Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state())
|
||||
print(format.renderSingle(info))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import Network
|
||||
import SystemConfiguration
|
||||
import Sentry
|
||||
|
||||
enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
||||
case dhcp, arp
|
||||
|
||||
private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"}
|
||||
}
|
||||
|
||||
struct IP: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
||||
|
||||
@@ -13,46 +19,51 @@ struct IP: AsyncParsableCommand {
|
||||
@Option(help: "Number of seconds to wait for a potential VM booting")
|
||||
var wait: UInt16 = 0
|
||||
|
||||
@Option(help: ArgumentHelp("Strategy for resolving IP address: dhcp or arp",
|
||||
discussion: """
|
||||
By default, Tart is looking up and parsing DHCP lease file to determine the IP of the VM.\n
|
||||
This method is fast and the most reliable but only returns local IP adresses.\n
|
||||
Alternatively, Tart can call external `arp` executable and parse it's output.\n
|
||||
In case of enabled Bridged Networking this method will return VM's IP address on the network interface used for Bridged Networking.\n
|
||||
Note that `arp` strategy won't work for VMs using `--net-softnet`.
|
||||
"""))
|
||||
var resolver: IPResolutionStrategy = .dhcp
|
||||
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
|
||||
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
||||
throw RuntimeError.NoIPAddressFound("no IP address found, is your VM running?")
|
||||
}
|
||||
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait) else {
|
||||
var message = "no IP address found"
|
||||
|
||||
let arpCache = try ARPCache()
|
||||
|
||||
if let ipViaARP = try arpCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
||||
// Capture the warning into Sentry
|
||||
SentrySDK.capture(message: "DHCP lease and ARP cache entries for a single MAC address differ") { scope in
|
||||
scope.setLevel(.warning)
|
||||
|
||||
scope.setContext(value: [
|
||||
"MAC address": vmMACAddress,
|
||||
"IP via ARP": ipViaARP,
|
||||
"IP via DHCP": ipViaDHCP,
|
||||
], key: "Address conflict details")
|
||||
|
||||
scope.add(Attachment(path: "/var/db/dhcpd_leases", filename: "dhcpd_leases.txt", contentType: "text/plain"))
|
||||
scope.add(Attachment(data: arpCache.arpCommandOutput, filename: "arp-an-output.txt", contentType: "text/plain"))
|
||||
if try !vmDir.running() {
|
||||
message += ", is your VM running?"
|
||||
}
|
||||
|
||||
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
||||
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
||||
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
||||
if (vmConfig.os == .linux && resolver == .arp) {
|
||||
message += " (not all Linux distributions are compatible with the ARP resolver)"
|
||||
}
|
||||
|
||||
throw RuntimeError.NoIPAddressFound(message)
|
||||
}
|
||||
|
||||
print(ipViaDHCP)
|
||||
print(ip)
|
||||
}
|
||||
|
||||
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
|
||||
static public func resolveIP(_ vmMACAddress: MACAddress, resolutionStrategy: IPResolutionStrategy = .dhcp, secondsToWait: UInt16 = 0) async throws -> IPv4Address? {
|
||||
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
||||
|
||||
repeat {
|
||||
if let leases = try Leases(), let ip = try leases.resolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
switch resolutionStrategy {
|
||||
case .arp:
|
||||
if let ip = try ARPCache().ResolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
}
|
||||
case .dhcp:
|
||||
if let leases = try Leases(), let ip = try leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
// wait a second
|
||||
|
||||
@@ -2,7 +2,7 @@ import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Import: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Import VM from a file")
|
||||
static var configuration = CommandConfiguration(abstract: "Import VM from a compressed .tvm file")
|
||||
|
||||
@Argument(help: "Path to a file created with \"tart export\".")
|
||||
var path: String
|
||||
|
||||
@@ -6,6 +6,8 @@ fileprivate struct VMInfo: Encodable {
|
||||
let Source: String
|
||||
let Name: String
|
||||
let Size: Int
|
||||
let Running: Bool
|
||||
let State: String
|
||||
}
|
||||
|
||||
struct List: AsyncParsableCommand {
|
||||
@@ -32,17 +34,19 @@ struct List: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
var infos: [VMInfo] = []
|
||||
|
||||
if source == nil || source == "local" {
|
||||
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
||||
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB())
|
||||
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
})
|
||||
}
|
||||
|
||||
if source == nil || source == "oci" {
|
||||
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
||||
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB())
|
||||
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
})
|
||||
}
|
||||
|
||||
if (quiet) {
|
||||
for info in infos {
|
||||
print(info.Name)
|
||||
|
||||
@@ -35,6 +35,9 @@ struct Login: AsyncParsableCommand {
|
||||
|
||||
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
||||
password = String(decoding: passwordData, as: UTF8.self)
|
||||
|
||||
// Support "echo $PASSWORD | tart login --username $USERNAME --password-stdin $REGISTRY"
|
||||
password.trimSuffix { c in c.isNewline }
|
||||
} else {
|
||||
(user, password) = try StdinCredentials.retrieve()
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ struct Prune: AsyncParsableCommand {
|
||||
// Clean up cache entries based on last accessed date
|
||||
if let olderThan = olderThan {
|
||||
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
||||
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
|
||||
let olderThanDate = Date() - olderThanInterval
|
||||
|
||||
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
|
||||
}
|
||||
@@ -77,7 +77,50 @@ struct Prune: AsyncParsableCommand {
|
||||
try prunablesToDelete.forEach { try $0.delete() }
|
||||
}
|
||||
|
||||
static func pruneReclaim(reclaimBytes: UInt64) throws {
|
||||
static func reclaimIfNeeded(_ requiredBytes: UInt64) throws {
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
|
||||
}
|
||||
|
||||
// Figure out how much disk space is available
|
||||
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [
|
||||
.volumeAvailableCapacityKey,
|
||||
.volumeAvailableCapacityForImportantUsageKey
|
||||
])
|
||||
let volumeAvailableCapacityCalculated = max(
|
||||
UInt64(attrs.volumeAvailableCapacity!),
|
||||
UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
|
||||
)
|
||||
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: [
|
||||
"volumeAvailableCapacity": attrs.volumeAvailableCapacity!,
|
||||
"volumeAvailableCapacityForImportantUsage": attrs.volumeAvailableCapacityForImportantUsage!,
|
||||
"volumeAvailableCapacityCalculated": volumeAvailableCapacityCalculated
|
||||
], key: "Prune")
|
||||
}
|
||||
|
||||
if volumeAvailableCapacityCalculated <= 0 {
|
||||
SentrySDK.capture(message: "Zero volume capacity reported") { scope in
|
||||
scope.setLevel(.warning)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Now that we know how much free space is left,
|
||||
// check if we even need to reclaim anything
|
||||
if requiredBytes < volumeAvailableCapacityCalculated {
|
||||
return
|
||||
}
|
||||
|
||||
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated)
|
||||
}
|
||||
|
||||
private static func reclaimIfPossible(_ reclaimBytes: UInt64) throws {
|
||||
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
|
||||
defer { transaction.finish() }
|
||||
|
||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||
let prunables: [Prunable] = try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
@@ -98,10 +141,11 @@ struct Prune: AsyncParsableCommand {
|
||||
break
|
||||
}
|
||||
|
||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||
try prunable.delete()
|
||||
try SentrySDK.span?.setData(value: prunable.sizeBytes(), key: prunable.url.path)
|
||||
|
||||
try SentrySDK.span?.setExtra(value: prunable.sizeBytes(), key: prunable.url.path);
|
||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||
|
||||
try prunable.delete()
|
||||
}
|
||||
|
||||
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||
|
||||
@@ -6,7 +6,7 @@ import Compression
|
||||
struct Push: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
|
||||
|
||||
@Argument(help: "local VM name")
|
||||
@Argument(help: "local or remote VM name")
|
||||
var localName: String
|
||||
|
||||
@Argument(help: "remote VM name(s)")
|
||||
@@ -28,7 +28,8 @@ struct Push: AsyncParsableCommand {
|
||||
var populateCache: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
let localVMDir = try VMStorageLocal().open(localName)
|
||||
let ociStorage = VMStorageOCI()
|
||||
let localVMDir = try VMStorageHelper.open(localName)
|
||||
|
||||
// Parse remote names supplied by the user
|
||||
let remoteNames = try remoteNames.map{
|
||||
@@ -53,23 +54,55 @@ struct Push: AsyncParsableCommand {
|
||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||
|
||||
let pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||
registry: registry,
|
||||
references: remoteNamesForRegistry.map{ $0.reference.value },
|
||||
chunkSizeMb: chunkSize
|
||||
)
|
||||
let references = remoteNamesForRegistry.map{ $0.reference.value }
|
||||
|
||||
// Populate the local cache (if requested)
|
||||
let pushedRemoteName: RemoteName
|
||||
// If we're pushing a local OCI VM, check if points to an already existing registry manifest
|
||||
// and if so, only upload manifests (without config, disk and NVRAM) to the user-specified references
|
||||
if let remoteName = try? RemoteName(localName) {
|
||||
pushedRemoteName = try await lightweightPushToRegistry(
|
||||
registry: registry,
|
||||
remoteName: remoteName,
|
||||
references: references
|
||||
)
|
||||
} else {
|
||||
pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||
registry: registry,
|
||||
references: references,
|
||||
chunkSizeMb: chunkSize
|
||||
)
|
||||
// Populate the local cache (if requested)
|
||||
if populateCache {
|
||||
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
||||
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
||||
}
|
||||
}
|
||||
|
||||
// link the rest remote names
|
||||
if populateCache {
|
||||
let ociStorage = VMStorageOCI()
|
||||
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
||||
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
||||
for remoteName in remoteNamesForRegistry {
|
||||
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lightweightPushToRegistry(registry: Registry, remoteName: RemoteName, references: [String]) async throws -> RemoteName {
|
||||
// Is the local OCI VM already present in the registry?
|
||||
let digest = try VMStorageOCI().digest(remoteName)
|
||||
|
||||
let (remoteManifest, _) = try await registry.pullManifest(reference: digest)
|
||||
|
||||
// Overwrite registry's references with the retrieved manifest
|
||||
for reference in references {
|
||||
defaultLogger.appendNewLine("pushing manifest for \(reference)...")
|
||||
|
||||
_ = try await registry.pushManifest(reference: reference, manifest: remoteManifest)
|
||||
}
|
||||
|
||||
return RemoteName(host: registry.host!, namespace: registry.namespace,
|
||||
reference: Reference(digest: digest))
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection where Element == RemoteName {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ArgumentParser
|
||||
import Cocoa
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
@@ -17,13 +18,24 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Don't open a UI window.",
|
||||
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
|
||||
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
|
||||
var noGraphics: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Open serial console in /dev/ttySXX",
|
||||
discussion: "Useful for debugging Linux Kernel."))
|
||||
var serial: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp(
|
||||
"Attach an externally created serial console",
|
||||
discussion: "Alternative to `--serial` flag for programmatic integrations."
|
||||
))
|
||||
var serialPath: String?
|
||||
|
||||
@Flag(help: "Force open a UI window, even when VNC is enabled.")
|
||||
var graphics: Bool = false
|
||||
|
||||
@Flag(help: "Boot into recovery mode")
|
||||
@Flag(help: "Boot into recovery mode")
|
||||
var recovery: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
@@ -81,10 +93,14 @@ struct Run: AsyncParsableCommand {
|
||||
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
|
||||
var netSoftnet: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
@Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\"."))
|
||||
var suspendable: Bool = false
|
||||
|
||||
mutating func validate() throws {
|
||||
if vnc && vncExperimental {
|
||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||
}
|
||||
|
||||
if netBridged != nil && netSoftnet {
|
||||
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
|
||||
}
|
||||
@@ -92,11 +108,33 @@ struct Run: AsyncParsableCommand {
|
||||
if graphics && noGraphics {
|
||||
throw ValidationError("--graphics and --no-graphics are mutually exclusive")
|
||||
}
|
||||
|
||||
let localStorage = VMStorageLocal()
|
||||
let vmDir = try localStorage.open(name)
|
||||
if try vmDir.state() == "suspended" {
|
||||
suspendable = true
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let localStorage = VMStorageLocal()
|
||||
let vmDir = try localStorage.open(name)
|
||||
|
||||
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
if try vmDir.state() == "suspended" {
|
||||
try storageLock.lock() // lock before checking
|
||||
let needToGenerateNewMac = try localStorage.list().contains {
|
||||
// check if there is a running VM with the same MAC but different name
|
||||
try $1.running() && $1.macAddress() == vmDir.macAddress() && $1.name != vmDir.name
|
||||
}
|
||||
|
||||
if needToGenerateNewMac {
|
||||
print("There is already a running VM with the same MAC address!")
|
||||
print("Resetting VM to assign a new MAC address...")
|
||||
try vmDir.regenerateMACAddress()
|
||||
}
|
||||
}
|
||||
|
||||
if netSoftnet && isInteractiveSession() {
|
||||
try Softnet.configureSUIDBitIfNeeded()
|
||||
@@ -118,11 +156,31 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
var serialPorts: [VZSerialPortConfiguration] = []
|
||||
if serial {
|
||||
let tty_fd = createPTY()
|
||||
if (tty_fd < 0) {
|
||||
throw RuntimeError.VMConfigurationError("Failed to create PTY")
|
||||
}
|
||||
let tty_read = FileHandle.init(fileDescriptor: tty_fd)
|
||||
let tty_write = FileHandle.init(fileDescriptor: tty_fd)
|
||||
serialPorts.append(createSerialPortConfiguration(tty_read, tty_write))
|
||||
} else if serialPath != nil {
|
||||
let tty_read = FileHandle.init(forReadingAtPath: serialPath!)
|
||||
let tty_write = FileHandle.init(forWritingAtPath: serialPath!)
|
||||
if (tty_read == nil || tty_write == nil) {
|
||||
throw RuntimeError.VMConfigurationError("Failed to open PTY")
|
||||
}
|
||||
serialPorts.append(createSerialPortConfiguration(tty_read!, tty_write!))
|
||||
}
|
||||
|
||||
vm = try VM(
|
||||
vmDir: vmDir,
|
||||
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare()
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
@@ -153,6 +211,9 @@ struct Run: AsyncParsableCommand {
|
||||
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
|
||||
}
|
||||
|
||||
// now VM state will return "running" so we can unlock
|
||||
try storageLock.unlock()
|
||||
|
||||
let task = Task {
|
||||
do {
|
||||
if let vncImpl = vncImpl {
|
||||
@@ -166,7 +227,19 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
try await vm!.run(recovery)
|
||||
var resume = false
|
||||
|
||||
if #available(macOS 14, *) {
|
||||
if FileManager.default.fileExists(atPath: vmDir.stateURL.path) {
|
||||
print("restoring VM state from a snapshot...")
|
||||
try await vm!.virtualMachine.restoreMachineStateFrom(url: vmDir.stateURL)
|
||||
try FileManager.default.removeItem(at: vmDir.stateURL)
|
||||
resume = true
|
||||
print("resuming VM...")
|
||||
}
|
||||
}
|
||||
|
||||
try await vm!.run(recovery: recovery, resume: resume)
|
||||
|
||||
if let vncImpl = vncImpl {
|
||||
try vncImpl.stop()
|
||||
@@ -178,26 +251,69 @@ struct Run: AsyncParsableCommand {
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
|
||||
print(error)
|
||||
fputs("\(error)\n", stderr)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// "tart stop" support
|
||||
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||
sigintSrc.setEventHandler {
|
||||
task.cancel()
|
||||
}
|
||||
sigintSrc.activate()
|
||||
|
||||
// "tart suspend" / UI window closing support
|
||||
signal(SIGUSR1, SIG_IGN)
|
||||
let sigusr1Src = DispatchSource.makeSignalSource(signal: SIGUSR1)
|
||||
sigusr1Src.setEventHandler {
|
||||
Task {
|
||||
do {
|
||||
if #available(macOS 14, *) {
|
||||
try vm!.configuration.validateSaveRestoreSupport()
|
||||
|
||||
print("pausing VM to take a snapshot...")
|
||||
try await vm!.virtualMachine.pause()
|
||||
|
||||
print("creating a snapshot...")
|
||||
try await vm!.virtualMachine.saveMachineStateTo(url: vmDir.stateURL)
|
||||
|
||||
print("snapshot created successfully! shutting down the VM...")
|
||||
|
||||
task.cancel()
|
||||
} else {
|
||||
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
} catch (let e) {
|
||||
print(RuntimeError.SuspendFailed(e.localizedDescription))
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
sigusr1Src.activate()
|
||||
|
||||
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||
if noGraphics || useVNCWithoutGraphics {
|
||||
dispatchMain()
|
||||
} else {
|
||||
runUI()
|
||||
runUI(suspendable)
|
||||
}
|
||||
}
|
||||
|
||||
private func createSerialPortConfiguration(_ tty_read: FileHandle, _ tty_write: FileHandle) -> VZVirtioConsoleDeviceSerialPortConfiguration {
|
||||
let serialPortConfiguration = VZVirtioConsoleDeviceSerialPortConfiguration()
|
||||
let serialPortAttachment = VZFileHandleSerialPortAttachment(
|
||||
fileHandleForReading: tty_read,
|
||||
fileHandleForWriting: tty_write)
|
||||
|
||||
serialPortConfiguration.attachment = serialPortAttachment
|
||||
return serialPortConfiguration
|
||||
}
|
||||
|
||||
func isInteractiveSession() -> Bool {
|
||||
isatty(STDOUT_FILENO) == 1
|
||||
}
|
||||
@@ -343,7 +459,7 @@ struct Run: AsyncParsableCommand {
|
||||
return [device]
|
||||
}
|
||||
|
||||
private func runUI() {
|
||||
private func runUI(_ suspendable: Bool) {
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
@@ -351,15 +467,32 @@ struct Run: AsyncParsableCommand {
|
||||
nsApp.applicationIconImage = NSImage(data: AppIconData)
|
||||
|
||||
struct MainApp: App {
|
||||
static var disappearSignal: Int32 = SIGINT
|
||||
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
Group {
|
||||
VMView(vm: vm!).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}.onDisappear {
|
||||
NSApplication.shared.terminate(self)
|
||||
let ret = kill(getpid(), MainApp.disappearSignal)
|
||||
if ret != 0 {
|
||||
// Fallback to the old termination method that doesn't
|
||||
// propagate the cancellation to Task's in case graceful
|
||||
// termination via kill(2) is not successful
|
||||
NSApplication.shared.terminate(self)
|
||||
}
|
||||
}
|
||||
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
||||
}.frame(
|
||||
minWidth: CGFloat(vm!.config.display.width),
|
||||
idealWidth: CGFloat(vm!.config.display.width),
|
||||
maxWidth: .infinity,
|
||||
minHeight: CGFloat(vm!.config.display.height),
|
||||
idealHeight: CGFloat(vm!.config.display.height),
|
||||
maxHeight: .infinity
|
||||
)
|
||||
}.commands {
|
||||
// Remove some standard menu options
|
||||
CommandGroup(replacing: .help, addition: {})
|
||||
@@ -369,23 +502,68 @@ struct Run: AsyncParsableCommand {
|
||||
CommandGroup(replacing: .undoRedo, addition: {})
|
||||
CommandGroup(replacing: .windowSize, addition: {})
|
||||
// Replace some standard menu options
|
||||
CommandGroup(replacing: .appInfo) { AboutTart() }
|
||||
CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) }
|
||||
CommandMenu("Control") {
|
||||
Button("Start") {
|
||||
Task { try await vm!.virtualMachine.start() }
|
||||
}
|
||||
Button("Stop") {
|
||||
Task { try await vm!.virtualMachine.stop() }
|
||||
}
|
||||
Button("Request Stop") {
|
||||
Task { try vm!.virtualMachine.requestStop() }
|
||||
}
|
||||
if #available(macOS 14, *) {
|
||||
Button("Suspend") {
|
||||
kill(getpid(), SIGUSR1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
|
||||
MainApp.main()
|
||||
}
|
||||
}
|
||||
|
||||
// The only way to fully remove Edit menu item.
|
||||
class MinimalMenuAppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||
let indexOfEditMenu = 2
|
||||
|
||||
func applicationDidFinishLaunching(_ : Notification) {
|
||||
NSApplication.shared.mainMenu?.removeItem(at: indexOfEditMenu)
|
||||
}
|
||||
}
|
||||
|
||||
struct AboutTart: View {
|
||||
var credits: NSAttributedString
|
||||
|
||||
init(config: VMConfig) {
|
||||
let mutableAttrStr = NSMutableAttributedString()
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.alignment = NSTextAlignment.center
|
||||
let attrCenter: [NSAttributedString.Key : Any] = [
|
||||
.paragraphStyle: style,
|
||||
]
|
||||
mutableAttrStr.append(NSAttributedString(string: "CPU: \(config.cpuCount) cores\n", attributes: attrCenter))
|
||||
mutableAttrStr.append(NSAttributedString(string: "Memory: \(config.memorySize / 1024 / 1024) MB\n", attributes: attrCenter))
|
||||
mutableAttrStr.append(NSAttributedString(string: "Display: \(config.display.description)\n", attributes: attrCenter))
|
||||
mutableAttrStr.append(NSAttributedString(string: "https://github.com/cirruslabs/tart", attributes: [
|
||||
.paragraphStyle: style,
|
||||
.link : "https://github.com/cirruslabs/tart"
|
||||
]))
|
||||
credits = mutableAttrStr
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button("About Tart") {
|
||||
NSApplication.shared.orderFrontStandardAboutPanel(options: [
|
||||
NSApplication.AboutPanelOptionKey.applicationIcon: NSApplication.shared.applicationIconImage as Any,
|
||||
NSApplication.AboutPanelOptionKey.applicationName: "Tart",
|
||||
NSApplication.AboutPanelOptionKey.applicationVersion: CI.version,
|
||||
NSApplication.AboutPanelOptionKey.credits: try! NSAttributedString(markdown: "https://github.com/cirruslabs/tart"),
|
||||
NSApplication.AboutPanelOptionKey.credits: credits,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -398,7 +576,18 @@ struct VMView: NSViewRepresentable {
|
||||
|
||||
func makeNSView(context: Context) -> NSViewType {
|
||||
let machineView = VZVirtualMachineView()
|
||||
machineView.capturesSystemKeys = true
|
||||
|
||||
// Do not capture system keys so that shortcuts like
|
||||
// Shift-Command-4 + Space (capture a screenshot of window)
|
||||
// work on the host instead of the guest
|
||||
machineView.capturesSystemKeys = false
|
||||
|
||||
// Enable automatic display reconfiguration
|
||||
// for guests that support it
|
||||
if #available(macOS 14.0, *) {
|
||||
machineView.automaticallyReconfiguresDisplay = true
|
||||
}
|
||||
|
||||
return machineView
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,21 @@ struct Stop: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
switch try vmDir.state() {
|
||||
case "suspended":
|
||||
try stopSuspended(vmDir)
|
||||
case "running":
|
||||
try await stopRunning(vmDir)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func stopSuspended(_ vmDir: VMDirectory) throws {
|
||||
try? FileManager.default.removeItem(at: vmDir.stateURL)
|
||||
}
|
||||
|
||||
func stopRunning(_ vmDir: VMDirectory) async throws {
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
|
||||
// Find the VM's PID
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import System
|
||||
import SwiftDate
|
||||
|
||||
struct Suspend: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
|
||||
// Find the VM's PID
|
||||
var pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||
}
|
||||
|
||||
// Tell the "tart run" process to suspend the VM
|
||||
let ret = kill(pid, SIGUSR1)
|
||||
if ret != 0 {
|
||||
throw RuntimeError.SuspendFailed("failed to send SIGUSR1 signal to the \"tart run\" process running VM \"\(name)\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import Foundation
|
||||
|
||||
class EnvironmentCredentialsProvider: CredentialsProvider {
|
||||
func retrieve(host: String) throws -> (String, String)? {
|
||||
if let tartRegistryHostname = ProcessInfo.processInfo.environment["TART_REGISTRY_HOSTNAME"],
|
||||
tartRegistryHostname != host {
|
||||
return nil
|
||||
}
|
||||
|
||||
let username = ProcessInfo.processInfo.environment["TART_REGISTRY_USERNAME"]
|
||||
let password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"]
|
||||
if let username = username, let password = password {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import Sysctl
|
||||
|
||||
class DeviceInfo {
|
||||
private static var osMemoized: String? = nil
|
||||
private static var modelMemoized: String? = nil
|
||||
|
||||
static var os: String {
|
||||
if let os = osMemoized {
|
||||
return os
|
||||
}
|
||||
|
||||
osMemoized = getOS()
|
||||
|
||||
return osMemoized!
|
||||
}
|
||||
|
||||
static var model: String {
|
||||
if let model = modelMemoized {
|
||||
return model
|
||||
}
|
||||
|
||||
modelMemoized = getModel()
|
||||
|
||||
return modelMemoized!
|
||||
}
|
||||
|
||||
private static func getOS() -> String {
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
|
||||
return "macOS \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
}
|
||||
|
||||
private static func getModel() -> String {
|
||||
return SystemControl().hardware.model
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,16 @@ enum Format: String, ExpressibleByArgument, CaseIterable {
|
||||
}
|
||||
let table = TextTable<T> { (item: T) in
|
||||
let mirroredObject = Mirror(reflecting: item)
|
||||
return mirroredObject.children.enumerated().map { (_, element) in
|
||||
let fieldName = element.label!
|
||||
return Column(title: fieldName, value: element.value)
|
||||
}
|
||||
return mirroredObject.children.enumerated()
|
||||
.filter {(_, element) in
|
||||
// Deprecate the "Running" field: only make it available
|
||||
// from JSON for backwards-compatibility
|
||||
element.label! != "Running"
|
||||
}
|
||||
.map { (_, element) in
|
||||
let fieldName = element.label!
|
||||
return Column(title: fieldName, value: element.value)
|
||||
}
|
||||
}
|
||||
return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
case .json:
|
||||
|
||||
@@ -67,7 +67,7 @@ struct ARPCache {
|
||||
self.arpCommandOutput = arpCommandOutput
|
||||
}
|
||||
|
||||
func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||
let lines = String(decoding: arpCommandOutput, as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: "\n")
|
||||
@@ -95,11 +95,6 @@ struct ARPCache {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
|
||||
}
|
||||
|
||||
let interface = try match.getCaptureGroup(name: "interface", for: line)
|
||||
if bridgeOnly && !interface.starts(with: "bridge") {
|
||||
continue
|
||||
}
|
||||
|
||||
if macAddress == mac {
|
||||
return ip
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ class Leases {
|
||||
return rawLeases
|
||||
}
|
||||
|
||||
func resolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||
leases[macAddress]?.ip
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ struct TokenResponse: Decodable, Authentication {
|
||||
let defaultIssuedAt = Date()
|
||||
let defaultExpiresIn = 60
|
||||
|
||||
var token: String
|
||||
var token: String?
|
||||
var accessToken: String?
|
||||
var expiresIn: Int?
|
||||
var issuedAt: Date?
|
||||
|
||||
@@ -65,7 +66,13 @@ struct TokenResponse: Decodable, Authentication {
|
||||
return dateFormatter.date(from: dateString) ?? Date()
|
||||
}
|
||||
|
||||
return try decoder.decode(TokenResponse.self, from: fromData)
|
||||
let response = try decoder.decode(TokenResponse.self, from: fromData)
|
||||
|
||||
guard response.token != nil || response.accessToken != nil else {
|
||||
throw DecodingError.keyNotFound(CodingKeys.token, .init(codingPath: [], debugDescription: "Missing token or access_token. One must be present."))
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
var tokenExpiresAt: Date {
|
||||
@@ -83,7 +90,7 @@ struct TokenResponse: Decodable, Authentication {
|
||||
}
|
||||
|
||||
func header() -> (String, String) {
|
||||
("Authorization", "Bearer \(token)")
|
||||
return ("Authorization", "Bearer \(token ?? accessToken ?? "")")
|
||||
}
|
||||
|
||||
func isValid() -> Bool {
|
||||
@@ -92,12 +99,22 @@ struct TokenResponse: Decodable, Authentication {
|
||||
}
|
||||
|
||||
class Registry {
|
||||
let baseURL: URL
|
||||
private let baseURL: URL
|
||||
let namespace: String
|
||||
let credentialsProviders: [CredentialsProvider]
|
||||
|
||||
var currentAuthToken: Authentication? = nil
|
||||
|
||||
var host: String? {
|
||||
guard let host = baseURL.host else { return nil }
|
||||
|
||||
if let port = baseURL.port {
|
||||
return "\(host):\(port)"
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
init(urlComponents: URLComponents,
|
||||
namespace: String,
|
||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
@@ -387,6 +404,9 @@ class Registry {
|
||||
request.addValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
|
||||
request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))",
|
||||
forHTTPHeaderField: "User-Agent")
|
||||
|
||||
return try await Fetcher.fetch(request, viaFile: viaFile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ class PIDLock {
|
||||
init(lockURL: URL) throws {
|
||||
url = lockURL
|
||||
fd = open(lockURL.path, O_RDWR)
|
||||
if fd == -1 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError.PIDLockFailed("failed to open lock file \(url): \(details)")
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import Virtualization
|
||||
|
||||
struct Darwin: Platform {
|
||||
struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
var description: String {
|
||||
"error: host macOS version is outdated to run this virtual machine"
|
||||
}
|
||||
}
|
||||
|
||||
struct Darwin: PlatformSuspendable {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
|
||||
@@ -50,11 +56,18 @@ struct Darwin: Platform {
|
||||
VZMacOSBootLoader()
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
|
||||
let result = VZMacPlatformConfiguration()
|
||||
|
||||
result.machineIdentifier = ecid
|
||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
||||
|
||||
if !hardwareModel.isSupported {
|
||||
// At the moment support of M1 chip is not yet dropped in any macOS version
|
||||
// This mean that host software is not supporting this hardware model and should be updated
|
||||
throw UnsupportedHostOSError()
|
||||
}
|
||||
|
||||
result.hardwareModel = hardwareModel
|
||||
|
||||
return result
|
||||
@@ -85,13 +98,37 @@ struct Darwin: Platform {
|
||||
return result
|
||||
}
|
||||
|
||||
func keyboards() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||
return [VZMacKeyboardConfiguration(), VZUSBKeyboardConfiguration()]
|
||||
} else {
|
||||
return [VZUSBKeyboardConfiguration()]
|
||||
}
|
||||
}
|
||||
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 13, *) {
|
||||
// Trackpad is only supported starting with macOS Ventura
|
||||
// macOS Monterey will continue using a USB device == .darwin
|
||||
// Trackpad is only supported by guests starting with macOS Ventura
|
||||
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
} else {
|
||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 13, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ struct Linux: Platform {
|
||||
return result
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
|
||||
VZGenericPlatformConfiguration()
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ struct Linux: Platform {
|
||||
return result
|
||||
}
|
||||
|
||||
func keyboards() -> [VZKeyboardConfiguration] {
|
||||
[VZUSBKeyboardConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@ import Virtualization
|
||||
protocol Platform: Codable {
|
||||
func os() -> OS
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration
|
||||
func platform(nvramURL: URL) throws -> VZPlatformConfiguration
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||
func keyboards() -> [VZKeyboardConfiguration]
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||
}
|
||||
|
||||
protocol PlatformSuspendable: Platform {
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration]
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration]
|
||||
}
|
||||
|
||||
@@ -57,6 +57,11 @@ struct Root: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// Add commands that are only available on specific macOS versions
|
||||
if #available(macOS 14, *) {
|
||||
configuration.subcommands.append(Suspend.self)
|
||||
}
|
||||
|
||||
// Ensure the default SIGINT handled is disabled,
|
||||
// otherwise there's a race between two handlers
|
||||
signal(SIGINT, SIG_IGN);
|
||||
@@ -94,7 +99,7 @@ struct Root: AsyncParsableCommand {
|
||||
|
||||
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
|
||||
if let errorWithExitCode = error as? HasExitCode {
|
||||
print(error)
|
||||
fputs("\(error)\n", stderr)
|
||||
|
||||
Foundation.exit(errorWithExitCode.exitCode)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
func createPTY() -> Int32 {
|
||||
var tty_fd: Int32 = -1
|
||||
var sfd: Int32 = -1
|
||||
var termios_ = termios()
|
||||
let tty_path = UnsafeMutablePointer<CChar>.allocate(capacity: 1024)
|
||||
|
||||
var res = openpty(&tty_fd, &sfd, tty_path, nil, nil);
|
||||
if (res < 0) {
|
||||
perror("openpty error")
|
||||
return -1
|
||||
}
|
||||
|
||||
// close slave file descriptor
|
||||
close(sfd)
|
||||
|
||||
res = fcntl(tty_fd, F_GETFL)
|
||||
if (res < 0) {
|
||||
perror("fcntl F_GETFL error")
|
||||
return res
|
||||
}
|
||||
|
||||
// set serial nonblocking
|
||||
res = fcntl(tty_fd, F_SETFL, res | O_NONBLOCK)
|
||||
if (res < 0) {
|
||||
perror("fcntl F_SETFL O_NONBLOCK error")
|
||||
return res
|
||||
}
|
||||
|
||||
// set baudrate to 115200
|
||||
tcgetattr(tty_fd, &termios_)
|
||||
cfsetispeed(&termios_, speed_t(B115200))
|
||||
cfsetospeed(&termios_, speed_t(B115200))
|
||||
if (tcsetattr(tty_fd, TCSANOW, &termios_) != 0) {
|
||||
perror("tcsetattr error")
|
||||
return -1
|
||||
}
|
||||
|
||||
print("Successfully open pty \(String(cString: tty_path))")
|
||||
|
||||
tty_path.deallocate()
|
||||
return tty_fd
|
||||
}
|
||||
@@ -5,34 +5,26 @@ import Dynamic
|
||||
// Kudos to @saagarjha's VirtualApple for finding about _VZVirtualMachineStartOptions
|
||||
|
||||
extension VZVirtualMachine {
|
||||
@available(macOS 12, *)
|
||||
@MainActor @available(macOS 12, *)
|
||||
func start(_ recovery: Bool) async throws {
|
||||
if !recovery {
|
||||
// just use the regular API
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.main.async {
|
||||
self.start(completionHandler: { result in
|
||||
continuation.resume(with: result)
|
||||
})
|
||||
}
|
||||
}
|
||||
return try await self.start()
|
||||
}
|
||||
|
||||
// use some private stuff only for recovery
|
||||
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
DispatchQueue.main.async {
|
||||
let handler: @convention(block) (_ result: Any?) -> Void = { result in
|
||||
if let error = result as? Error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume(returning: ())
|
||||
}
|
||||
let handler: @convention(block) (_ result: Any?) -> Void = { result in
|
||||
if let error = result as? Error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume(returning: ())
|
||||
}
|
||||
// dynamic magic
|
||||
let options = Dynamic._VZVirtualMachineStartOptions()
|
||||
options.bootMacOSRecovery = recovery
|
||||
Dynamic(self)._start(withOptions: options, completionHandler: handler)
|
||||
}
|
||||
// dynamic magic
|
||||
let options = Dynamic._VZVirtualMachineStartOptions()
|
||||
options.bootMacOSRecovery = recovery
|
||||
Dynamic(self)._start(withOptions: options, completionHandler: handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Virtualization.Framework's virtual machine
|
||||
@Published var virtualMachine: VZVirtualMachine
|
||||
|
||||
// Virtualization.Framework's virtual machine configuration
|
||||
var configuration: VZVirtualMachineConfiguration
|
||||
|
||||
// Semaphore used to communicate with the VZVirtualMachineDelegate
|
||||
var sema = DispatchSemaphore(value: 0)
|
||||
|
||||
@@ -40,7 +43,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
init(vmDir: VMDirectory,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = []
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = [],
|
||||
suspendable: Bool = false
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
@@ -51,10 +56,12 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
self.network = network
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||
network: network, additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directorySharingDevices
|
||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||
network: network, additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -132,7 +139,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
diskSizeGB: UInt16,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = []
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = []
|
||||
) async throws {
|
||||
var ipswURL = ipswURL
|
||||
|
||||
@@ -176,10 +184,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
self.network = network
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, network: network,
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directorySharingDevices
|
||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, network: network,
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -216,27 +225,16 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
return try VM(vmDir: vmDir)
|
||||
}
|
||||
|
||||
func run(_ recovery: Bool) async throws {
|
||||
func run(recovery: Bool, resume shouldResume: Bool) async throws {
|
||||
try network.run(sema)
|
||||
|
||||
let startTask = DispatchQueue.main.sync {
|
||||
Task {
|
||||
if #available(macOS 13, *) {
|
||||
// new API introduced in Ventura
|
||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||
startOptions.startUpFromMacOSRecovery = recovery
|
||||
try await virtualMachine.start(options: startOptions)
|
||||
} else {
|
||||
// use method that also available on Monterey
|
||||
try await virtualMachine.start(recovery)
|
||||
}
|
||||
}
|
||||
if shouldResume {
|
||||
try await resume()
|
||||
} else {
|
||||
try await start(recovery)
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
// Await on VZVirtualMachine.start() result
|
||||
_ = try await startTask.value
|
||||
|
||||
await withTaskCancellationHandler(operation: {
|
||||
// Wait for the VM to finish running
|
||||
// or for the exit condition
|
||||
sema.wait()
|
||||
@@ -245,23 +243,44 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
})
|
||||
|
||||
if Task.isCancelled {
|
||||
DispatchQueue.main.sync {
|
||||
Task {
|
||||
try await self.virtualMachine.stop()
|
||||
}
|
||||
}
|
||||
try await stop()
|
||||
}
|
||||
|
||||
try await network.stop()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func start(_ recovery: Bool) async throws {
|
||||
if #available(macOS 13, *) {
|
||||
// new API introduced in Ventura
|
||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||
startOptions.startUpFromMacOSRecovery = recovery
|
||||
try await virtualMachine.start(options: startOptions)
|
||||
} else {
|
||||
// use method that also available on Monterey
|
||||
try await virtualMachine.start(recovery)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resume() async throws {
|
||||
try await virtualMachine.resume()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func stop() async throws {
|
||||
try await self.virtualMachine.stop()
|
||||
}
|
||||
|
||||
static func craftConfiguration(
|
||||
diskURL: URL,
|
||||
nvramURL: URL,
|
||||
vmConfig: VMConfig,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration]
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||
serialPorts: [VZSerialPortConfiguration],
|
||||
suspendable: Bool = false
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -273,23 +292,30 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.memorySize = vmConfig.memorySize
|
||||
|
||||
// Platform
|
||||
configuration.platform = vmConfig.platform.platform(nvramURL: nvramURL)
|
||||
configuration.platform = try vmConfig.platform.platform(nvramURL: nvramURL)
|
||||
|
||||
// Display
|
||||
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
||||
|
||||
// Audio
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
|
||||
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
|
||||
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
|
||||
configuration.audioDevices = [soundDeviceConfiguration]
|
||||
if !suspendable {
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
|
||||
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
|
||||
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
|
||||
configuration.audioDevices = [soundDeviceConfiguration]
|
||||
}
|
||||
|
||||
// Keyboard and mouse
|
||||
configuration.keyboards = [VZUSBKeyboardConfiguration()]
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
|
||||
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
|
||||
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
|
||||
} else {
|
||||
configuration.keyboards = vmConfig.platform.keyboards()
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
}
|
||||
|
||||
// Networking
|
||||
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||
@@ -303,11 +329,30 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
|
||||
|
||||
// Entropy
|
||||
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||
if !suspendable {
|
||||
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||
}
|
||||
|
||||
// Directory sharing devices
|
||||
configuration.directorySharingDevices = directorySharingDevices
|
||||
|
||||
// Serial Port
|
||||
configuration.serialPorts = serialPorts
|
||||
|
||||
// Version console device
|
||||
//
|
||||
// A dummy console device useful for implementing
|
||||
// host feature checks in the guest agent software.
|
||||
if #available(macOS 13, *) {
|
||||
let consolePort = VZVirtioConsolePortConfiguration()
|
||||
consolePort.name = "tart-version-\(CI.version)"
|
||||
|
||||
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||
consoleDevice.ports[0] = consolePort
|
||||
|
||||
configuration.consoleDevices.append(consoleDevice)
|
||||
}
|
||||
|
||||
try configuration.validate()
|
||||
|
||||
return configuration
|
||||
|
||||
@@ -81,7 +81,8 @@ extension VMDirectory {
|
||||
try? decodeStream.close()
|
||||
}
|
||||
|
||||
guard let extractStream = ArchiveStream.extractStream(extractingTo: FilePath(baseURL.path)) else {
|
||||
guard let extractStream = ArchiveStream.extractStream(extractingTo: FilePath(baseURL.path),
|
||||
flags: [.ignoreOperationNotPermitted]) else {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError.ImportFailed("ArchiveStream.extractStream() failed: \(details)")
|
||||
|
||||
@@ -159,7 +159,7 @@ extension VMDirectory {
|
||||
}
|
||||
|
||||
let pushedReference = Reference(digest: try manifest.digest())
|
||||
return RemoteName(host: registry.baseURL.host!, namespace: registry.namespace, reference: pushedReference)
|
||||
return RemoteName(host: registry.host!, namespace: registry.namespace, reference: pushedReference)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ struct VMDirectory: Prunable {
|
||||
var nvramURL: URL {
|
||||
baseURL.appendingPathComponent("nvram.bin")
|
||||
}
|
||||
var stateURL: URL {
|
||||
baseURL.appendingPathComponent("state.vzvmsave")
|
||||
}
|
||||
|
||||
var explicitlyPulledMark: URL {
|
||||
baseURL.appendingPathComponent(".explicitly-pulled")
|
||||
@@ -26,6 +29,29 @@ struct VMDirectory: Prunable {
|
||||
baseURL
|
||||
}
|
||||
|
||||
func running() throws -> Bool {
|
||||
// The most common reason why PIDLock() instantiation fails is a race with "tart delete" (ENOENT),
|
||||
// which is fine to report as "not running".
|
||||
//
|
||||
// The other reasons are unlikely and the cost of getting a false positive is way less than
|
||||
// the cost of crashing with an exception when calling "tart list" on a busy machine, for example.
|
||||
guard let lock = try? PIDLock(lockURL: configURL) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return try lock.pid() != 0
|
||||
}
|
||||
|
||||
func state() throws -> String {
|
||||
if try running() {
|
||||
return "running"
|
||||
} else if FileManager.default.fileExists(atPath: stateURL.path) {
|
||||
return "suspended"
|
||||
} else {
|
||||
return "stopped"
|
||||
}
|
||||
}
|
||||
|
||||
static func temporary() throws -> VMDirectory {
|
||||
let tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
|
||||
@@ -51,9 +77,9 @@ struct VMDirectory: Prunable {
|
||||
try? FileManager.default.removeItem(at: nvramURL)
|
||||
}
|
||||
|
||||
func validate() throws {
|
||||
func validate(userFriendlyName: String) throws {
|
||||
if !FileManager.default.fileExists(atPath: baseURL.path) {
|
||||
throw RuntimeError.VMDoesNotExist(name: baseURL.lastPathComponent)
|
||||
throw RuntimeError.VMDoesNotExist(name: userFriendlyName)
|
||||
}
|
||||
|
||||
if !initialized {
|
||||
@@ -66,6 +92,7 @@ struct VMDirectory: Prunable {
|
||||
try FileManager.default.copyItem(at: configURL, to: to.configURL)
|
||||
try FileManager.default.copyItem(at: nvramURL, to: to.nvramURL)
|
||||
try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
|
||||
try? FileManager.default.copyItem(at: stateURL, to: to.stateURL)
|
||||
|
||||
// Re-generate MAC address
|
||||
if generateMAC {
|
||||
@@ -81,6 +108,8 @@ struct VMDirectory: Prunable {
|
||||
var vmConfig = try VMConfig(fromURL: configURL)
|
||||
|
||||
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||
// cleanup state if any
|
||||
try? FileManager.default.removeItem(at: stateURL)
|
||||
|
||||
try vmConfig.save(toURL: configURL)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ extension Error {
|
||||
}
|
||||
|
||||
enum RuntimeError : Error {
|
||||
case VMConfigurationError(_ message: String)
|
||||
case VMDoesNotExist(name: String)
|
||||
case VMMissingFiles(_ message: String)
|
||||
case VMNotRunning(_ message: String)
|
||||
@@ -56,6 +57,8 @@ enum RuntimeError : Error {
|
||||
case ExportFailed(_ message: String)
|
||||
case ImportFailed(_ message: String)
|
||||
case SoftnetFailed(_ message: String)
|
||||
case OCIStorageError(_ message: String)
|
||||
case SuspendFailed(_ message: String)
|
||||
}
|
||||
|
||||
protocol HasExitCode {
|
||||
@@ -65,6 +68,8 @@ protocol HasExitCode {
|
||||
extension RuntimeError : CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .VMConfigurationError(let message):
|
||||
return message
|
||||
case .VMDoesNotExist(let name):
|
||||
return "the specified VM \"\(name)\" does not exist"
|
||||
case .VMMissingFiles(let message):
|
||||
@@ -95,6 +100,10 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return "VM import failed: \(message)"
|
||||
case .SoftnetFailed(let message):
|
||||
return "Softnet failed: \(message)"
|
||||
case .OCIStorageError(let message):
|
||||
return "OCI storage error: \(message)"
|
||||
case .SuspendFailed(let message):
|
||||
return "Failed to suspend the VM: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class VMStorageLocal {
|
||||
func open(_ name: String) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
|
||||
try vmDir.validate()
|
||||
try vmDir.validate(userFriendlyName: name)
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
@@ -16,10 +16,20 @@ class VMStorageOCI: PrunableStorage {
|
||||
VMDirectory(baseURL: vmURL(name)).initialized
|
||||
}
|
||||
|
||||
func digest(_ name: RemoteName) throws -> String {
|
||||
let digest = vmURL(name).resolvingSymlinksInPath().lastPathComponent
|
||||
|
||||
if !digest.starts(with: "sha256:") {
|
||||
throw RuntimeError.OCIStorageError("\(name) is not a digest and doesn't point to a digest")
|
||||
}
|
||||
|
||||
return digest
|
||||
}
|
||||
|
||||
func open(_ name: RemoteName) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
|
||||
try vmDir.validate()
|
||||
try vmDir.validate(userFriendlyName: name.description)
|
||||
|
||||
try vmDir.baseURL.updateAccessDate()
|
||||
|
||||
@@ -123,6 +133,10 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
func pull(_ name: RemoteName, registry: Registry) async throws {
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["imageName": name], key: "OCI")
|
||||
}
|
||||
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
||||
@@ -130,6 +144,12 @@ class VMStorageOCI: PrunableStorage {
|
||||
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||
reference: Reference(digest: Digest.hash(manifestData)))
|
||||
|
||||
if exists(name) && exists(digestName) && linked(from: name, to: digestName) {
|
||||
// optimistically check if we need to do anything at all before locking
|
||||
defaultLogger.appendNewLine("\(digestName) image is already cached and linked!")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure that host directory for given RemoteName exists in OCI storage
|
||||
let hostDirectoryURL = hostDirectoryURL(digestName)
|
||||
try FileManager.default.createDirectory(at: hostDirectoryURL, withIntermediateDirectories: true)
|
||||
@@ -158,36 +178,13 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
// Try to reclaim some cache space if we know the VM size in advance
|
||||
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
||||
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
|
||||
|
||||
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey])
|
||||
let capacityImportant = attrs.volumeAvailableCapacityForImportantUsage!
|
||||
let capacityAvailable = attrs.volumeAvailableCapacity!
|
||||
let availableCapacityBytes = max(UInt64(capacityImportant), UInt64(capacityAvailable))
|
||||
|
||||
if capacityImportant == 0 || capacityAvailable == 0 {
|
||||
SentrySDK.capture(message: "Zero capacity") { scope in
|
||||
scope.setLevel(.warning)
|
||||
|
||||
scope.setContext(value: [
|
||||
"volumeAvailableCapacityForImportantUsageKey": capacityImportant,
|
||||
"volumeAvailableCapacityKey": capacityAvailable,
|
||||
], key: "Attributes")
|
||||
}
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["imageUncompressedDiskSize": uncompressedDiskSize], key: "OCI")
|
||||
}
|
||||
|
||||
// There is a suspicious that occasionally capacity is returned as zero which can't be true.
|
||||
// Let's validate to avoid unnecessary pruning.
|
||||
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
|
||||
let transaction = SentrySDK.startTransaction(name: "Automatically Pruning Cache", operation: "prune", bindToScope: true)
|
||||
transaction.setData(value: name, key: "name")
|
||||
transaction.setData(value: uncompressedDiskSize, key: "uncompressedDiskSize")
|
||||
transaction.setData(value: availableCapacityBytes, key: "availableCapacity")
|
||||
transaction.setData(value: requiredCapacityBytes, key: "requiredCapacity")
|
||||
defer { transaction.finish() }
|
||||
let otherVMFilesSize: UInt64 = 128 * 1024 * 1024
|
||||
|
||||
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
||||
}
|
||||
try Prune.reclaimIfNeeded(uncompressedDiskSize + otherVMFilesSize)
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
@@ -212,6 +209,15 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
}
|
||||
|
||||
func linked(from: RemoteName, to: RemoteName) -> Bool {
|
||||
do {
|
||||
let resolvedFrom = try FileManager.default.destinationOfSymbolicLink(atPath: vmURL(from).path)
|
||||
return resolvedFrom == vmURL(to).path
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func link(from: RemoteName, to: RemoteName) throws {
|
||||
if FileManager.default.fileExists(atPath: vmURL(from).path) {
|
||||
try FileManager.default.removeItem(at: vmURL(from))
|
||||
|
||||
@@ -12,7 +12,7 @@ final class MACAddressResolverTests: XCTestCase {
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
}
|
||||
|
||||
func testMultipleEntries() throws {
|
||||
@@ -28,8 +28,8 @@ final class MACAddressResolverTests: XCTestCase {
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
XCTAssertEqual(IPv4Address("5.6.7.8"),
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -13,7 +13,7 @@ categories:
|
||||
|
||||
**TLDR:** We are transitioning Tart's licensing from AGPL-3.0 to [Fair Source 100](https://fair.io/). This change will
|
||||
permit unlimited installations on personal computers, but organizations that exceed a certain number of server
|
||||
installations utilizing 100 CPU cores will be required to obtain a paid sponsorship.
|
||||
installations utilizing 100 CPU cores will be required to obtain a paid license.
|
||||
|
||||
## Background
|
||||
|
||||
@@ -55,15 +55,15 @@ of Tart virtual machines on a cluster of Apple Silicon servers. Concurrently, we
|
||||
which will establish a stable API and offer long-term support under a new Fair Source 100 license.
|
||||
|
||||
The Fair Source 100 license for Tart means that once a certain threshold of server installations utilizing 100 CPU cores
|
||||
is exceeded, a paid sponsorship will be required. A "server installation" refers to the installation of Tart on a physical
|
||||
is exceeded, a paid license will be required. A "server installation" refers to the installation of Tart on a physical
|
||||
device without a physical display connected. For example, a Mac Mini with a HDMI Dummy Plug is considered a server,
|
||||
but a Mac Mini on a desk with a connected physical display is considered a personal computer. **Usage on personal computers
|
||||
and before reaching the 100 CPU cores limit is royalty-free and does not have the viral properties of AGPL.**
|
||||
|
||||
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Sponsorship](/licensing#sponsorships),
|
||||
which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Sponsorship](/licensing#sponsorships)
|
||||
(\$5000 per month) will be required, and for organizations that exceed 5000 CPU cores, a custom [Diamond Sponsorship](/licensing#sponsorships)
|
||||
(\$1 per core per month) will be necessary. **All sponsorships will include priority feature development and SLAs on support with urgent issues.**
|
||||
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Tier License](/licensing#license-tiers),
|
||||
which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](/licensing#license-tiers)
|
||||
(\$5000 per month) will be required, and for organizations that exceed 5000 CPU cores, a custom [Diamond Tier License](/licensing#license-tiers)
|
||||
(\$1 per core per month) will be necessary. **All paid license tiers will include priority feature development and SLAs on support with urgent issues.**
|
||||
|
||||
## Have we considered alternatives?
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2023-04-25
|
||||
search:
|
||||
exclude: true
|
||||
authors:
|
||||
- fkorotkov
|
||||
categories:
|
||||
- announcement
|
||||
- orchard
|
||||
---
|
||||
|
||||
# Announcing Orchard orchestration for managing macOS virtual machines at scale
|
||||
|
||||
Today we are happy to announce general availability of Orchard – our new orchestrator to manage Tart virtual machines at scale.
|
||||
In this post we’ll cover the motivation behind creating yet another orchestrator and why we didn’t go with Kubernetes or Nomad integration.
|
||||
|
||||
## What problem are we trying to solve?
|
||||
|
||||
After releasing Tart we pretty quickly started getting requests about managing macOS virtual machines on a cluster of
|
||||
Apple Silicon machines rather than just a single host which only allows a maximum of two virtual machines at a time.
|
||||
By the end of 2022 the requests reached a tipping point, and we started planning.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
First, we established some constraints about the end users and potential workload our solution should handle.
|
||||
Running macOS or Linux virtual machines on Apple Silicon is a very niche use case. These VMs are either used in
|
||||
automation solutions like CI/CD or for managing remote desktop environments. In this case **we are aiming to manage
|
||||
only thousands of virtual machines and not millions**.
|
||||
|
||||
Second, **operators of such solutions won’t have experience of operating Kubernetes or Nomad**. Operators will most likely
|
||||
come with experience of using such systems but not managing them. And again, having built-in things like RBAC and
|
||||
ability to scale to millions were appealing but it seemed like it would be a solution for a few rather than a solution
|
||||
for everybody to use. Additionally Orchard should provide **first class support for accessing virtual machines over SSH/VNC**
|
||||
and support script execution.
|
||||
|
||||
By that time, the idea of building a simple opinionated orchestrator got more and more appealing. Plus we kind of already did it
|
||||
for [Cirrus CI’s persistent workers](https://cirrus-ci.org/guide/persistent-workers/) feature.
|
||||
|
||||
## Technical constraints
|
||||
|
||||
With the UX constraints and expectations in place we started thinking about architecture for the orchestrator that we
|
||||
started calling **Orchard**.
|
||||
|
||||
<script src="https://unpkg.com/@dotlottie/player-component@latest/dist/dotlottie-player.js"></script>
|
||||
<dotlottie-player
|
||||
src="/assets/animations/Orchard.lottie"
|
||||
mode="normal"
|
||||
style="width: 100%; height: 360px; margin: auto; background-color: rgb(5 62 94)"
|
||||
autoplay
|
||||
loop
|
||||
/>
|
||||
|
||||
Since Orchard will manage a maximum of a couple thousands virtual machines and not millions we **decided to not think much
|
||||
about horizontal scalability.** Just a single instance of Orchard controller should be enough if it can restart quickly and
|
||||
persist state between restarts.
|
||||
|
||||
**Orchard should be secure by default**. All the communication between a controller and workers should be secure.
|
||||
All external API requests to Orchard controller should be authorized.
|
||||
|
||||
During development it’s crucial to have a quick feedback cycle. **It should be extremely easy to run Orchard in development**.
|
||||
Configuring a production cluster should be also easy for novice operators.
|
||||
|
||||
## High-level implementation details
|
||||
|
||||
Cirrus Labs started as a predominantly Kotlin shop with a little Go. But over the years we gradually moved a lot of things to Go.
|
||||
We love the expressibility of Kotlin as a language but the ecosystem for writing system utilities and services is superb in Go.
|
||||
|
||||
Orchard is a single Go project that implements both controller server interface and worker client logic in a single repository.
|
||||
This simplifies code sharing and testability of the both components and allows to change them in a single pull request.
|
||||
|
||||
Another benefit is that Orchard can be distributed as a single binary. We intend to run Orchard controller on a single host.
|
||||
Data model for the orchestration didn’t look complex as well. These observations lead us to exploring the use of an embedded database.
|
||||
Just imagine! **Orchard can be distributed as a single binary with no external dependencies on any database or runtime!**
|
||||
|
||||
And we did exactly that! Orchard is distributed as a single binary that can be run in “controller” mode on a Linux/macOS host and
|
||||
in “worker” mode on macOS hosts. Orchard controller is using extremely fast [BadgerDB](https://dgraph.io/docs/badger/) key-value storage to persist data.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Please give [Orchard](https://github.com/cirruslabs/orchard) a try! To run it locally in development mode on any Apple Silicon device
|
||||
please run the following command:
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/orchard
|
||||
orchard dev
|
||||
```
|
||||
|
||||
This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/cirruslabs/orchard#creating-virtual-machines)
|
||||
on how to create your first virtual machine and access it.
|
||||
|
||||
In a [separate blog post](/blog/2023/04/28/ssh-over-grpc-or-how-orchard-simplifies-accessing-vms-in-private-networks/)
|
||||
we’ll cover how Orchard implements seamless SSH access over a gRPC connection. Stay tuned and please don’t hesitate to
|
||||
[reach out](https://github.com/cirruslabs/orchard/discussions/landing)!
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2023-04-28
|
||||
search:
|
||||
exclude: true
|
||||
authors:
|
||||
- edigaryev
|
||||
categories:
|
||||
- orchard
|
||||
---
|
||||
|
||||
# SSH over gRPC or how Orchard simplifies accessing VMs in private networks
|
||||
|
||||
We started developing [Orchard](https://github.com/cirruslabs/orchard), an orchestrator for [Tart](https://tart.run/), with the requirement that it should allow users to access virtual machines running on worker nodes in private networks that users might not have access to.
|
||||
|
||||
At the same time, we wanted to enable users to access VMs on these remote workers just as easily as they’d access network services on their local Tart VMs.
|
||||
|
||||
While these features sound great on paper, they pose a technical problem: how do we connect to the remote workers, let alone VMs running on these workers, if we can’t assume that these workers will be easily reachable? And how do we establish an SSH connection with a VM running on a remote worker through all these hoops?
|
||||
|
||||
<!-- more -->
|
||||
|
||||
## Implementing port forwarding: gRPC to the rescue
|
||||
|
||||
We need to keep a full-duplex connection with the controller for the port-forwarding to work, and the two obvious protocol options are:
|
||||
|
||||
- WebSocket API through a new controller’s REST API endpoint
|
||||
- gRPC using `Content-Type` differentiation
|
||||
|
||||
We’ve chosen the gRPC for controller ↔︎ worker connection, simply because it requires less code on our side and it will only be used internally, which means we don’t need to document it as extensively as our REST API. In essence, port forwarding is streaming of bytes of a connection in both ways, so gRPC streams looked like a natural solution. The resulting protocol is dead simple:
|
||||
|
||||
```Protobuf
|
||||
service Controller {
|
||||
rpc Watch(google.protobuf.Empty) returns (stream WatchInstruction);
|
||||
|
||||
rpc PortForward(stream PortForwardData) returns (stream PortForwardData);
|
||||
}
|
||||
|
||||
message WatchInstruction {
|
||||
message PortForward {
|
||||
string session = 1;
|
||||
string vm_uid = 2;
|
||||
uint32 vm_port = 3;
|
||||
}
|
||||
|
||||
oneof action {
|
||||
PortForward port_forward_action = 1;
|
||||
}
|
||||
}
|
||||
|
||||
message PortForwardData {
|
||||
bytes data = 1;
|
||||
}
|
||||
```
|
||||
|
||||
On bootstrap, each Orchard worker establishes a `Watch()` RPC stream and waits for the `PortForward` instruction from the controller indefinitely. This long-running session might be used not just for port-forwarding, but for notifying the workers about changed resources, which results in workers picking up your VM for execution instantly.
|
||||
|
||||
Once `PortForward` instruction is received, the worker connects to the specified VM and port locally and opens a new `PortForward()` RPC stream with the controller, carrying the unique `session` identifier in the gRPC metadata to help distinguish several port forwarding requests.
|
||||
|
||||
We’re using a pretty ingenious Golang package that turns any gRPC stream into a `net.Conn`: https://github.com/mitchellh/go-grpc-net-conn. This allows us to abstract from the gRPC details and simply proxy two `net.Conns`, thus providing the port forwarding functionality.
|
||||
|
||||
We’ve also initially considered using [Yamux](https://github.com/hashicorp/yamux) to only keep a single connection with each worker, however, that involves the burden of dealing with flow control and potential implementation bugs associated with it, so we’ve decided to simply open an additional connection for each port forwarding session and let the OS deal with it.
|
||||
|
||||
## Building on top of the port-forwarding
|
||||
|
||||
First of all, we’ve made the new port-forwarding functionality available for integrations via the Orchard’s REST API:
|
||||
|
||||

|
||||
|
||||
All you need is to use a WebSocket client when accessing this endpoint to make it work.
|
||||
|
||||
Secondly, we’ve exposed three commands in the Orchard CLI that all use this endpoint:
|
||||
|
||||
### `orchard port-forward`
|
||||
|
||||
Opens a TCP port locally and forwards everything sent to it to the specified VM (and vice versa).
|
||||
|
||||
For example, `orchard port-forward vm ventura-builder 2222:22` will forward traffic from the local TCP port `2222` to the `ventura-builder` VM’s TCP port `22`.
|
||||
|
||||
### `orchard ssh`
|
||||
|
||||
Connects to the specified VM on the default SSH port `22`, optionally only launching a command (if specified), similarly to what the official OpenSSH client does.
|
||||
|
||||
For example, `orchard ssh vm ventura-builder` will open an interactive session with the `ventura-builder` VM.
|
||||
|
||||
You can also send local scripts for execution by utilizing redirection:
|
||||
|
||||
```shell
|
||||
orchard ssh vm ventura-builder 'sh -s' < script.sh
|
||||
```
|
||||
|
||||
### `orchard vnc`
|
||||
|
||||
Establishes a port forwarding to the specified VM’s default VNC port `5900` and opens the default macOS Screen Sharing app.
|
||||
|
||||
For example, `orchard vnc vm ventura-builder` will establish a port-forwarding to the `ventura-builder` VM's port `5900` under the hood and launch macOS Screen Sharing app.
|
||||
|
||||
Note that the SSH and VNC commands expect the VM resource to specify credentials in it’s definition (can be done via `orchard create vm`), and will otherwise fall back to the credentials specified by `--username` and `--password`, or if none specified — to de-facto standard of `admin:admin` credentials.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Overall, the technology described in this article somewhat resembles what [we previously did for Cirrus Terminal](https://cirrus-ci.org/blog/2021/08/06/introducing-cirrus-terminal-a-simple-way-to-get-ssh-like-access-to-your-tasks/). The only difference is that in Cirrus Terminal we carry terminal-specific characters, and in Orchard — we carry bytes for an arbitrary TCP connection.
|
||||
|
||||
We really hope this feature will be useful for many, just as the Cirrus Terminal, and that it will remove the pain of scaling Tart beyond a single machine.
|
||||
|
||||
You can give [Orchard](https://github.com/cirruslabs/orchard) a try by running it locally in development mode on any Apple Silicon device:
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/orchard
|
||||
orchard dev
|
||||
```
|
||||
|
||||
This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/cirruslabs/orchard#creating-virtual-machines)
|
||||
on how to create your first virtual machine and access it.
|
||||
|
||||
Stay tuned and don’t hesitate to send us your feedback either [on GitHub](https://github.com/cirruslabs/orchard) or [Twitter](https://twitter.com/cirrus_labs)!
|
||||
@@ -31,7 +31,7 @@ please first make sure that the service is binded 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:
|
||||
|
||||
```bash
|
||||
```shell
|
||||
netstat -nr | grep default | head -n 1 | awk '{print $2}'
|
||||
```
|
||||
|
||||
@@ -43,7 +43,7 @@ is stricter and it's not only possible to access the host.
|
||||
|
||||
To change the default network to `192.168.77.1`:
|
||||
|
||||
```bash
|
||||
```shell
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Address -string 192.168.77.1
|
||||
```
|
||||
|
||||
@@ -51,6 +51,18 @@ Note that even through a network would normally be specified as `192.168.77.0`,
|
||||
|
||||
The default subnet mask `255.255.255.0` should suffice for most use-cases, however, you can also change it to `255.255.0.0`, for example:
|
||||
|
||||
```bash
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
|
||||
```
|
||||
```shell
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
|
||||
```
|
||||
|
||||
## Changing the default DHCP lease time
|
||||
|
||||
By default, the built-in macOS DHCP server allocates IP-addresses to the VMs for the duration of 86,400 seconds (one day), which may easily cause DHCP exhaustion if you run more than ~253 VMs per day, or in other words, more than one VM every ~6 minutes.
|
||||
|
||||
This issue is worked around automatically [when using Softnet](http://github.com/cirruslabs/softnet), however, if you don't use or can't use it, the following command will reduce the lease time from the default 86,400 seconds (one day) to 600 seconds (10 minutes):
|
||||
|
||||
```shell
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.InternetSharing.default.plist bootpd -dict DHCPLeaseTimeSecs -int 600
|
||||
```
|
||||
|
||||
Note that this tweak persists across reboots, so normally you'll only need to do it once per new host.
|
||||
|
||||
@@ -4,6 +4,17 @@ Tart already powers several CI services mentioned above including our own [Cirru
|
||||
For services that haven't leveraged Tart yet, we offer fully managed runners via a monthly subscription.
|
||||
*Cirrus Runners* is the fastest way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
|
||||
|
||||
## Testimonials from customers
|
||||
|
||||
Sebastian Jachec, Mobile Engineer at [Daybridge](https://www.daybridge.com/).
|
||||
|
||||
> It’s been plain-sailing with the Cirrus Runners — they’ve been great! They’re consistently 60+% faster on workflows that we previously used Github Actions’ macOS runners for.
|
||||
|
||||
Max Lapides, Senior Mobile Engineer at [Tonal](https://www.tonal.com/).
|
||||
|
||||
> Previously, we were using the GitHub‑hosted macOS runners and our iOS build took ~30 minutes. Now with Cirrus Runners, the iOS build only takes ~12 minutes. That’s a huge boost to our productivity, and for only $150/month per runner it is much less expensive too.
|
||||
|
||||
|
||||
## Configuring Cirrus Runners
|
||||
|
||||
Configuring Cirrus Runners for GitHub Actions is as simple as installing [Cirrus Runners App](https://github.com/apps/cirrus-runners).
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# GitLab Runner Executor
|
||||
|
||||
It is possible to run GitLab jobs in isolated ephemeral Tart Virtual Machines via [Tart Executor](https://github.com/cirruslabs/gitlab-tart-executor).
|
||||
Tart Executor utilizes [custom executor](https://docs.gitlab.com/runner/executors/custom.html) feature of GitLab Runner.
|
||||
|
||||
# Basic Configuration
|
||||
|
||||
Configuring Tart Executor for GitLab Runner is as simple as installing `gitlab-tart-executor` binary from Homebrew:
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/gitlab-tart-executor
|
||||
```
|
||||
|
||||
And updating configuration of your self-hosted GitLab Runner to use `gitlab-tart-executor` binary:
|
||||
|
||||
```toml
|
||||
concurrent = 2
|
||||
|
||||
[[runners]]
|
||||
# ...
|
||||
executor = "custom"
|
||||
builds_dir = "/Users/admin/builds" # directory inside the
|
||||
cache_dir = "/Users/admin/cache"
|
||||
[runners.feature_flags]
|
||||
FF_RESOLVE_FULL_TLS_CHAIN = false
|
||||
[runners.custom]
|
||||
prepare_exec = "gitlab-tart-executor"
|
||||
prepare_args = ["prepare"]
|
||||
run_exec = "gitlab-tart-executor"
|
||||
run_args = ["run"]
|
||||
cleanup_exec = "gitlab-tart-executor"
|
||||
cleanup_args = ["cleanup"]
|
||||
```
|
||||
|
||||
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-ventura-base:latest
|
||||
|
||||
test:
|
||||
tags:
|
||||
- tart-installed # in case you tagged runners with Tart Executor installed
|
||||
script:
|
||||
- uname -a
|
||||
```
|
||||
|
||||
For more advanced configuration please refer to [GitLab Tart Executor repository](https://github.com/cirruslabs/gitlab-tart-executor).
|
||||
@@ -3,9 +3,10 @@ hide:
|
||||
- navigation
|
||||
---
|
||||
|
||||
Both [Tart Virtualization](https://github.com/cirruslabs/tart) and **Orchard Orchestration** (coming soon)
|
||||
are licensed under [Fair Source 100 License](https://fair.io/). Usage on personal computers including personal workstations is royalty-free,
|
||||
but organizations that exceed a certain number of server installations utilizing 100 CPU cores will be required to obtain a paid sponsorship.
|
||||
Both [Tart Virtualization](https://github.com/cirruslabs/tart) and [Orchard Orchestration](https://github.com/cirruslabs/orchard)
|
||||
are licensed under [Fair Source License](https://fair.io/). Usage on personal computers including personal workstations is royalty-free,
|
||||
but organizations that exceed a certain number of server installations (100 CPU cores for Tart and/or 4 hosts for Orchard)
|
||||
will be required to obtain a paid license.
|
||||
|
||||
??? note "Performance and Efficiency Cores"
|
||||
The virtual CPU cores in Tart VMs do not differentiate between the high-performance and high-efficient cores
|
||||
@@ -13,27 +14,27 @@ but organizations that exceed a certain number of server installations utilizing
|
||||
being executed within the virtual machines. As a result, both performance and energy-efficient cores of the host CPU
|
||||
are treated equally in terms of licensing.
|
||||
|
||||
# Sponsorships
|
||||
# License Tiers
|
||||
|
||||
When an organization surpasses the 100 CPU cores limit, it is required to obtain a Gold Sponsorship, which costs \$1000 per month.
|
||||
Upon reaching a limit of 500 CPU cores, a Platinum Sponsorship (\$5000 per month) will be required, and for organizations
|
||||
that exceed 5000 CPU cores, a custom Diamond Sponsorship (\$1 per core per month) will be necessary.
|
||||
When an organization surpasses the 100 CPU cores limit, it is required to obtain a Gold Tier License, which costs \$1000 per month.
|
||||
Upon reaching a limit of 500 CPU cores, a Platinum Tier License (\$5000 per month) will be required, and for organizations
|
||||
that exceed 5000 CPU cores, a custom Diamond Tier License (\$1 per core per month) will be necessary.
|
||||
|
||||
If your organization is interested in purchasing one of the sponsorships, please email [licensing@cirruslabs.org](mailto:licensing@cirruslabs.org).
|
||||
You can see a template of a sponsorship subscription agreement [here](assets/TartSponsorshipSubscriptionTemplate.pdf).
|
||||
If your organization is interested in purchasing one of the license tiers, please email [licensing@cirruslabs.org](mailto:licensing@cirruslabs.org).
|
||||
You can see a template of a license subscription agreement [here](assets/TartLicenseSubscription.pdf).
|
||||
|
||||
# General Support
|
||||
|
||||
The best way to ask general questions about particular use cases is to email our support team at [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
||||
Our support team is trying our best to respond ASAP, but there is no guarantee on a response time unless your organization
|
||||
has a sponsorship subscription which includes [Priority Support](#priority-support).
|
||||
has a paid license subscription which includes [Priority Support](#priority-support).
|
||||
|
||||
If you have a feature request or noticed lack of some documentation please feel free to [create a GitHub issue](https://github.com/cirruslabs/tart/issues/new).
|
||||
Our support team will answer it by replying to the issue or by updating the documentation.
|
||||
|
||||
# Priority Support
|
||||
|
||||
In addition to the general support we provide a *Priority Support* with guaranteed response times included in all the paid sponsorships .
|
||||
In addition to the general support we provide a *Priority Support* with guaranteed response times included in all the paid license tiers.
|
||||
|
||||
| Severity | Support Impact | First Response Time SLA | Hours | How to Submit |
|
||||
|----------|-----------------------------------------------------------------------------------------------|-------------------------|-------|--------------------------------------------------------------------------------------------------|
|
||||
@@ -56,5 +57,5 @@ In addition to the general support we provide a *Priority Support* with guarante
|
||||
Information, an enhancement, or documentation clarification is requested, but there is no impact on the operation of Tart and/or Orchard.
|
||||
|
||||
!!! info "How to submit a priority or an urgent issue"
|
||||
Once your organization [signs the Sponsorship Subscription contract](#sponsorships), members of your organization
|
||||
Once your organization [obtains a license](#license-tiers), members of your organization
|
||||
will get access to separate support emails specified in your subscription contract.
|
||||
|
||||
@@ -11,6 +11,19 @@ tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
|
||||
tart run ventura-base
|
||||
```
|
||||
|
||||
??? info "Manual installation from a release archive"
|
||||
It's also possible to manually install `tart` binary from the latest released archive:
|
||||
|
||||
```bash
|
||||
curl -LO https://github.com/cirruslabs/tart/releases/latest/download/tart.tar.gz
|
||||
tar -xzvf tart.tar.gz
|
||||
./tart.app/Contents/MacOS/tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
|
||||
./tart.app/Contents/MacOS/tart run ventura-base
|
||||
```
|
||||
|
||||
Please note that `./tart.app/Contents/MacOS/tart` binary is required to be used in order to trick macOS
|
||||
to pick `tart.app/Contents/embedded.provisionprofile` for elevated privileges that Tart needs.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartScreenshot.png"/>
|
||||
</p>
|
||||
@@ -20,7 +33,7 @@ tart run ventura-base
|
||||
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 macos-monterey-base)
|
||||
ssh admin@$(tart ip macos-ventura-base)
|
||||
```
|
||||
|
||||
## Mounting directories
|
||||
@@ -57,6 +70,17 @@ The directory we've mounted above will be accessible from the `/Volumes/My Share
|
||||
|
||||
Note: to use the directory mounting feature, the guest VM needs to run macOS 13.0 (Ventura) or newer.
|
||||
|
||||
??? tip "Changing mount location"
|
||||
It is possible to remount the directories after a virtual machine is started by running the following commands:
|
||||
|
||||
```bash
|
||||
sudo umount "/Volumes/My Shared Files"
|
||||
mkdir ~/workspace
|
||||
mount_virtiofs com.apple.virtio-fs.automount ~/workspace
|
||||
```
|
||||
|
||||
After running the above commands the direcory will be available at `~/workspace/project`
|
||||
|
||||
### Accessing mounted directories in Linux guests
|
||||
|
||||
To be able to access the shared directories from the Linux guest, you need to manually mount the virtual filesystem first:
|
||||
|
||||
@@ -127,6 +127,16 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mdx-spotlight__feature > #lottie-player {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.2rem;
|
||||
box-shadow: var(--md-shadow-z2);
|
||||
width: 25rem;
|
||||
max-width: 100%;
|
||||
background-color: rgb(5 62 94);
|
||||
}
|
||||
|
||||
.mdx-spotlight__feature figcaption {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block announce %}
|
||||
<a href="/blog/2023/04/25/announcing-orchard-orchestration-for-managing-macos-virtual-machines-at-scale/">
|
||||
🚀🚀🚀  Announcing <strong>Orchard</strong> orchestration for managing macOS virtual machines at scale 🚀🚀🚀
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
<!-- Render landing page under tabs -->
|
||||
{% block tabs %} {{ super() }}
|
||||
|
||||
@@ -75,6 +81,8 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://unpkg.com/@dotlottie/player-component@latest/dist/dotlottie-player.js"></script>
|
||||
|
||||
<!-- landing page for landing page -->
|
||||
<!-- Hero -->
|
||||
<section class="tx-container">
|
||||
@@ -82,7 +90,6 @@
|
||||
<div class="tx-landing">
|
||||
<!-- landing image -->
|
||||
<div class="tx-landing__image">
|
||||
<script src="https://unpkg.com/@dotlottie/player-component@latest/dist/dotlottie-player.js"></script>
|
||||
<dotlottie-player
|
||||
src="/assets/animations/TartLogo.lottie"
|
||||
mode="normal"
|
||||
@@ -125,7 +132,7 @@
|
||||
<div class="mdx-spotlight">
|
||||
<figure class="mdx-spotlight__feature">
|
||||
<img
|
||||
src="assets/images/spotlight/virtualization-framework.png"
|
||||
src="assets/images/spotlight/virtualization-framework.webp"
|
||||
alt="Apple’s native Virtualization.Framework"
|
||||
loading="lazy"
|
||||
width="500"
|
||||
@@ -144,13 +151,14 @@
|
||||
</figure>
|
||||
<figure class="mdx-spotlight__feature">
|
||||
<img
|
||||
src="assets/images/spotlight/supported-registries.png"
|
||||
src="assets/images/spotlight/supported-registries.webp"
|
||||
alt="OCI-compatible container registries"
|
||||
loading="lazy"
|
||||
width="500"
|
||||
height="160"
|
||||
/>
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Remote storage for Virtual Machines</h2>
|
||||
<p>
|
||||
For storing virtual machine images Tart integrates with
|
||||
OCI-compatible container registries. Work with virtual machines as
|
||||
@@ -160,13 +168,14 @@
|
||||
</figure>
|
||||
<figure class="mdx-spotlight__feature">
|
||||
<img
|
||||
src="assets/images/spotlight/github-actions runners.png"
|
||||
src="assets/images/spotlight/github-actions-runners.webp"
|
||||
alt="GitHub Actions Runners"
|
||||
loading="lazy"
|
||||
width="500"
|
||||
height="280"
|
||||
/>
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Seamless integration with your existing automations</h2>
|
||||
<p>
|
||||
Tart powers several continuous integration systems including
|
||||
<a href="/integrations/github-actions"
|
||||
@@ -180,6 +189,25 @@
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<figure class="mdx-spotlight__feature">
|
||||
<div id="lottie-player">
|
||||
<dotlottie-player
|
||||
src="/assets/animations/Orchard.lottie"
|
||||
mode="normal"
|
||||
style="height: 280px; margin: auto"
|
||||
autoplay
|
||||
loop
|
||||
/>
|
||||
</div>
|
||||
<figcaption class="md-typeset">
|
||||
<h2>Run at scale with <a href="https://github.com/cirruslabs/orchard">Orchard</a></h2>
|
||||
<p>
|
||||
Tart toolset includes Orchard Orchestration — tool to run and manage Tart virtual machines
|
||||
at scale on a cluster of Apple Silicon hosts. An Orchard Cluster exposes a simple REST API to manage
|
||||
thousands virtual machines. Orchard CLI allows accessing remote virtual machines like they run locally.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,7 +274,7 @@
|
||||
<div class="mdx-users">
|
||||
<figure class="mdx-users__testimonial">
|
||||
<img
|
||||
src="assets/images/users/seb-jachec.jpg"
|
||||
src="assets/images/users/seb-jachec.webp"
|
||||
alt="Sebastian Jachec"
|
||||
loading="lazy"
|
||||
width="200"
|
||||
@@ -270,7 +298,7 @@
|
||||
</figure>
|
||||
<figure class="mdx-users__testimonial">
|
||||
<img
|
||||
src="assets/images/users/mikhail-tokarev.jpeg"
|
||||
src="assets/images/users/mikhail-tokarev.webp"
|
||||
alt="Mikhail Tokarev"
|
||||
loading="lazy"
|
||||
width="200"
|
||||
@@ -294,7 +322,7 @@
|
||||
</figure>
|
||||
<figure class="mdx-users__testimonial">
|
||||
<img
|
||||
src="assets/images/users/max-lapides.jpeg"
|
||||
src="assets/images/users/max-lapides.webp"
|
||||
alt="Max Lapides"
|
||||
loading="lazy"
|
||||
width="200"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
source = [".build/arm64-apple-macosx/debug/tart"]
|
||||
source = [".build/arm64-apple-macosx/release/tart"]
|
||||
bundle_id = "com.github.cirruslabs.tart"
|
||||
|
||||
apple_id {
|
||||
|
||||
@@ -42,6 +42,7 @@ extra_css:
|
||||
|
||||
plugins:
|
||||
- blog
|
||||
- privacy
|
||||
- rss:
|
||||
match_path: blog/posts/.*
|
||||
date_from_meta:
|
||||
@@ -87,9 +88,11 @@ nav:
|
||||
- "Quick Start": quick-start.md
|
||||
- "Integrations":
|
||||
- "GitHub Actions": integrations/github-actions.md
|
||||
- "GitLab Runner": integrations/gitlab-runner.md
|
||||
- "Self-hosted CI": integrations/cirrus-cli.md
|
||||
- "Managing VMs": integrations/vm-management.md
|
||||
- "Licensing": licensing.md
|
||||
- "Support & Licensing": licensing.md
|
||||
- "Orchestration": https://github.com/cirruslabs/orchard
|
||||
- "FAQ": faq.md
|
||||
- "Legal":
|
||||
- 'Terms of Service': legal/terms.md
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
|
||||
# helper script to build and run a signed tart binary
|
||||
# usage: ./scripts/run-signed.sh run ventura-base
|
||||
|
||||
set -e
|
||||
|
||||
swift build --product tart
|
||||
codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
|
||||
.build/debug/tart "$@"
|
||||