Compare commits

...
60 Commits
Author SHA1 Message Date
Fedor Korotkov 704811e671 Introduce TART_NO_AUTO_PRUNE (#565)
* Introduce `TART_NO_AUTO_PRUNE`

Similar to `HOMEBREW_NO_AUTO_UPDATE`

* Self review
2023-07-25 16:01:53 +00:00
Fedor Korotkov d4fcecd47c [skip ci] Downgrade Lottie Player (#563)
Seems in 2.0.0 release they broke looping. Right now animation on https://tart.run/ is always looping even without `loop` property. I was able to disable it, so I just downgraded to the last known working version.
2023-07-20 19:46:58 +04:00
Nikolay Edigaryev 33ca96e1a0 Retrieve VM's IP for use in VNC after the VM is started #2 (#562) 2023-07-17 12:51:43 +04:00
Fedor Korotkov 4ce06279ff [skip ci] Clarify default runner group assigment (#559) 2023-07-14 07:50:31 -04:00
Fedor Korotkov fa97adfc9e Highlight Cirrus Runners in README.md (#558)
* Mention Cirrus Runners in the README.md

* Increase font size

* typo

* Fixed branch
2023-07-13 18:09:19 +00:00
Nikolay Edigaryev 6c377029d6 tart prune: allow pruning local VMs with --entries=vms (#557) 2023-07-13 22:05:28 +04:00
Fedor Korotkov 93a1b70ecb Don't delete an initiator of pruning (#556)
* Don't delete an initiator of pruning

Sometimes people have an image that is greater than half of the disk itself. In that case such image will be pulled and prunned right away.

This change makes sure that an image that is being cloned from is not pruned right away.

* Resolve symbolic links
2023-07-13 13:59:23 +04:00
Fedor Korotkov cf9a3a9221 Allow mounting a single directory without a name (#555)
* Allow mounting a single directory without a name

To utilize `VZSingleDirectoryShare` which seems more stable than `VZMultipleDirectoryShare`.

We've been having reports from users that mounted directories occasionally return "no such file" errors when building large projects. I took a stab at reproducing the issue by running https://github.com/devMEremenko/XcodeBenchmark in a mounted directory:

```bash
tart run --dir=workdir-test:~/workspace-temp/XcodeBenchmark ventura-xcode
```

And I was able to reproduce the "no such file" error on the first try! After looking into the issue I decided to try `VZSingleDirectoryShare` as this PR changes and to my pleasant surprise it all worked like a charm the next run. So it seems there is a bug in `VZMultipleDirectoryShare` integration with virtiofs. Since in most cases users only mount a single directory it makes sense to allow doing it wihtout providing a `name`.

So now it will be possible to run the following command:

```bash
tart run --dir=~/workspace-temp/XcodeBenchmark ventura-xcode
```

Which will make `~/workspace-temp/XcodeBenchmark` available under `/Volumes/My Shared Files/` without any intermediate directories.

* Reformat

* Updated description
2023-07-12 19:08:52 +00:00
Stanisław Chmiela ad566faa23 Add OCI annotation with upload time (#551) 2023-07-11 19:02:38 -04:00
Fedor Korotkov 1afaa7ec7a Reclaim disk space only in the very end of tart clone (#553)
* Reclaim disk space only in the very end of `tart clone`

Please check comments in code for reasoning.

* Phrasing

* Removed hidden argument
2023-07-11 15:49:43 +00:00
Fedor Korotkov 826e508646 Upload debug files to both Sentry projects (#552)
* Upload debug files to both Sentry projects

Apparently it's not possible to share debug files cross projects. So let's upload to both of them. To one we use in production and one we use for testing.

* Use environment variable
2023-07-11 17:49:04 +04:00
Stanisław ChmielaandFedor Korotkov 8653ca4115 Fix prune --older-than deleting all cache (#549)
* Fix `prune --older-than`

* Update Sources/tart/Commands/Prune.swift

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2023-07-11 16:10:49 +04:00
Nikolay Edigaryev 63b74f407b Version console device (#546) 2023-07-10 15:23:50 +00:00
Nikolay Edigaryev 3a2cba6929 Introduce TART_REGISTRY_HOSTNAME (#545)
* Introduce TART_REGISTRY_HOST

* TART_REGISTRY_HOST → TART_REGISTRY_HOSTNAME
2023-07-10 18:19:21 +04:00
Stanisław Chmiela 7592b86663 Add host to Registry taking port into consideration (#544) 2023-07-07 18:32:41 -04:00
Fedor Korotkov e8dbb86fc0 Support stopping of suspended VMs (#541) 2023-07-07 13:38:34 +04:00
Fedor Korotkov d2ed4ef801 Resume suspended VMs without explicit --suspendable flag (#540) 2023-07-07 11:38:31 +04:00
Nikolay EdigaryevandFedor Korotkov 2014de7dac Suspend/resume support (#527)
* Suspend/resume support

* Use RuntimeError.SuspendFailed for consistency's sake

* Add a comment about "Running" field deprecation

* Use compute credits

* Use Mac-specific input devices and remove --no-{audio,entropy}

* Suspend the VM when closing window and running with --suspendable

* Snapshotting Improvements (#539)

* Don't use static field for arguments

It throws a runtime error

* Fixed suspendability

* Lazy generation of new MAC addresses

To support cloning on suspended VMs

* Refactored

* formatted

* Configurable signal for window closing

* reformatted

* Don't generate MAC only for suspended VMs

* Removed misleading comment

* Reverted

* Lock while a suspendable VM is starting

* Lock on TART_HOME

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2023-07-06 18:04:39 +00:00
Nikolay Edigaryev 1f23b24920 PIDLock: check open(2) error (#538)
* PIDLock: check open(2) error

* Bump Sentry to 8.8.0
2023-07-04 15:22:25 +00:00
Fedor Korotkov 6ce4a06089 Document how to remount directories on macOS guests (#537) 2023-07-03 20:21:08 +04:00
Fedor Korotkov 1a2f187ac8 Fixed mouse/keyboard on Monterey guest (#535)
I guess [my comment was accurate](https://github.com/cirruslabs/tart/pull/524/files#r1239939939). Fixes #534

Tested by running `ghcr.io/cirruslabs/macos-monterey-base:latest` locally on a Sonoma host.
2023-07-01 10:35:45 +04:00
Fedor Korotkov 285bf9b6c2 Run gon right after building (#533)
To sing and stuff
2023-06-29 23:01:08 +04:00
Fedor KorotkovandNikolay Edigaryev 415ed3388d Optimistically check if we need to do anything on a pull (#531)
* Optimistically check if we need to do anything on a pull

Right now on a pull we always acquire a lock for a registry host. This is problematic because, for example, host can be pulling `ghcr.io/cirruslabs/macos-ventura-xcode:15-beta-2` image when a new request will come to pull `ghcr.io/cirruslabs/macos-ventura-xcode:latest` if needed.

In this situation, even though `ghcr.io/cirruslabs/macos-ventura-xcode:latest` is already cached and linked, `tart pull` will wait for a lock.

This change optimistically check if there is something to do at all before acquiring a lock.

* Fix linter errors

---------

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2023-06-29 10:51:41 -04:00
Nikolay Edigaryev 870b414994 tart clone: try to reclaim disk space if needed (#532) 2023-06-29 18:46:00 +04:00
Stefan Mitterrutzner be7011bf11 Adds the OCI access_token fallback field (#530) 2023-06-29 10:33:20 +00:00
Nikolay Edigaryev 859050cb42 .goreleaser.yml: remove Tart binary from the root of the archive (#526) 2023-06-29 10:54:30 +04:00
Fedor Korotkov 4f321ec264 Use "License Tier" term (#528)
Instead of a "Sponsorship"
2023-06-27 15:17:50 +00:00
Nikolay Edigaryev 1b53ce42f8 Use Mac-specific input devices when possible (#524)
* Use VZMacKeyboardConfiguration when possible

* Use VZMacTrackpadConfiguration when possible
2023-06-23 17:54:47 +00:00
Nikolay Edigaryevandfedor e89ef32a83 Enable automatic display reconfiguration for Sonoma (#521)
* Enable automatic display reconfiguration for Sonoma

* Xcode 15 Beta

---------

Co-authored-by: fedor <fedor.korotkov@gmail.com>
2023-06-23 15:25:22 +00:00
Nikolay Edigaryev 62a34bf89f Fix "file “config.json” couldn’t be opened" error when pruning (#525)
* Fix "file “config.json” couldn’t be opened" error when pruning

* No need to use the ";"
2023-06-23 15:23:25 +00:00
Jontified 0608b2b9d1 Add Mullvad logo to list of users (#520) 2023-06-21 17:04:02 +04:00
fedor 91e859de9b Updated template with removed mentions on Cirrus CI 2023-06-19 14:17:02 -07:00
Nikolay Edigaryev c79da6a12b .goreleaser.yml: include LICENSE file in the release archive (#519) 2023-06-16 15:04:14 +04:00
Fedor Korotkov 2b7ca12324 Document manual installation via release archives (#516)
* Document manual installation via release archives

* Fixed typo
2023-06-09 20:01:08 +04:00
Nikolay Edigaryev 9016fcfdd4 Use MainActor to ensure we're running on main queue (#515)
* Use MainActor to ensure we're running on main queue

...and to simplify the code.

* VZVirtualMachine.requestStop() is not asynchronous
2023-06-07 15:06:33 +04:00
Andrzej Fiedukowicz 546238d9df Replace mentions of Monteray with Ventura in quickstart guide (#510)
* Replace mentions of Monteray with Ventura in quickstart guide

They seem to just be leftovers from previous versions of the docs, so a small cleanup could be helpful.

* Update quick-start.md

* Update quick-start.md

* Update quick-start.md
2023-06-02 05:48:04 -04:00
Fedor Korotkov 2b6818c493 Clarify Licensing Use Limitation (#506)
For both Tart and Orchard

[skip ci]
2023-05-26 17:06:55 +04:00
Nikolay Edigaryev cf49fd10b6 Print errors to stderr (#504) 2023-05-18 19:11:22 +04:00
pheianoxandFedor Korotkov 59b3e0c0fb Add PITS Global Data Recovery Services to the list of companies (#499)
* Add PITS Global Data Recovery Services to the list of companies

* Remove unnecessary change at line 48

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>

* Update PITS Global Data Recovery logo location

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2023-05-16 14:36:13 -04:00
pheianox 7bbdfc06e7 Add PITSGlobalDataRecoveryServices.png logo (#500) 2023-05-16 14:35:51 -04:00
Nikolay Edigaryev 637c54e1d1 FAQ: document how to change the default DHCP lease time (#494)
* FAQ: document how to change the default DHCP lease time

* Use shell for code snippets

* Note about persistence
2023-05-09 13:30:28 +00:00
Fedor Korotkov e611d97b69 Minor docs improvements (#492)
I realised we didn't add a proper link to Orchard
2023-05-07 17:55:55 +00:00
Fedor Korotkov 8e11bbe1cd Improve error reporting for unsupported host OS version (#491)
Fixes #489
2023-05-07 21:07:26 +04:00
Nikolay Edigaryev 6de31de6bf tart login: trim newline characters at the end of --password-stdin (#486) 2023-05-04 14:02:46 +04:00
fedor 37ae7888e6 Cross-link blog posts 2023-04-28 11:52:02 -04:00
Nikolay Edigaryev 64482f4345 Blog: how we implemented SSH over gRPC in Orchard (#480) 2023-04-28 10:36:26 -04:00
Nikolay Edigaryev 4f70d01dd6 Set User-Agent header for OCI HTTP requests (#478)
* Set User-Agent header for OCI HTTP requests

* IORegistry value is actually a NUL-terminated C string

* Use sysctl instead of IOKit
2023-04-28 18:02:57 +04:00
Fedor Korotkov 9098eaf024 Optimized landing page loading (#477)
Converting all the images to WebP reduced the size more than 2x.

Plus enabled `privacy` plugin for mkdocs so the site will bundle remote resources. It appeared that loading Roboto font dynamically was adding 700ms to the page load.
2023-04-25 14:00:58 +00:00
fedor 337d95ac95 Fixed date in the blog post link 2023-04-25 09:18:45 -04:00
Fedor Korotkov 3eb8ae2aa5 [blog] Orchard Announcement (#476)
* [blog] Announcing Orchard orchestration

* Added animation to the post

* Moved date
2023-04-25 09:13:28 -04:00
Nikolay Edigaryev b03408f856 tart ip: wait for the VM to start if --wait was set (#467) 2023-04-10 10:37:00 +00:00
fedor c749bdeaf1 Update Sponsorship Template 2023-04-06 16:35:08 -04:00
fedor 8e75a59d54 Revert "Revert pkg (#462)"
This reverts commit 3fdf82079a.
2023-04-06 10:36:07 -04:00
Nikolay EdigaryevandFedor Korotkov 1d3aa5ac81 tart push: allow pushing OCI VMs from the cache too (#465)
* tart push: allow pushing OCI VMs from the cache too

* Check for RemoteName earlier

* Refactored pushing of OCI images under new tag (#466)

* Refactored pushing of OCI images under new tag

* Fixed compilation

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2023-04-06 14:20:04 +00:00
Fedor Korotkov 261c1806df Improve tart ip error message (#464)
Resolves #460
2023-04-05 18:19:39 +00:00
Fedor Korotkov 92a4b3164d Document GitLab Runner Executor (#463) 2023-04-05 09:00:54 +04:00
Fedor Korotkov 3fdf82079a Revert pkg (#462)
* Revert "Revert "Build pkg again (#457)""

This reverts commit a05684157e.

* Updated identifier for pkg
2023-04-03 19:52:05 +04:00
fedor a05684157e Revert "Build pkg again (#457)"
This reverts commit e62e921eec.
2023-04-03 10:27:12 -04:00
Fedor Korotkov e62e921eec Build pkg again (#457)
* Build .pkg again

Last time it broke in #441. The theory is that notarization of the `.pkg` before after notarizaation of the binary was breaking validation on Apple side.

This attempt does build the .pkg before we do all the dance with gon ang goreleaser.

* codesign deep

* Move back to before hooks
2023-04-03 10:09:10 -04:00
Fedor Korotkov e1bb565c3b UI improvements (#459) 2023-03-31 10:58:14 -04:00
67 changed files with 1251 additions and 287 deletions
+17 -6
View File
@@ -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]
@@ -88,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]
@@ -120,9 +128,11 @@ task:
# 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
@@ -132,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!]
+6 -7
View File
@@ -4,6 +4,9 @@ before:
hooks:
- .ci/set-version.sh
- swift build -c release --product tart
- gon gon.hcl
- mkdir -p tart.app/Contents/MacOS
- cp .build/arm64-apple-macosx/release/tart tart.app/Contents/MacOS/
builds:
- builder: prebuilt
@@ -11,11 +14,9 @@ builds:
- darwin
goarch:
- arm64
binary: tart.app/Contents/MacOS/tart
prebuilt:
path: .build/arm64-apple-macosx/release/tart
hooks:
post:
- gon gon.hcl
path: tart.app/Contents/MacOS/tart
archives:
- name_template: "{{ .ProjectName }}"
@@ -23,9 +24,7 @@ archives:
- src: Resources/embedded.provisionprofile
dst: tart.app/Contents
strip_parent: true
- src: ".build/arm64-apple-macosx/release/tart"
dst: tart.app/Contents/MacOS
strip_parent: true
- LICENSE
release:
prerelease: auto
-17
View File
@@ -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>
-8
View File
@@ -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>
-8
View File
@@ -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>
+11 -2
View File
@@ -23,8 +23,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa",
"state" : {
"revision" : "ac224c437a3070ffe34460137ac8761eddaf2852",
"version" : "8.3.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",
+3 -1
View File
@@ -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: "8.3.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",
+17 -2
View File
@@ -6,9 +6,18 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722).
* Push/Pull virtual machines from any OCI-compatible container registry.
* Use Tart Packer Plugin to automate VM creation.
* Built-in CI integration.
* Easily integrates with any CI system.
*Tart* is already adopted by several automation services:
Tart powers [Cirrus Runners](https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral)
service — a drop-in replacement for the standard GitHub-hosted runners, offering 2-3 times better performance for a fraction of the price.
<p align="center">
<a href="https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
</a>
</p>
Tart is also adopted by several other automation services:
<p align="center">
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
@@ -31,6 +40,9 @@ 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>
@@ -40,6 +52,9 @@ Many more companies are using Tart in their internal setups. Here are a few of t
<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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+21 -2
View File
@@ -3,7 +3,20 @@ import Foundation
import SystemConfiguration
struct Clone: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Clone a VM")
static var configuration = CommandConfiguration(
abstract: "Clone a VM",
discussion: """
Creates a local virtual machine by cloning either a remote or another local virtual machine.
Due to copy-on-write magic in Apple File System a cloned VM won't actually claim all the space right away.
Only changes to a cloned disk will be written and claim new space. By default, Tart checks available capacity
in Tart's home directory and checks if there is enough space for the worst possible scenario: when the whole disk
will be modified.
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable. This might be helpful
for use cases when the original image is very big and a workload is known to only modify a fraction of the cloned disk.
"""
)
@Argument(help: "source VM name")
var sourceName: String
@@ -31,7 +44,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 +56,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()), sourceVM)
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
+2 -1
View File
@@ -7,6 +7,7 @@ fileprivate struct VMInfo: Encodable {
let Disk: Int
let Display: String
let Running: Bool
let State: String
}
struct Get: AsyncParsableCommand {
@@ -25,7 +26,7 @@ struct Get: AsyncParsableCommand {
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb,
Display: vmConfig.display.description, Running: try vmDir.running())
Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state())
print(format.renderSingle(info))
}
}
+12 -1
View File
@@ -35,8 +35,19 @@ struct IP: AsyncParsableCommand {
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait) else {
throw RuntimeError.NoIPAddressFound("no IP address found, is your VM running?")
var message = "no IP address found"
if try !vmDir.running() {
message += ", is your VM running?"
}
if (vmConfig.os == .linux && resolver == .arp) {
message += " (not all Linux distributions are compatible with the ARP resolver)"
}
throw RuntimeError.NoIPAddressFound(message)
}
print(ip)
}
+3 -2
View File
@@ -7,6 +7,7 @@ fileprivate struct VMInfo: Encodable {
let Name: String
let Size: Int
let Running: Bool
let State: String
}
struct List: AsyncParsableCommand {
@@ -36,13 +37,13 @@ struct List: AsyncParsableCommand {
if source == nil || source == "local" {
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running())
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(), Running: vmDir.running())
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
})
}
+3
View File
@@ -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()
}
+102 -22
View File
@@ -5,23 +5,40 @@ import SwiftUI
import SwiftDate
struct Prune: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs")
@Option(help: ArgumentHelp("Remove cache entries last accessed more than n days ago",
@Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs."))
var entries: String = "caches"
@Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago",
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
valueName: "n"))
var olderThan: UInt?
@Option(help: ArgumentHelp("Remove least recently used cache entries that do not fit the specified cache size budget n, expressed in gigabytes",
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
valueName: "n"))
@Option(help: .hidden)
var cacheBudget: UInt?
@Option(help: ArgumentHelp("Remove the least recently used entries that do not fit the specified space size budget n, expressed in gigabytes",
discussion: "For example, --space-budget=50 will effectively shrink all entries to a total size of 50 gigabytes.",
valueName: "n"))
var spaceBudget: UInt?
@Flag(help: .hidden)
var gc: Bool = false
func validate() throws {
if olderThan == nil && cacheBudget == nil && !gc {
mutating func validate() throws {
// --cache-budget deprecation logic
if let cacheBudget = cacheBudget {
fputs("--cache-budget is deprecated, please use --space-budget\n", stderr)
if spaceBudget != nil {
throw ValidationError("--cache-budget is deprecated, please use --space-budget")
}
spaceBudget = cacheBudget
}
if olderThan == nil && spaceBudget == nil && !gc {
throw ValidationError("at least one pruning criteria must be specified")
}
}
@@ -31,43 +48,53 @@ struct Prune: AsyncParsableCommand {
try VMStorageOCI().gc()
}
// Build a list of prunable storages that we're going to prune based on user's request
let prunableStorages: [PrunableStorage]
switch entries {
case "caches":
prunableStorages = [VMStorageOCI(), try IPSWCache()]
case "vms":
prunableStorages = [VMStorageLocal()]
default:
throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"")
}
// 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)
try Prune.pruneOlderThan(prunableStorages: prunableStorages, olderThanDate: olderThanDate)
}
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
if let cacheBudget = cacheBudget {
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
if let spaceBudget = spaceBudget {
try Prune.pruneSpaceBudget(prunableStorages: prunableStorages, spaceBudgetBytes: UInt64(spaceBudget) * 1024 * 1024 * 1024)
}
}
static func pruneOlderThan(olderThanDate: Date) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
static func pruneOlderThan(prunableStorages: [PrunableStorage], olderThanDate: Date) throws {
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
}
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() }
var cacheBudgetBytes = cacheBudgetBytes
var spaceBudgetBytes = spaceBudgetBytes
var prunablesToDelete: [Prunable] = []
for prunable in prunables {
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
if prunableSizeBytes <= cacheBudgetBytes {
if prunableSizeBytes <= spaceBudgetBytes {
// Don't mark for deletion as
// there's a budget available
cacheBudgetBytes -= prunableSizeBytes
spaceBudgetBytes -= prunableSizeBytes
} else {
// Mark for deletion
prunablesToDelete.append(prunable)
@@ -77,7 +104,54 @@ struct Prune: AsyncParsableCommand {
try prunablesToDelete.forEach { try $0.delete() }
}
static func pruneReclaim(reclaimBytes: UInt64) throws {
static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {
if ProcessInfo.processInfo.environment.keys.contains("TART_NO_AUTO_PRUNE") {
return
}
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, initiator)
}
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) 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 +172,16 @@ struct Prune: AsyncParsableCommand {
break
}
cacheReclaimedBytes += try prunable.sizeBytes()
try prunable.delete()
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
// do not prune the initiator
continue
}
try SentrySDK.span?.setExtra(value: prunable.sizeBytes(), key: prunable.url.path);
try SentrySDK.span?.setData(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);
+10 -1
View File
@@ -3,7 +3,16 @@ import Dispatch
import SwiftUI
struct Pull: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Pull a VM from a registry")
static var configuration = CommandConfiguration(
abstract: "Pull a VM from a registry",
discussion: """
Pulls a virtual machine from a remote OCI-compatible registry. Supports authorization via Keychain (see "tart login --help"),
Docker credential helpers defined in ~/.docker/config.json or via TART_REGISTRY_USERNAME/TART_REGISTRY_PASSWORD environment variables.
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image to fit via "tart prune".
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
"""
)
@Argument(help: "remote VM name")
var remoteName: String
+44 -11
View File
@@ -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 {
+220 -46
View File
@@ -1,4 +1,5 @@
import ArgumentParser
import Cocoa
import Dispatch
import SwiftUI
import Virtualization
@@ -72,13 +73,17 @@ struct Run: AsyncParsableCommand {
var rosettaTag: String?
@Option(help: ArgumentHelp("""
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")
""", discussion: """
Requires host to be macOS 13.0 (Ventura) or newer.
All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS,
A shared directory is automatically mounted to "/Volumes/My Shared Files" directory on macOS,
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
For macOS guests, they must be running macOS 13.0 (Ventura) or newer.
""", valueName: "name:path[:ro]"))
In case of passing multiple directories it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\"
These names will be used as directory names under the mounting point inside guests. For the example above it will be
"/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively.
""", valueName: "[name:]path[:ro]"))
var dir: [String] = []
@Option(help: ArgumentHelp("""
@@ -92,10 +97,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")
}
@@ -103,11 +112,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()
@@ -152,7 +183,8 @@ struct Run: AsyncParsableCommand {
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
additionalDiskAttachments: additionalDiskAttachments,
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
serialPorts: serialPorts
serialPorts: serialPorts,
suspendable: suspendable
)
let vncImpl: VNC? = try {
@@ -183,8 +215,25 @@ 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 {
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!.start(recovery: recovery, resume: resume)
if let vncImpl = vncImpl {
let vncURL = try await vncImpl.waitForURL()
@@ -196,7 +245,7 @@ struct Run: AsyncParsableCommand {
}
}
try await vm!.run(recovery)
try await vm!.run()
if let vncImpl = vncImpl {
try vncImpl.stop()
@@ -208,23 +257,56 @@ 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)
}
}
@@ -314,46 +396,31 @@ struct Run: AsyncParsableCommand {
throw UnsupportedOSError("directory sharing", "is")
}
struct DirectoryShare {
let name: String
let path: URL
let readOnly: Bool
}
var directoryShares: [DirectoryShare] = []
var allNamedShares = true
for rawDir in dir {
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
if splits.count < 2 {
throw ValidationError("invalid --dir syntax: should at least include name and path, colon-separated")
let directoryShare = try DirectoryShare(parseFrom: rawDir)
if (directoryShare.name == nil) {
allNamedShares = false
}
var readOnly: Bool = false
if splits.count == 3 {
if splits[2] == "ro" {
readOnly = true
} else {
throw ValidationError("invalid --dir syntax: optional read-only specifier can only be \"ro\"")
}
}
let (name, path) = (String(splits[0]), String(splits[1]))
directoryShares.append(DirectoryShare(
name: name,
path: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath),
readOnly: readOnly)
)
directoryShares.append(directoryShare)
}
var directories: [String : VZSharedDirectory] = Dictionary()
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
if allNamedShares {
var directories: [String : VZSharedDirectory] = Dictionary()
directoryShares.forEach { directories[$0.name!] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
} else if dir.count > 1 {
throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named")
} else if dir.count == 1 {
let directoryShare = directoryShares.first!
let singleDirectoryShare = VZSingleDirectoryShare(directory: VZSharedDirectory(url: directoryShare.path, readOnly: directoryShare.readOnly))
sharingDevice.share = singleDirectoryShare
}
return [sharingDevice]
}
@@ -383,7 +450,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)
@@ -391,13 +458,17 @@ 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 {
let ret = kill(getpid(), SIGINT)
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
@@ -405,7 +476,14 @@ struct Run: AsyncParsableCommand {
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: {})
@@ -415,23 +493,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,
])
}
}
@@ -444,7 +567,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
}
@@ -452,3 +586,43 @@ struct VMView: NSViewRepresentable {
nsView.virtualMachine = vm.virtualMachine
}
}
struct DirectoryShare {
let name: String?
let path: URL
let readOnly: Bool
init(parseFrom: String) throws {
let splits = parseFrom.split(maxSplits: 2) { $0 == ":" }
if splits.count == 3 {
if splits[2] == "ro" {
readOnly = true
} else {
throw ValidationError("invalid --dir syntax: optional read-only specifier can only be \"ro\"")
}
name = String(splits[0])
path = String(splits[1]).toFilePathURL()
} else if splits.count == 2 {
if splits[1] == "ro" {
name = nil
path = String(splits[0]).toFilePathURL()
readOnly = true
} else {
name = String(splits[0])
path = String(splits[1]).toFilePathURL()
readOnly = false
}
} else {
name = nil
path = String(splits[0]).toFilePathURL()
readOnly = false
}
}
}
extension String {
func toFilePathURL() -> URL {
URL(fileURLWithPath: NSString(string: self).expandingTildeInPath)
}
}
+15
View File
@@ -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
+28
View File
@@ -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 {
+37
View File
@@ -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
}
}
+10 -4
View File
@@ -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:
+11 -4
View File
@@ -5,6 +5,7 @@ let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
// Annotations
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
struct OCIManifest: Codable, Equatable {
var schemaVersion: Int = 2
@@ -13,15 +14,21 @@ struct OCIManifest: Codable, Equatable {
var layers: [OCIManifestLayer] = Array()
var annotations: Dictionary<String, String>?
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil) {
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil, uploadDate: Date? = nil) {
self.config = config
self.layers = layers
var annotations: [String: String] = [:]
if let uncompressedDiskSize = uncompressedDiskSize {
annotations = [
uncompressedDiskSizeAnnotation: String(uncompressedDiskSize)
]
annotations[uncompressedDiskSizeAnnotation] = String(uncompressedDiskSize)
}
if let uploadDate = uploadDate {
annotations[uploadTimeAnnotation] = uploadDate.toISO()
}
self.annotations = annotations
}
init(fromJSON: Data) throws {
+24 -4
View File
@@ -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)
}
}
+5
View File
@@ -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 {
+41 -4
View File
@@ -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 []
}
}
}
+5 -1
View File
@@ -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()]
}
+7 -1
View File
@@ -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]
}
+6 -1
View File
@@ -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)
}
+11 -19
View File
@@ -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)
}
}
}
+84 -45
View File
@@ -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)
@@ -41,7 +44,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
serialPorts: [VZSerialPortConfiguration] = []
serialPorts: [VZSerialPortConfiguration] = [],
suspendable: Bool = false
) throws {
name = vmDir.name
config = try VMConfig.init(fromURL: vmDir.configURL)
@@ -52,11 +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,
serialPorts: serialPorts
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)
@@ -179,11 +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,
serialPorts: serialPorts
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
vmConfig: config, network: network,
additionalDiskAttachments: additionalDiskAttachments,
directorySharingDevices: directorySharingDevices,
serialPorts: serialPorts
)
virtualMachine = VZVirtualMachine(configuration: configuration)
@@ -220,27 +225,18 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
return try VM(vmDir: vmDir)
}
func run(_ recovery: Bool) async throws {
func start(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
func run() async throws {
await withTaskCancellationHandler(operation: {
// Wait for the VM to finish running
// or for the exit condition
sema.wait()
@@ -249,16 +245,35 @@ 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,
@@ -266,7 +281,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
serialPorts: [VZSerialPortConfiguration]
serialPorts: [VZSerialPortConfiguration],
suspendable: Bool = false
) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration()
@@ -278,23 +294,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()
@@ -308,7 +331,9 @@ 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
@@ -316,6 +341,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// 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
+3 -2
View File
@@ -148,7 +148,8 @@ extension VMDirectory {
let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
layers: layers,
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
uncompressedDiskSize: UInt64(mappedDiskReadOffset),
uploadDate: Date()
)
// Manifest
@@ -159,7 +160,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)
}
}
+28 -3
View File
@@ -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")
@@ -27,7 +30,26 @@ struct VMDirectory: Prunable {
}
func running() throws -> Bool {
try PIDLock(lockURL: configURL).pid() != 0
// 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 {
@@ -55,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 {
@@ -70,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 {
@@ -85,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)
}
+6
View File
@@ -57,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 {
@@ -98,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)"
}
}
}
+8 -2
View File
@@ -1,6 +1,6 @@
import Foundation
class VMStorageLocal {
class VMStorageLocal: PrunableStorage {
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
private func vmURL(_ name: String) -> URL {
@@ -14,7 +14,9 @@ class VMStorageLocal {
func open(_ name: String) throws -> VMDirectory {
let vmDir = VMDirectory(baseURL: vmURL(name))
try vmDir.validate()
try vmDir.validate(userFriendlyName: name)
try vmDir.baseURL.updateAccessDate()
return vmDir
}
@@ -63,6 +65,10 @@ class VMStorageLocal {
}
}
func prunables() throws -> [Prunable] {
try list().map { (_, vmDir) in vmDir }
}
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
try list().contains { try $1.macAddress() == macAddress }
}
+34 -28
View File
@@ -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))
+32
View File
@@ -0,0 +1,32 @@
import XCTest
@testable import tart
final class DirectoryShareTests: XCTestCase {
func testNamedParsing() throws {
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build")
XCTAssertEqual(share.name, "build")
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
XCTAssertFalse(share.readOnly)
}
func testNamedReadOnlyParsing() throws {
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build:ro")
XCTAssertEqual(share.name, "build")
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
XCTAssertTrue(share.readOnly)
}
func testOptionalNameParsing() throws {
let share = try DirectoryShare(parseFrom: "/Users/admin/build")
XCTAssertNil(share.name)
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
XCTAssertFalse(share.readOnly)
}
func testOptionalNameReadOnlyParsing() throws {
let share = try DirectoryShare(parseFrom: "/Users/admin/build:ro")
XCTAssertNil(share.name)
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
XCTAssertTrue(share.readOnly)
}
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

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?
+94
View File
@@ -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:
![OpenAPI documentation for Orchard's port-forwarding endpoint](/assets/images/orchard-port-forwarding-api.png)
All you need is to use a WebSocket client when accessing this endpoint to make it work.
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)!
+17 -5
View File
@@ -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.
+1
View File
@@ -28,5 +28,6 @@ jobs:
```
When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`.
Note that Cirrus Runners will get added to the default runner group. By default, only private repositories can access runners in a default runner group, but you can override this in your organization's settings.
![](/assets/images/TartGHARunners.png)
+49
View File
@@ -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).
+13 -12
View File
@@ -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.
+25 -1
View File
@@ -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:
+10
View File
@@ -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;
}
+35 -7
View File
@@ -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/">
🚀🚀🚀&nbsp&nbspAnnouncing <strong>Orchard</strong> orchestration for managing macOS virtual machines at scale&nbsp;&nbsp;🚀🚀🚀
</a>
{% endblock %}
<!-- Render landing page under tabs -->
{% block tabs %} {{ super() }}
@@ -75,6 +81,8 @@
}
</style>
<script src="https://unpkg.com/@dotlottie/player-component@1.4.0/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 &mdash; 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"
+4 -1
View File
@@ -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
+11
View File
@@ -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 "$@"