Compare commits

...
95 Commits
Author SHA1 Message Date
Nikolay Edigaryev d8b69de52d Fetcher.fetchViaFile(): use an mmap(2)-ed file, similarly to DiskV1 (#641)
* Fetcher.fetchViaFile(): use an mmap(2)-ed file, similarly to DiskV1

* No need to convert Data to Data
2023-11-01 15:00:48 +04:00
Nikolay Edigaryev c4c2bfeded tart-dev.entitlements: add "com.apple.security.get-task-allow" (#642) 2023-11-01 14:35:55 +04:00
Fedor Korotkov b95585b56b Don't forget to finalize output stream (#640)
There is a suspicion that this might leak memory
2023-11-01 12:19:11 +04:00
Fedor Korotkov 8d5574ed3f Removed usage of deprecated APIs (#628)
See https://developer.apple.com/documentation/virtualization/vzmacauxiliarystorage/3816043-init
2023-10-11 17:05:49 -04:00
Fedor Korotkov 457c2bc7db Adjusted live installation counter (#627) 2023-10-11 20:55:51 +00:00
Fedor Korotkov 4bf9bdd531 Document Tart on AWS (#625)
Fixes #581
2023-10-07 12:06:06 +04:00
Fedor Korotkov 8e9d61d5f5 Validate that a suspendable VM doesn't have shared directories (#623) 2023-10-04 20:21:45 +04:00
Fedor KorotkovandNikolay Edigaryev 71d03226fe Support mounting remote archives (#620)
* Support mounting remote archives

Allow to pass an HTTPS link instead of a local path to `tart run --dir` argument. HTTPS link should point to a gzipped Tar archive aka `*.tar.gz` file.

In this situation Tart will download an archive by the link if necessary, will cache it and will unarchive it into a temporary folder inside `$TART_HOME` to be mounted to the VM.

This use case is useful for mounting something external that updates faster than the VM itself. For example, GitHub Actions Runner installation.

* Don't use async/await APIs to prevent from deadlocks because of the MainActor thing

* Prefer cached data

* Moved comment

* Fix URLCache caching files in memory instead of on-disk (#622)

* Fix URLCache caching files in memory instead of on-disk

* Fix disk capacity typo

* Moved log

* Moved fetching logic to `DirectoryShare#createConfiguration` method

---------

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2023-10-03 23:01:29 +04:00
Nikolay Edigaryev 36dab9878d tart: bump max password characters from 256 to 1024 (#618) 2023-10-02 13:58:55 +00:00
Nikolay Edigaryev f634002813 tart run: disable console device when --suspendable is requested (#615) 2023-09-30 21:13:44 +04:00
Fedor Korotkov 8e79669afb Configure Markdown Linter (#614) 2023-09-29 03:35:17 -04:00
Fedor KorotkovandNikolay Edigaryev 2da8bc0fb5 Document XL Cirrus Runners (#613)
* Document XL Cirrus Runners

Also tried to put everything about Cirrus Runners in one place rather than having the information spreaded between https://tart.run and https://github.com/apps/cirrus-runners.

Plus updated docs to use Sonoma.

* Apply suggestions from code review

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>

---------

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2023-09-28 15:53:13 -04:00
Nikolay Edigaryev 2d984ba194 .cirrus.yml: add an execution_lock: for integration tests (#610)
* .cirrus.yml: add an execution_lock: for integration tests

* Use Persistent Worker's resources instead of grabbing an execution lock
2023-09-22 22:52:52 +04:00
Fedor Korotkov 50ce44c3eb Support block devices on Sonoma (#611)
* Support block devices on Sonoma

* Updated docs

* Removed unused error
2023-09-22 22:30:18 +04:00
Fedor Korotkov c9e49ceb39 Missing user
So it's an even number of them
2023-09-22 12:10:13 -04:00
Fedor Korotkov 6df50e55d8 --suspendable devices fallback on Ventura host (#605)
Fixes #604
2023-09-22 07:38:13 -04:00
Nikolay Edigaryev 1fd710d00d web: fix GitLab Runner integration link (#608)
Resolves https://github.com/cirruslabs/tart/issues/607.
2023-09-22 07:38:02 -04:00
Rui Marinho 4f6c7e79e1 Add Uphold as a Tart user (#606) 2023-09-22 07:37:02 -04:00
fedor 1afb43e85b Updated announcement link 2023-09-20 10:58:17 -04:00
Alex Clay 954cac3bee Change brew update to brew upgrade (#602) 2023-09-20 14:35:03 +00:00
Nikolay Edigaryev 3ff4fc34c6 Improved format for fast and efficient pulls from remote OCI-registry (#589)
* Improved format for fast and efficient pulls from remote OCI-registry

* Tests: introduce fileWithRandomData() helper function

* Remove useless continuation

* --concurrency should be an option, not an argument

* --v2-disk-format → --old-disk-format and use the new V2 by default

* Reduce LZ4 buffer size from 64 to 4 MiB

* --old-disk-format → --disk-format=...
2023-09-20 10:14:05 -04:00
Fedor Korotkov e118b42b1f Tart 2.0.0 blog post (#601) 2023-09-20 10:13:42 -04:00
Fedor Korotkov f823190039 Build with the release version of Xcode 15 (#600) 2023-09-19 14:34:21 +00:00
Nikolay Edigaryev 27cadc3f3b tart create --from-ipsw: do a HEAD instead of a GET first (#599) 2023-09-14 17:16:08 +00:00
Nikolay Edigaryev d4d3852745 Return exit code 2 on RuntimeError.VMDoesNotExist (#597)
* Return exit code 2 on RuntimeError.VMDoesNotExist

* Upgrade isFileNotFound() do detect underlying errors
2023-09-12 13:56:49 +04:00
Fedor Korotkov 4bb248e7b4 Support wildcards in credHelpers (#592)
* Support wildcards in `credHelpers`

With #591 `tart pull` fails when for example you have `ecr-login` set as the default `credsStore` but you try to pull our images from `ghcr.io`.

This change reverts #591 and instead supports regex in `credHelpers`. This is not supported by Docker itself but highly demanded in https://github.com/docker/cli/issues/2928

I think it's fine to support it for Tart.

Additionally this change bumps the minimum host macOS version to Ventura in order to bring `Regex`. Yes, `Regex` only supported in Swift for macOS 13+ 🤦‍♂️I think it's fine in the light of Sonoma release and Tart 2.0.0.

* Removed Monterey mentions from docs
2023-08-28 11:37:49 -04:00
Fedor Korotkov f45551cbf0 Support Docker's credsStore (#591)
This way for #581 we don't need to specify a fully quialified URL and can simply use the following `~/.docker/config.json`:

```json
{
	"credsStore": "ecr-login"
}
```

Related to https://github.com/docker/cli/issues/2928
2023-08-21 19:44:35 +00:00
Nikolay Edigaryev f68297097e Add a simple integration test for "tart run" (#587)
* Add a simple integration test for "tart run"

* Integration tests: only "tart list" local VM images
2023-08-16 04:04:52 -04:00
Fedor Korotkov 637a2387e7 No bridged network interfaces by default (#586)
Fixes #585
2023-08-15 19:47:25 +04:00
Fedor Korotkov 050d6a6ff1 Clarify host cpu core usage (#584)
* Clarify usage of cores of the host CPU

* Updated phrasing
2023-08-15 11:25:30 +00:00
Nikolay Edigaryev 5eddd1ce41 Introduce "tart logout" command (#583)
* Introduce "tart logout"

* tart login: introduce --no-validate
2023-08-15 15:16:33 +04:00
Fedor Korotkov 35f5b30bc4 Multiple bridged interfaces (#578)
* Support multiple Bridged Network interfaces

Fixes #572

* Allow duplicated bridged interfaces
2023-08-14 19:17:38 +04:00
Fedor Korotkov 8b27fea745 Document running scripts via ssh (#579) 2023-08-14 06:45:44 -04:00
fedor e00f62c95a Bumped dotlottie file 2023-08-10 13:52:27 -04:00
TommyandFedor Korotkov d90893e9a0 Add CONTRIBUTING.md? (#575)
* Create contribute.md

* Update CONTRIBUTING.md

Co-Authored-By: Fedor Korotkov <fedor.korotkov@gmail.com>

* Update CONTRIBUTING.md

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

* Update CONTRIBUTING.md

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

* missing '''

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2023-08-03 13:10:34 -04:00
TommyandFedor Korotkov feb733a7c0 GC avoidance and tmpDeterminstic (#570)
* GC avoidance and tmpDeterminstic

* change tmpDeterministic to use hashing

- temporaryDeterministic() now takes in a key and hashes it
- creates directory with the hash

* Update Sources/tart/VMStorageOCI.swift

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

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2023-07-31 18:21:42 +00:00
Fedor Korotkov 545b6fcd94 Provide license usage examples (#568) 2023-07-27 12:06:40 -04:00
Nikolay Edigaryev c750d63ac9 Clarify licensing (#566)
* Clarify licensing

* Refactor "License Tiers" section
2023-07-26 14:50:44 +00:00
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
98 changed files with 2399 additions and 478 deletions
+22 -7
View File
@@ -1,11 +1,16 @@
use_compute_credits: true
env:
XCODE_TAG: 15
task:
name: Test on Ventura
name: Test on Sonoma
alias: test
persistent_worker:
labels:
name: dev-mini
resources:
tart-vms: 1
test_script:
- swift test
integration_test_script:
@@ -24,11 +29,19 @@ task:
path: "integration-tests/pytest-junit.xml"
format: junit
task:
name: Markdown Lint
only_if: $CIRRUS_BRANCH != 'gh-pages' && changesInclude('**.md')
container:
image: node:latest
install_script: npm install -g markdownlint-cli
lint_script: markdownlint --config=docs/.markdownlint.yml docs/
task:
name: Lint
alias: lint
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
lint_script:
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
always:
@@ -37,24 +50,24 @@ task:
format: swiftformat
task:
only_if: $CIRRUS_TAG == ''
name: Build
alias: build
only_if: $CIRRUS_TAG == ''
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
image: ghcr.io/cirruslabs/macos-sonoma-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
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
@@ -89,7 +102,7 @@ task:
- test
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
@@ -120,9 +133,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
+7 -8
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
@@ -46,7 +45,7 @@ brews:
libexec.install Dir["*"]
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
custom_block: |
depends_on :macos => :monterey
depends_on :macos => :ventura
on_macos do
unless Hardware::CPU.arm?
+40
View File
@@ -0,0 +1,40 @@
# Contributing to Tart
Table of Contents
-----------------
- [How to Build](#how-to-build)
- [How to Create an Issue/Enhancement](#how-to-create-an-issueenhancement)
- [Style Guidelines](#style-guidelines)
- [Pull Requests](#Pull-Requests)
## How to Build
1. Fork the repository to your own GitHub account
2. Clone the forked repository to your local machine
3. If using Xcode, use from Xcode 15 or newer
4. Run ./scripts/run-signed.sh from the root of your repository
```bash
./scripts/run-signed.sh list
```
## How to Create an Issue/Enhancement
1. Go to the [Issue page](https://github.com/cirruslabs/tart/issues) of the repository
2. Click on the "New Issue" button
3. Provide a descriptive title and detailed description of the issue or enhancement you're suggesting
4. Submit the issue
## Style Guidelines
1. Code should follow camel case
2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command:
```bash
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
```
## Pull Requests
1. Provide a detailed description of the changes you made in the pull request
2. Wait for pull request to be reviewed
3. Make adjustments if necessary
+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",
+4 -2
View File
@@ -4,7 +4,7 @@ import PackageDescription
let package = Package(
name: "Tart",
platforms: [
.macOS(.v12)
.macOS(.v13)
],
products: [
.executable(name: "tart", targets: ["tart"])
@@ -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",
+23 -5
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,18 +52,24 @@ 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://uphold.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Uphold.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).
## Usage
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
```bash
brew install cirruslabs/cli/tart
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
tart run ventura-base
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
tart run sonoma-base
```
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

+2
View File
@@ -4,5 +4,7 @@
<dict>
<key>com.apple.security.virtualization</key>
<true/>
<key>com.apple.security.get-task-allow</key>
<true/>
</dict>
</plist>
+29 -3
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
@@ -14,10 +27,17 @@ struct Clone: AsyncParsableCommand {
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
var concurrency: UInt = 4
func validate() throws {
if newName.contains("/") {
throw ValidationError("<new-name> should be a local name")
}
if concurrency < 1 {
throw ValidationError("network concurrency cannot be less than 1")
}
}
func run() async throws {
@@ -27,11 +47,10 @@ struct Clone: AsyncParsableCommand {
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
// Pull the VM in case it's OCI-based and doesn't exist locally yet
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
try await ociStorage.pull(remoteName, registry: registry)
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency)
}
let sourceVM = try VMStorageHelper.open(sourceName)
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
@@ -44,10 +63,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)
})
+1 -5
View File
@@ -47,11 +47,7 @@ struct Create: AsyncParsableCommand {
}
if linux {
if #available(macOS 13, *) {
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
} else {
throw UnsupportedOSError("Linux VMs", "are")
}
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
}
try VMStorageLocal().move(name, from: tmpVMDir)
+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())
})
}
+14 -6
View File
@@ -17,6 +17,9 @@ struct Login: AsyncParsableCommand {
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
@Flag(help: "skip validation of the registry's credentials before logging-in")
var noValidate: Bool = false
func validate() throws {
let usernameProvided = username != nil
let passwordProvided = passwordStdin
@@ -35,6 +38,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()
}
@@ -42,12 +48,14 @@ struct Login: AsyncParsableCommand {
host: (user, password)
])
do {
let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProviders: [credentialsProvider])
try await registry.ping()
} catch {
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
if !noValidate {
do {
let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProviders: [credentialsProvider])
try await registry.ping()
} catch {
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
}
}
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
+14
View File
@@ -0,0 +1,14 @@
import ArgumentParser
import Dispatch
import SwiftUI
struct Logout: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Logout from a registry")
@Argument(help: "host")
var host: String
func run() async throws {
try KeychainCredentialsProvider().remove(host: host)
}
}
+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);
+20 -2
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
@@ -11,6 +20,15 @@ struct Pull: AsyncParsableCommand {
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
var concurrency: UInt = 4
func validate() throws {
if concurrency < 1 {
throw ValidationError("network concurrency cannot be less than 1")
}
}
func run() async throws {
// Be more liberal when accepting local image as argument,
// see https://github.com/cirruslabs/tart/issues/36
@@ -25,6 +43,6 @@ struct Pull: AsyncParsableCommand {
defaultLogger.appendNewLine("pulling \(remoteName)...")
try await VMStorageOCI().pull(remoteName, registry: registry)
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency)
}
}
+48 -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)")
@@ -23,12 +23,16 @@ struct Push: AsyncParsableCommand {
"""))
var chunkSize: Int = 0
@Option(help: .hidden)
var diskFormat: String = "v2"
@Flag(help: ArgumentHelp("cache pushed images locally",
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
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 +57,56 @@ 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,
diskFormat: diskFormat
)
// 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 {
+310 -89
View File
@@ -1,5 +1,6 @@
import ArgumentParser
import Cocoa
import Darwin
import Dispatch
import SwiftUI
import Virtualization
@@ -51,10 +52,17 @@ struct Run: AsyncParsableCommand {
var vncExperimental: Bool = false
@Option(help: ArgumentHelp("""
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\" --disk=\"/dev/disk0\")
""", discussion: """
Can be either a disk image file or a block device like a local SSD on AWS EC2 Mac instances.
Learn how to create a disk image using Disk Utility here:
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
To work with block devices 'tart' binary must be executed as root which affects locating Tart VMs.
To workaround this issue pass TART_HOME explicitly:
sudo TART_HOME="$HOME/.tart" tart run sonoma --disk=/dev/disk0
""", valueName: "path[:ro]"))
var disk: [String] = []
@@ -73,13 +81,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("""
@@ -87,28 +99,60 @@ struct Run: AsyncParsableCommand {
""", discussion: """
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
""", valueName: "interface name"))
var netBridged: String?
var netBridged: [String] = []
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
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 {
if netBridged.count > 0 && netSoftnet {
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
}
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
}
if suspendable {
if dir.count > 0 {
throw ValidationError("Suspending VMs with shared directories is not supported")
}
}
}
@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()
@@ -116,20 +160,6 @@ struct Run: AsyncParsableCommand {
let additionalDiskAttachments = try additionalDiskAttachments()
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
// see https://github.com/cirruslabs/tart/issues/323 for more details.
for additionalDiskAttachment in additionalDiskAttachments {
// Read-only attachments do not seem to acquire the lock
if additionalDiskAttachment.isReadOnly {
continue
}
if try !FileLock(lockURL: additionalDiskAttachment.url).trylock() {
throw RuntimeError.DiskAlreadyInUse("disk \(additionalDiskAttachment.url.path) seems to be already in use, "
+ "unmount it first in Finder")
}
}
var serialPorts: [VZSerialPortConfiguration] = []
if serial {
let tty_fd = createPTY()
@@ -151,9 +181,10 @@ struct Run: AsyncParsableCommand {
vm = try VM(
vmDir: vmDir,
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
additionalDiskAttachments: additionalDiskAttachments,
additionalStorageDevices: additionalDiskAttachments,
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
serialPorts: serialPorts
serialPorts: serialPorts,
suspendable: suspendable
)
let vncImpl: VNC? = try {
@@ -184,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()
@@ -197,7 +245,7 @@ struct Run: AsyncParsableCommand {
}
}
try await vm!.run(recovery)
try await vm!.run()
if let vncImpl = vncImpl {
try vncImpl.stop()
@@ -209,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)
}
}
@@ -250,23 +331,19 @@ struct Run: AsyncParsableCommand {
return try Softnet(vmMACAddress: config.macAddress.string)
}
if let netBridged = netBridged {
let matchingInterfaces = VZBridgedNetworkInterface.networkInterfaces.filter { interface in
interface.identifier == netBridged || interface.localizedDisplayName == netBridged
if netBridged.count > 0 {
func findBridgedInterface(_ name: String) throws -> VZBridgedNetworkInterface {
let interface = VZBridgedNetworkInterface.networkInterfaces.first { interface in
interface.identifier == name || interface.localizedDisplayName == name
}
if (interface == nil) {
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
+ "available interfaces: \(bridgeInterfaces())")
}
return interface!
}
if matchingInterfaces.isEmpty {
let available = bridgeInterfaces().joined(separator: ", ")
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
+ "available interfaces: \(available)")
}
if matchingInterfaces.count > 1 {
throw ValidationError("more than one bridge interface matched \"\(netBridged)\", "
+ "consider refining the search criteria")
}
return NetworkBridged(interface: matchingInterfaces.first!)
return NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
}
return nil
@@ -284,22 +361,43 @@ struct Run: AsyncParsableCommand {
}
}
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
var result: [VZDiskImageStorageDeviceAttachment] = []
func additionalDiskAttachments() throws -> [VZStorageDeviceConfiguration] {
var result: [VZStorageDeviceConfiguration] = []
let readOnlySuffix = ":ro"
let expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
for rawDisk in expandedDiskPaths {
if rawDisk.hasSuffix(readOnlySuffix) {
result.append(try VZDiskImageStorageDeviceAttachment(
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
readOnly: true
))
let diskReadOnly = rawDisk.hasSuffix(readOnlySuffix)
let diskPath = diskReadOnly ? String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count)) : rawDisk
let diskURL = URL(fileURLWithPath: diskPath)
// check if `diskPath` is a block device or a directory
if pathHasMode(diskPath, mode: S_IFBLK) || pathHasMode(diskPath, mode: S_IFDIR) {
print("Using block device\n")
guard #available(macOS 14, *) else {
throw UnsupportedOSError("attaching block devices", "are")
}
let fileHandle = FileHandle(forUpdatingAtPath: diskPath)
guard fileHandle != nil else {
if ProcessInfo.processInfo.userName != "root" {
throw RuntimeError.VMConfigurationError("need to run as root to work with block devices")
}
throw RuntimeError.VMConfigurationError("block device \(diskURL.url.path) seems to be already in use, unmount it first via 'diskutil unmount'")
}
let attachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: fileHandle!, readOnly: diskReadOnly, synchronizationMode: .full)
result.append(VZVirtioBlockDeviceConfiguration(attachment: attachment))
} else {
result.append(try VZDiskImageStorageDeviceAttachment(
url: URL(fileURLWithPath: rawDisk),
readOnly: false
))
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
// see https://github.com/cirruslabs/tart/issues/323 for more details.
if try !diskReadOnly && !FileLock(lockURL: diskURL).trylock() {
throw RuntimeError.DiskAlreadyInUse("disk \(diskURL.url.path) seems to be already in use, unmount it first in Finder")
}
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(
url: diskURL,
readOnly: diskReadOnly
)
result.append(VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment))
}
}
@@ -315,46 +413,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()
try directoryShares.forEach { directories[$0.name!] = try $0.createConfiguration() }
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: try directoryShare.createConfiguration())
sharingDevice.share = singleDirectoryShare
}
return [sharingDevice]
}
@@ -384,7 +467,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)
@@ -392,6 +475,8 @@ 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 {
@@ -400,7 +485,7 @@ struct Run: AsyncParsableCommand {
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
@@ -408,7 +493,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: {})
@@ -427,13 +519,19 @@ struct Run: AsyncParsableCommand {
Task { try await vm!.virtualMachine.stop() }
}
Button("Request Stop") {
Task { try await vm!.virtualMachine.requestStop() }
Task { try vm!.virtualMachine.requestStop() }
}
if #available(macOS 14, *) {
Button("Suspend") {
kill(getpid(), SIGUSR1)
}
}
}
}
}
}
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
MainApp.main()
}
}
@@ -486,8 +584,18 @@ struct VMView: NSViewRepresentable {
func makeNSView(context: Context) -> NSViewType {
let machineView = VZVirtualMachineView()
// so keys like take a windows screenshot (cmd+shift+4+space) works on the host and not guest
// 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
}
@@ -495,3 +603,116 @@ struct VMView: NSViewRepresentable {
nsView.virtualMachine = vm.virtualMachine
}
}
struct DirectoryShare {
let name: String?
let path: URL
let readOnly: Bool
init(parseFrom: String) throws {
let readOnlySuffix = ":ro"
readOnly = parseFrom.hasSuffix(readOnlySuffix)
let maybeNameAndURL = readOnly ? String(parseFrom.dropLast(readOnlySuffix.count)) : parseFrom
if maybeNameAndURL.starts(with: "https://") || maybeNameAndURL.starts(with: "http://") {
// just a URL
name = nil
path = URL(string: maybeNameAndURL)!
return
}
let splits = maybeNameAndURL.split(separator: ":", maxSplits: 1)
if splits.count == 2 {
name = String(splits[0])
path = String(splits[1]).toRemoteOrLocalURL()
} else {
name = nil
path = String(splits[0]).toRemoteOrLocalURL()
}
}
func createConfiguration() throws -> VZSharedDirectory {
if (path.isFileURL) {
return VZSharedDirectory(url: path, readOnly: readOnly)
}
let urlCache = URLCache(memoryCapacity: 0, diskCapacity: 1 * 1024 * 1024 * 1024)
let archiveRequest = URLRequest(url: path, cachePolicy: .returnCacheDataElseLoad)
var response: CachedURLResponse? = urlCache.cachedResponse(for: archiveRequest)
if (response == nil) {
print("Downloading \(path)...")
// download and unarchive remote directories if needed here
// use old school API to prevent deadlocks since we are running via MainActor
let downloadSemaphore = DispatchSemaphore(value: 0)
Task {
do {
let (archiveData, archiveResponse) = try await URLSession.shared.data(for: archiveRequest)
urlCache.storeCachedResponse(CachedURLResponse(response: archiveResponse, data: archiveData, storagePolicy: .allowed), for: archiveRequest)
print("Cached for future invocations!")
} catch {
print("Download failed: \(error)")
}
downloadSemaphore.signal()
}
downloadSemaphore.wait()
response = urlCache.cachedResponse(for: archiveRequest)
} else {
print("Using cached archive for \(path)...")
}
if (response == nil) {
throw ValidationError("Failed to fetch a remote archive!")
}
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".volume")
try FileManager.default.createDirectory(atPath: temporaryLocation.path, withIntermediateDirectories: true)
let lock = try FileLock(lockURL: temporaryLocation)
try lock.lock()
guard let executableURL = resolveBinaryPath("tar") else {
throw ValidationError("tar not found in PATH")
}
let process = Process.init()
process.executableURL = executableURL
process.currentDirectoryURL = temporaryLocation
process.arguments = ["-xz"]
let inPipe = Pipe()
process.standardInput = inPipe
process.launch()
inPipe.fileHandleForWriting.write(response!.data)
try inPipe.fileHandleForWriting.close()
process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
throw ValidationError("Unarchiving failed!")
}
print("Unarchived into a temporary directory!")
return VZSharedDirectory(url: temporaryLocation, readOnly: readOnly)
}
}
extension String {
func toRemoteOrLocalURL() -> URL {
if (starts(with: "https://") || starts(with: "https://")) {
URL(string: self)!
} else {
URL(fileURLWithPath: NSString(string: self).expandingTildeInPath)
}
}
}
func pathHasMode(_ path: String, mode: mode_t) -> Bool {
var st = stat()
let statRes = stat(path, &st)
guard statRes != -1 else {
return false
}
return (Int32(st.st_mode) & Int32(mode)) == Int32(mode)
}
+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)\"")
}
}
}
@@ -11,7 +11,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
return credentialsFromAuth
}
if let helperProgram = config.credHelpers?[host] {
if let helperProgram = try config.findCredHelper(host: host) {
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
}
@@ -59,6 +59,26 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
struct DockerConfig: Codable {
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
var credHelpers: Dictionary<String, String>? = Dictionary()
func findCredHelper(host: String) throws -> String? {
// Tart supports wildcards in credHelpers
// Similar to what is requested from Docker: https://github.com/docker/cli/issues/2928
guard let credHelpers else {
return nil
}
for (hostPattern, helperProgram) in credHelpers {
if (hostPattern == host) {
return helperProgram
}
let compiledPattern = try? Regex(hostPattern)
if (try compiledPattern?.wholeMatch(in: host) != nil) {
return helperProgram
}
}
return nil
}
}
struct DockerAuthConfig: Codable {
@@ -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 {
@@ -61,6 +61,24 @@ class KeychainCredentialsProvider: CredentialsProvider {
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
}
}
func remove(host: String) throws {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: host,
kSecAttrLabel as String: "Tart Credentials",
]
let status = SecItemDelete(query as CFDictionary)
switch status {
case errSecSuccess:
return
case errSecItemNotFound:
return
default:
throw CredentialsProviderError.Failed(message: "Failed to remove Keychain item(s): \(status.explanation())")
}
}
}
extension OSStatus {
@@ -13,7 +13,7 @@ class StdinCredentials {
return (user, password)
}
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String {
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 1024, isSensitive: Bool) throws -> String {
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
throw StdinCredentialsError.CredentialRequired(which: name)
+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
}
}
+3 -3
View File
@@ -35,12 +35,12 @@ class Fetcher {
//
// This keeps a working reference to that file, yet we don't
// have to deal with the cleanup any more.
let fh = try FileHandle(forReadingFrom: fileURL)
let mappedFile = try Data(contentsOf: fileURL, options: [.alwaysMapped])
try FileManager.default.removeItem(at: fileURL)
Task {
while let data = try fh.read(upToCount: 64 * 1024 * 1024) {
await dataCh.send(data)
for chunk in (0 ..< mappedFile.count).chunks(ofCount: 64 * 1024 * 1024) {
await dataCh.send(mappedFile.subdata(in: chunk))
}
dataCh.finish()
+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:
+1 -1
View File
@@ -1,7 +1,7 @@
import Virtualization
protocol Network {
func attachment() -> VZNetworkDeviceAttachment
func attachments() -> [VZNetworkDeviceAttachment]
func run(_ sema: DispatchSemaphore) throws
func stop() async throws
}
+5 -5
View File
@@ -2,14 +2,14 @@ import Foundation
import Virtualization
class NetworkBridged: Network {
let interface: VZBridgedNetworkInterface
let interfaces: [VZBridgedNetworkInterface]
init(interface: VZBridgedNetworkInterface) {
self.interface = interface
init(interfaces: [VZBridgedNetworkInterface]) {
self.interfaces = interfaces
}
func attachment() -> VZNetworkDeviceAttachment {
VZBridgedNetworkDeviceAttachment(interface: interface)
func attachments() -> [VZNetworkDeviceAttachment] {
interfaces.map { VZBridgedNetworkDeviceAttachment(interface: $0) }
}
func run(_ sema: DispatchSemaphore) throws {
+2 -2
View File
@@ -2,8 +2,8 @@ import Foundation
import Virtualization
class NetworkShared: Network {
func attachment() -> VZNetworkDeviceAttachment {
VZNATNetworkDeviceAttachment()
func attachments() -> [VZNetworkDeviceAttachment] {
[VZNATNetworkDeviceAttachment()]
}
func run(_ sema: DispatchSemaphore) throws {
+2 -2
View File
@@ -92,9 +92,9 @@ class Softnet: Network {
}
}
func attachment() -> VZNetworkDeviceAttachment {
func attachments() -> [VZNetworkDeviceAttachment] {
let fh = FileHandle.init(fileDescriptor: vmFD)
return VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
return [VZFileHandleNetworkDeviceAttachment(fileHandle: fh)]
}
static func configureSUIDBitIfNeeded() throws {
+36
View File
@@ -1,6 +1,11 @@
import Foundation
import CryptoKit
enum DigestError: Error {
case InvalidOffset
case InvalidSize
}
class Digest {
var hash: SHA256 = SHA256()
@@ -15,6 +20,37 @@ class Digest {
static func hash(_ data: Data) -> String {
SHA256.hash(data: data).hexdigest()
}
static func hash(_ url: URL) throws -> String {
hash(try Data(contentsOf: url))
}
static func hash(_ url: URL, offset: UInt64, size: UInt64) throws -> String {
// Sanity check
let fhSanity = try FileHandle(forReadingFrom: url)
try fhSanity.seekToEnd()
let fileSize = try fhSanity.offset()
try fhSanity.close()
if offset > fileSize {
throw DigestError.InvalidOffset
}
if (offset + size) > fileSize {
throw DigestError.InvalidSize
}
// Read a chunk of size ``size`` at offset ``offset``
// and calculate it's digest
let fh = try FileHandle(forReadingFrom: url)
defer { try! fh.close() }
try fh.seek(toOffset: offset)
let data = try fh.read(upToCount: Int(size))!
return hash(data)
}
}
extension SHA256.Digest {
+6
View File
@@ -0,0 +1,6 @@
import Foundation
protocol Disk {
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer]
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws
}
+75
View File
@@ -0,0 +1,75 @@
import Foundation
import Compression
class DiskV1: Disk {
private static let bufferSizeBytes = 4 * 1024 * 1024
private static let layerLimitBytes = 500 * 1000 * 1000
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer] {
var pushedLayers: [OCIManifestLayer] = []
// Open the disk file
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
var mappedDiskReadOffset = 0
// Compress the disk file as a single stream
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
// Determine the size of the next chunk
let bytesRead = min(length, mappedDisk.count - mappedDiskReadOffset)
// Read the next uncompressed chunk
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
// Advance the offset
mappedDiskReadOffset += bytesRead
// Provide the uncompressed chunk to the compressing filter
return data
}
// Cut the compressed stream into layers, each equal exactly ``Self.layerLimitBytes`` bytes,
// except for the last one, which may be smaller
while let compressedData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
pushedLayers.append(OCIManifestLayer(
mediaType: diskV1MediaType,
size: compressedData.count,
digest: layerDigest
))
// Update progress using an absolute value
progress.completedUnitCount = Int64(mappedDiskReadOffset)
}
return pushedLayers
}
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws {
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
throw OCIError.FailedToCreateVmFile
}
// Open the disk file
let disk = try FileHandle(forWritingTo: diskURL)
defer { try! disk.close() }
// Decompress the layers onto the disk in a single stream
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
if let data = data {
disk.write(data)
}
}
for diskLayer in diskLayers {
try await registry.pullBlob(diskLayer.digest) { data in
try filter.write(data)
// Update the progress
progress.completedUnitCount += Int64(data.count)
}
}
try filter.finalize()
}
}
+154
View File
@@ -0,0 +1,154 @@
import Foundation
import Compression
class DiskV2: Disk {
private static let bufferSizeBytes = 4 * 1024 * 1024
private static let layerLimitBytes = 500 * 1000 * 1000
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer] {
var pushedLayers: [OCIManifestLayer] = []
// Open the disk file
let disk = try FileHandle(forReadingFrom: diskURL)
// Compress the disk file as multiple individually decompressible streams,
// each equal ``Self.layerLimitBytes`` bytes or slightly larger due to the
// internal compressor's buffer
while let (compressedData, uncompressedSize, uncompressedDigest) = try compressNextLayerOfLimitBytesOrMore(disk: disk) {
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
pushedLayers.append(OCIManifestLayer(
mediaType: diskV2MediaType,
size: compressedData.count,
digest: layerDigest,
uncompressedSize: uncompressedSize,
uncompressedContentDigest: uncompressedDigest
))
// Update progress using a relative value
progress.completedUnitCount += Int64(uncompressedSize)
}
return pushedLayers
}
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws {
// Support resumable pulls
let pullResumed = FileManager.default.fileExists(atPath: diskURL.path)
if !pullResumed && !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
throw OCIError.FailedToCreateVmFile
}
// Calculate the uncompressed disk size
var uncompressedDiskSize: UInt64 = 0
for layer in diskLayers {
guard let uncompressedLayerSize = layer.uncompressedSize() else {
throw OCIError.LayerIsMissingUncompressedSizeAnnotation
}
uncompressedDiskSize += uncompressedLayerSize
}
// Truncate the target disk file so that it will be able
// to accomodate the uncompressed disk size
let disk = try FileHandle(forWritingTo: diskURL)
try disk.truncate(atOffset: uncompressedDiskSize)
try disk.close()
// Concurrently fetch and decompress layers
try await withThrowingTaskGroup(of: Void.self) { group in
var globalDiskWritingOffset: UInt64 = 0
for (index, diskLayer) in diskLayers.enumerated() {
// Respect the concurrency limit
if index >= concurrency {
try await group.next()
}
// Retrieve layer annotations
guard let uncompressedLayerSize = diskLayer.uncompressedSize() else {
throw OCIError.LayerIsMissingUncompressedSizeAnnotation
}
guard let uncompressedLayerContentDigest = diskLayer.uncompressedContentDigest() else {
throw OCIError.LayerIsMissingUncompressedDigestAnnotation
}
// Capture the current disk writing offset
let diskWritingOffset = globalDiskWritingOffset
// Launch a fetching and decompression task
group.addTask {
// No need to fetch and decompress anything if we've already done so
if try pullResumed && Digest.hash(diskURL, offset: diskWritingOffset, size: uncompressedLayerSize) == uncompressedLayerContentDigest {
// Update the progress
progress.completedUnitCount += Int64(diskLayer.size)
return
}
// Open the disk file at the specific offset
let disk = try FileHandle(forWritingTo: diskURL)
try disk.seek(toOffset: diskWritingOffset)
// Pull and decompress a single layer into the specific offset on disk
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
if let data = data {
disk.write(data)
}
}
try await registry.pullBlob(diskLayer.digest) { data in
try filter.write(data)
// Update the progress
progress.completedUnitCount += Int64(data.count)
}
try filter.finalize()
try disk.close()
}
globalDiskWritingOffset += uncompressedLayerSize
}
}
}
private static func compressNextLayerOfLimitBytesOrMore(disk: FileHandle) throws -> (Data, UInt64, String)? {
var compressedData = Data()
var bytesRead: UInt64 = 0
let digest = Digest()
// Create a compressing filter that we will terminate upon
// reaching ``Self.layerLimitBytes`` of compressed data
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: bufferSizeBytes) { (length: Int) -> Data? in
if compressedData.count >= Self.layerLimitBytes {
return nil
}
guard let uncompressedChunk = try disk.read(upToCount: bufferSizeBytes) else {
return nil
}
bytesRead += UInt64(uncompressedChunk.count)
digest.update(uncompressedChunk)
return uncompressedChunk
}
// Retrieve compressed data chunks, but normally no more than ``Self.layerLimitBytes`` bytes
while let compressedChunk = try compressingFilter.readData(ofLength: Self.bufferSizeBytes) {
compressedData.append(compressedChunk)
}
// Nothing was read this time from the disk,
// signal that to the consumer
if bytesRead == 0 {
return nil
}
return (compressedData, bytesRead, digest.finalize())
}
}
+54 -5
View File
@@ -1,10 +1,22 @@
import Foundation
// OCI manifest and OCI config media types
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
// Annotations
// Layer media types
let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
let diskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2"
let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
// Manifest annotations
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
// Layer annotations
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
struct OCIManifest: Codable, Equatable {
var schemaVersion: Int = 2
@@ -13,15 +25,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 {
@@ -64,6 +82,37 @@ struct OCIManifestLayer: Codable, Equatable {
var mediaType: String
var size: Int
var digest: String
var annotations: Dictionary<String, String>?
init(mediaType: String, size: Int, digest: String, uncompressedSize: UInt64? = nil, uncompressedContentDigest: String? = nil) {
self.mediaType = mediaType
self.size = size
self.digest = digest
var annotations: [String: String] = [:]
if let uncompressedSize = uncompressedSize {
annotations[uncompressedSizeAnnotation] = String(uncompressedSize)
}
if let uncompressedContentDigest = uncompressedContentDigest {
annotations[uncompressedContentDigestAnnotation] = uncompressedContentDigest
}
self.annotations = annotations
}
func uncompressedSize() -> UInt64? {
guard let value = annotations?[uncompressedSizeAnnotation] else {
return nil
}
return UInt64(value)
}
func uncompressedContentDigest() -> String? {
annotations?[uncompressedContentDigestAnnotation]
}
}
struct Descriptor: Equatable {
+26 -6
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()]
@@ -225,7 +242,7 @@ class Registry {
return digest
}
public func pullBlob(_ digest: String, handler: (Data) throws -> Void) async throws {
public func pullBlob(_ digest: String, handler: (Data) async throws -> Void) async throws {
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
if response.statusCode != HTTPCode.Ok.rawValue {
let body = try await channel.asData().asText()
@@ -236,7 +253,7 @@ class Registry {
for try await part in channel {
try Task.checkCancellation()
try handler(Data(part))
try await handler(part)
}
}
@@ -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 {
+44 -9
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)
result.auxiliaryStorage = VZMacAuxiliaryStorage(url: 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,35 @@ struct Darwin: Platform {
return result
}
func pointingDevices() -> [VZPointingDeviceConfiguration] {
if #available(macOS 13, *) {
// Trackpad is only supported starting with macOS Ventura
// macOS Monterey will continue using a USB device == .darwin
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
func keyboards() -> [VZKeyboardConfiguration] {
if #available(macOS 14, *) {
// Mac keyboard is only supported by guests starting with macOS Ventura
return [VZMacKeyboardConfiguration(), VZUSBKeyboardConfiguration()]
} else {
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
return [VZUSBKeyboardConfiguration()]
}
}
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
if #available(macOS 14, *) {
return [VZMacKeyboardConfiguration()]
} else {
// fallback to the regular configuration
return keyboards()
}
}
func pointingDevices() -> [VZPointingDeviceConfiguration] {
// Trackpad is only supported by guests starting with macOS Ventura
[VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
if #available(macOS 14, *) {
return [VZMacTrackpadConfiguration()]
} else {
// fallback to the regular configuration
return pointingDevices()
}
}
}
+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]
}
+13 -5
View File
@@ -16,6 +16,7 @@ struct Root: AsyncParsableCommand {
Get.self,
List.self,
Login.self,
Logout.self,
IP.self,
Pull.self,
Push.self,
@@ -57,6 +58,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);
@@ -76,10 +82,12 @@ struct Root: AsyncParsableCommand {
var command = try parseAsRoot()
// Run garbage-collection before each command (shouldn't take too long)
do {
try Config().gc()
} catch {
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
do {
try Config().gc()
} catch {
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
}
}
if var asyncCommand = command as? AsyncParsableCommand {
@@ -94,7 +102,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)
}
}
}
+98 -57
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)
@@ -39,9 +42,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
init(vmDir: VMDirectory,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
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, additionalStorageDevices: additionalStorageDevices,
directorySharingDevices: directorySharingDevices,
serialPorts: serialPorts,
suspendable: suspendable
)
virtualMachine = VZVirtualMachine(configuration: configuration)
@@ -66,9 +71,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
// Check if we already have this IPSW in cache
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
var headRequest = URLRequest(url: remoteURL)
headRequest.httpMethod = "HEAD"
let (_, headResponse) = try await Fetcher.fetch(headRequest, viaFile: false)
if let hash = response.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
if let hash = headResponse.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
if FileManager.default.fileExists(atPath: ipswLocation.path) {
@@ -82,6 +89,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Download the IPSW
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
let progress = Progress(totalUnitCount: response.expectedContentLength)
ProgressObserver(progress).log(defaultLogger)
@@ -133,7 +142,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
ipswURL: URL,
diskSizeGB: UInt16,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
serialPorts: [VZSerialPortConfiguration] = []
) async throws {
@@ -179,11 +188,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,
additionalStorageDevices: additionalStorageDevices,
directorySharingDevices: directorySharingDevices,
serialPorts: serialPorts
)
virtualMachine = VZVirtualMachine(configuration: configuration)
@@ -220,27 +229,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,24 +249,38 @@ 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 {
let startOptions = VZMacOSVirtualMachineStartOptions()
startOptions.startUpFromMacOSRecovery = recovery
try await virtualMachine.start(options: startOptions)
}
@MainActor
private func resume() async throws {
try await virtualMachine.resume()
}
@MainActor
private func stop() async throws {
try await self.virtualMachine.stop()
}
static func craftConfiguration(
diskURL: URL,
nvramURL: URL,
vmConfig: VMConfig,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
additionalStorageDevices: [VZStorageDeviceConfiguration],
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
serialPorts: [VZSerialPortConfiguration]
serialPorts: [VZSerialPortConfiguration],
suspendable: Bool = false
) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration()
@@ -278,37 +292,50 @@ 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()
vio.attachment = network.attachment()
vio.macAddress = vmConfig.macAddress
configuration.networkDevices = [vio]
configuration.networkDevices = network.attachments().map {
let vio = VZVirtioNetworkDeviceConfiguration()
vio.attachment = $0
vio.macAddress = vmConfig.macAddress
return vio
}
// Storage
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
attachments.append(contentsOf: additionalDiskAttachments)
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
var devices: [VZStorageDeviceConfiguration] = [
VZVirtioBlockDeviceConfiguration(attachment: try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false))
]
devices.append(contentsOf: additionalStorageDevices)
configuration.storageDevices = devices
// Entropy
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
if !suspendable {
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
}
// Directory sharing devices
configuration.directorySharingDevices = directorySharingDevices
@@ -316,6 +343,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 !suspendable {
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
+1 -5
View File
@@ -97,11 +97,7 @@ struct VMConfig: Codable {
case .darwin:
platform = try Darwin(from: decoder)
case .linux:
if #available(macOS 13, *) {
platform = try Linux(from: decoder)
} else {
throw UnsupportedOSError("Linux VMs", "are")
}
platform = try Linux(from: decoder)
}
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
cpuCount = try container.decode(Int.self, forKey: .cpuCount)
+37 -63
View File
@@ -1,33 +1,30 @@
import Foundation
import Compression
import Sentry
enum OCIError: Error {
case ShouldBeExactlyOneLayer
case ShouldBeAtLeastOneLayer
case FailedToCreateVmFile
case LayerIsMissingUncompressedSizeAnnotation
case LayerIsMissingUncompressedDigestAnnotation
}
extension VMDirectory {
private static let bufferSizeBytes = 64 * 1024 * 1024
private static let layerLimitBytes = 500 * 1000 * 1000
private static let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
private static let diskMediaType = "application/vnd.cirruslabs.tart.disk.v1"
private static let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
func pullFromRegistry(registry: Registry, reference: String) async throws {
func pullFromRegistry(registry: Registry, reference: String, concurrency: UInt) async throws {
defaultLogger.appendNewLine("pulling manifest...")
let (manifest, _) = try await registry.pullManifest(reference: reference)
return try await pullFromRegistry(registry: registry, manifest: manifest)
return try await pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency)
}
func pullFromRegistry(registry: Registry, manifest: OCIManifest) async throws {
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt) async throws {
// Pull VM's config file layer and re-serialize it into a config file
let configLayers = manifest.layers.filter {
$0.mediaType == Self.configMediaType
$0.mediaType == configMediaType
}
if configLayers.count != 1 {
throw OCIError.ShouldBeExactlyOneLayer
@@ -41,50 +38,36 @@ extension VMDirectory {
}
try configFile.close()
// Pull VM's disk layers and decompress them sequentially into a disk file
let diskLayers = manifest.layers.filter {
$0.mediaType == Self.diskMediaType
}
if diskLayers.isEmpty {
// Pull VM's disk layers and decompress them into a disk file
let diskImplType: Disk.Type
let layers: [OCIManifestLayer]
if manifest.layers.contains(where: { $0.mediaType == diskV1MediaType }) {
diskImplType = DiskV1.self
layers = manifest.layers.filter { $0.mediaType == diskV1MediaType }
} else if manifest.layers.contains(where: { $0.mediaType == diskV2MediaType }) {
diskImplType = DiskV2.self
layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
} else {
throw OCIError.ShouldBeAtLeastOneLayer
}
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
throw OCIError.FailedToCreateVmFile
}
let disk = try FileHandle(forWritingTo: diskURL)
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
if let data = data {
disk.write(data)
}
}
// Progress
let diskCompressedSize: Int64 = Int64(diskLayers.map {
$0.size
}
.reduce(0) {
$0 + $1
})
let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +)
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte)
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
let progress = Progress(totalUnitCount: diskCompressedSize)
ProgressObserver(progress).log(defaultLogger)
for diskLayer in diskLayers {
try await registry.pullBlob(diskLayer.digest) { data in
try filter.write(data)
progress.completedUnitCount += Int64(data.count)
}
}
try filter.finalize()
try disk.close()
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte);
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL, concurrency: concurrency, progress: progress)
// Pull VM's NVRAM file layer and store it in an NVRAM file
defaultLogger.appendNewLine("pulling NVRAM...")
let nvramLayers = manifest.layers.filter {
$0.mediaType == Self.nvramMediaType
$0.mediaType == nvramMediaType
}
if nvramLayers.count != 1 {
throw OCIError.ShouldBeExactlyOneLayer
@@ -99,7 +82,7 @@ extension VMDirectory {
try nvram.close()
}
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int) async throws -> RemoteName {
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String) async throws -> RemoteName {
var layers = Array<OCIManifestLayer>()
// Read VM's config and push it as blob
@@ -107,32 +90,22 @@ extension VMDirectory {
let configJSON = try JSONEncoder().encode(config)
defaultLogger.appendNewLine("pushing config...")
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
layers.append(OCIManifestLayer(mediaType: Self.configMediaType, size: configJSON.count, digest: configDigest))
layers.append(OCIManifestLayer(mediaType: configMediaType, size: configJSON.count, digest: configDigest))
// Progress
// Compress the disk file as multiple chunks and push them as disk layers
let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64
defaultLogger.appendNewLine("pushing disk... this will take a while...")
let progress = Progress(totalUnitCount: diskSize)
ProgressObserver(progress).log(defaultLogger)
// Read VM's compressed disk as chunks
// and sequentially upload them as blobs
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
let mappedDiskSize = mappedDisk.count
var mappedDiskReadOffset = 0
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
let bytesRead = min(length, mappedDiskSize - mappedDiskReadOffset)
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
mappedDiskReadOffset += bytesRead
progress.completedUnitCount = Int64(mappedDiskReadOffset)
return data
}
while let compressedLayerData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
let layerDigest = try await registry.pushBlob(fromData: compressedLayerData, chunkSizeMb: chunkSizeMb)
layers.append(OCIManifestLayer(mediaType: Self.diskMediaType, size: compressedLayerData.count, digest: layerDigest))
switch diskFormat {
case "v1":
layers.append(contentsOf: try await DiskV1.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, progress: progress))
case "v2":
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, progress: progress))
default:
throw RuntimeError.OCIUnsupportedDiskFormat(diskFormat)
}
// Read VM's NVRAM and push it as blob
@@ -140,7 +113,7 @@ extension VMDirectory {
let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()!
let nvramDigest = try await registry.pushBlob(fromData: nvram, chunkSizeMb: chunkSizeMb)
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest))
// Craft a stub OCI config for Docker Hub compatibility
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
@@ -148,7 +121,8 @@ extension VMDirectory {
let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
layers: layers,
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
uncompressedDiskSize: UInt64(diskSize),
uploadDate: Date()
)
// Manifest
@@ -159,7 +133,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)
}
}
+40 -3
View File
@@ -1,5 +1,6 @@
import Foundation
import Virtualization
import CryptoKit
struct VMDirectory: Prunable {
var baseURL: URL
@@ -13,6 +14,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 +31,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 {
@@ -37,6 +60,17 @@ struct VMDirectory: Prunable {
return VMDirectory(baseURL: tmpDir)
}
//Create tmp directory with hashing
static func temporaryDeterministic(key: String) throws -> VMDirectory {
let keyData = Data(key.utf8)
let hash = Insecure.MD5.hash(data: keyData)
// Convert hash to string
let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()
let tmpDir = try Config().tartTmpDir.appendingPathComponent(hashString)
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
return VMDirectory(baseURL: tmpDir)
}
var initialized: Bool {
FileManager.default.fileExists(atPath: configURL.path) &&
FileManager.default.fileExists(atPath: diskURL.path) &&
@@ -55,9 +89,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 +104,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 +120,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)
}
+18 -1
View File
@@ -34,9 +34,15 @@ class VMStorageHelper {
}
}
extension NSError {
func isFileNotFound() -> Bool {
return self.code == NSFileNoSuchFileError || self.code == NSFileReadNoSuchFileError
}
}
extension Error {
func isFileNotFound() -> Bool {
(self as NSError).code == NSFileReadNoSuchFileError
(self as NSError).isFileNotFound() || (self as NSError).underlyingErrors.contains(where: { $0.isFileNotFound() })
}
}
@@ -57,6 +63,9 @@ enum RuntimeError : Error {
case ExportFailed(_ message: String)
case ImportFailed(_ message: String)
case SoftnetFailed(_ message: String)
case OCIStorageError(_ message: String)
case OCIUnsupportedDiskFormat(_ format: String)
case SuspendFailed(_ message: String)
}
protocol HasExitCode {
@@ -98,6 +107,12 @@ 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 .OCIUnsupportedDiskFormat(let format):
return "OCI disk format \(format) is not supported by this version of Tart"
case .SuspendFailed(let message):
return "Failed to suspend the VM: \(message)"
}
}
}
@@ -105,6 +120,8 @@ extension RuntimeError : CustomStringConvertible {
extension RuntimeError : HasExitCode {
var exitCode: Int32 {
switch self {
case .VMDoesNotExist:
return 2
case .VMNotRunning:
return 2
case .VMAlreadyRunning:
+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 }
}
+37 -31
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()
@@ -122,7 +132,11 @@ class VMStorageOCI: PrunableStorage {
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
}
func pull(_ name: RemoteName, registry: Registry) async throws {
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt) 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)
@@ -150,7 +170,7 @@ class VMStorageOCI: PrunableStorage {
if !exists(digestName) {
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
let tmpVMDir = try VMDirectory.temporary()
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
@@ -158,40 +178,17 @@ 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: {
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency)
try move(digestName, from: tmpVMDir)
transaction.finish()
}, onCancel: {
@@ -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)
}
}
+16
View File
@@ -0,0 +1,16 @@
import XCTest
@testable import tart
final class DockerConfigTests: XCTestCase {
func testHelpers() throws {
let config = DockerConfig(credHelpers: [
"(.*).dkr.ecr.(.*).amazonaws.com": "ecr-login",
"gcr.io": "gcloud"
])
XCTAssertEqual(try config.findCredHelper(host: "gcr.io"), "gcloud")
XCTAssertEqual(try config.findCredHelper(host: "123.dkr.ecr.eu-west-1.amazonaws.com"), "ecr-login")
XCTAssertEqual(try config.findCredHelper(host: "456.dkr.ecr.us-east-1.amazonaws.com"), "ecr-login")
XCTAssertNil(try config.findCredHelper(host: "ghcr.io"))
}
}
+90
View File
@@ -0,0 +1,90 @@
import XCTest
@testable import tart
final class LayerizerTests: XCTestCase {
var registryRunner: RegistryRunner?
var registry: Registry {
registryRunner!.registry
}
override func setUp() async throws {
try await super.setUp()
do {
registryRunner = try await RegistryRunner()
} catch {
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
}
}
override func tearDown() async throws {
try await super.tearDown()
registryRunner = nil
}
func testDiskV1() async throws {
// Original disk file to be pushed to the registry
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
addTeardownBlock {
try FileManager.default.removeItem(at: originalDiskFileURL)
}
// Disk file to be pulled from the registry
// and compared against the original disk file
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
print("pushing disk...")
let diskLayers = try await DiskV1.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, progress: Progress())
print("pulling disk...")
try await DiskV1.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
print("comparing disks...")
try XCTAssertEqual(Digest.hash(originalDiskFileURL), Digest.hash(pulledDiskFileURL))
}
func testDiskV2() async throws {
// Original disk file to be pushed to the registry
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
addTeardownBlock {
try FileManager.default.removeItem(at: originalDiskFileURL)
}
// Disk file to be pulled from the registry
// and compared against the original disk file
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
print("pushing disk...")
let diskLayers = try await DiskV2.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, progress: Progress())
print("pulling disk...")
try await DiskV2.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
print("comparing disks...")
try XCTAssertEqual(Digest.hash(originalDiskFileURL), Digest.hash(pulledDiskFileURL))
}
private func fileWithRandomData(sizeBytes: Int) throws -> URL {
let devUrandom = try FileHandle(forReadingFrom: URL(filePath: "/dev/urandom"))
let temporaryFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
FileManager.default.createFile(atPath: temporaryFileURL.path, contents: nil)
let temporaryFile = try FileHandle(forWritingTo: temporaryFileURL)
var remainingBytes = sizeBytes
while remainingBytes > 0 {
let randomData = try devUrandom.read(upToCount: min(64 * 1024 * 1024, remainingBytes))!
remainingBytes -= randomData.count
try temporaryFile.write(contentsOf: randomData)
}
try devUrandom.close()
try temporaryFile.close()
return temporaryFileURL
}
}
+12
View File
@@ -0,0 +1,12 @@
"default": true
"MD002": false # First heading should be a top level heading
"MD007": # Unordered list indentation
indent: 4
"MD009": false # Trailing spaces
"MD013": false # Line length
"MD025": false # Multiple top level headings in the same document
"MD026": false # Trailing punctuation in heading
"MD033": false # Inline HTML
"MD041": false # First line in file should be a top level heading
"MD045": false # OK not to have a description for an image
"MD046": false # Code block style [Expected: fenced; Actual: indented]
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

+9 -8
View File
@@ -1,8 +1,9 @@
edigaryev:
name: Nikolay Edigaryev
description: Creator
avatar: https://github.com/edigaryev.png
fkorotkov:
name: Fedor Korotkov
description: Creator
avatar: https://github.com/fkorotkov.png
authors:
edigaryev:
name: Nikolay Edigaryev
description: Creator
avatar: https://github.com/edigaryev.png
fkorotkov:
name: Fedor Korotkov
description: Creator
avatar: https://github.com/fkorotkov.png
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

@@ -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 sonoma-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 sonoma-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 sonoma-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 sonoma-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)!
+104
View File
@@ -0,0 +1,104 @@
---
draft: false
date: 2023-09-20
search:
exclude: true
authors:
- fkorotkov
categories:
- announcement
---
# Tart 2.0.0 and community updates
Today we'd like to share some news and updates around the Tart ecosystem since the Tart 1.0.0 release back in February.
<!-- more -->
## Community Growth
In the last 7 months Tart community almost tripled and growth is continuing to accelerate. Tart just crossed 25,000 installations,
dozens of companies that we know of are using Tart in their daily workflows. If your company is not in the list please consider
[joining](https://github.com/cirruslabs/tart/blob/main/Resources/Users/HowToAddYourself.md)!
<div class="grid cards" markdown>
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/Uphold.png){ height="65" }
</div>
We are also very pleased by how the community responded to [the license change](2023-02-11-changing-tart-license.md).
We now have a number of companies running Tart at scale under the new license. Revenue from the licensing allowed us to
allocate time to continue improving Tart which brings us to the section below.
## Recent updates and what's changing in Tart 2.0.0
In the last 7 months we've had 12 feature releases that brought a lot of features requested by the community. Here are just
a few of them to highlight:
-[Custom GitLab Runner Executor](/integrations/gitlab-runner/).
-[Cluster Management via Orchard](2023-04-25-orchard-ga.md).
-Numerous compatibility improvements for all kinds of OCI-registries.
-Sonoma Support (see details [below](#macos-sonoma-updates)).
But one of the most requested features/complaints was around pulling huge Tart images from remote OCI-compatible registries.
With an ideal network conditions `tart pull` worked pretty good but in case of any network issues it was required to
restart the pull from scratch. Additionally, some registries are notably slow streaming a single blob but can stream
multiple blobs in parallel. Finally, the initial format of storing Tart VMs was very naive: disk image is compressed
via a single stream which is chunked up into blobs that are serially uploaded to a registry. A single compression stream
means that Tart can also only decompress blobs serially.
Given these three observations above we came up with an improved format of storing Tart VM disk images. In Tart 2.0.0
disk images are chunked up first and compressed independently into blobs, when pushed, each blob has attached annotations
of expected uncompressed size and a checksum. This way when Tart 2.0.0 is pulling an image pushed by Tart 2.0.0 each blob can
be pulled, uncompressed and written at the right offset independently. Having checksums along expected uncompressed blob size
also allowed to support resumable pulls. Upon a failure Tart 2.0.0 will compare checksums of chunks and will continue pulling
only missing blobs.
Overall in our experiments we saw a 10% improvement in compressed size of the images and **4 times faster pulls**.
In order to try the new image format please upgrade Tart and try to pull any of [the Sonoma images](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos-sonoma):
```bash
brew upgrade cirruslabs/cli/tart
tart pull ghcr.io/cirruslabs/macos-sonoma-base:latest
```
## macOS Sonoma Updates
Tart VMs now can be run in a "suspendable" mode which will enable VM snapshotting instead of the standard shutdown.
VMs with an existing snapshot will `run` from the same state as they got snapshotted. Please check demo down below:
<div>
<blockquote class="twitter-tweet" data-theme="dark">
<p lang="en" dir="ltr">
Tart 1.8.0 brings macOS Sonoma updates! 🍏 Now you can suspend and resume your virtual machines for even faster startup times. Check out the demo below 👇 <a href="https://t.co/RoRFT8Nwst">pic.twitter.com/RoRFT8Nwst</a>
</p>&mdash; Cirrus Labs (@cirrus_labs) <a href="https://twitter.com/cirrus_labs/status/1677308360385765382?ref_src=twsrc%5Etfw">July 7, 2023</a>
</blockquote>
<script src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
</div>
There are two caveats to the "suspendable" mode support:
1. Both host and guest should be running macOS Sonoma.
2. Snapshots are locally encrypted and can't be shared between physical hosts. Therefore `tart push` won't push the corresponding snapshotted state of the VM.
Try the "suspendable" mode for yourself by passing `--suspendable` flag to a `tart run` command:
```bash
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
tart run --suspendable sonoma-base
```
## Conclusion
We are very excited about this major release of Tart. Please give it a try and let us know how it went!
Stay tuned for new updates and announcements! There are a few coming up very shortly...
+71
View File
@@ -0,0 +1,71 @@
---
draft: false
date: 2023-10-06
search:
exclude: true
authors:
- fkorotkov
categories:
- announcement
---
# Tart is now available on AWS Marketplace
Announcing [official AMIs for EC2 Mac Instances](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws)
with preconfigured Tart installation that is optimized to work within AWS infrastructure.
EC2 Mac Instances is a gem of engineering powered by AWS Nitro devices. Just imagine there is a physical Mac Mini with
a plugged in Nitro device that can push the physical power button!
![EC2 M2 Pro](/blog/images/ec2-mac2-m2pro.png)
This clever synergy between Apple Hardware and Nitro System allows seamless integration with VPC networking and booting macOS from an EBS volume.
In this blog post we’ll see how a virtualization solution like Tart can compliment and elevate experience with EC2 Mac Instances.
<!-- more -->
Let’s start from the basics, what EC2 Mac Instances allow to do compared to physical Mac Minis seating in offices of
many companies around the world?
First and foremost, EC2 Mac Instances sit inside AWS data centers and can leverage all the goodies of VPC networking
within your company's existing infrastructure. No need to connect your Macs in the office through a VPN and deal
with networking and security.
Additionally, EC2 Mac Instances are booting from EBS volumes which means it is possible to always have reproducible instances
and apply all the best practices of Infrastructure-as-Code. Managing a fleet of physical Macs is a pain and it's very hard
to make them configured in a reproducible and stable way. With booting from identical EBS volumes your team is always sure
about the identical initial state of the fleet.
## Compromises of EC2 Mac Instances
The flexibility of EBS volumes for macOS comes with some compromises that virtualization solutions like Tart can help with.
The initial boot from an EBS volume takes some time and not instant. macOS itself is pretty heavy and a Nitro device needs
to download tens of gigabytes that macOS requires in order to boot. This means that **resetting a EC2 Mac Instance to a clean state
is not instant and usually takes a couple of minutes** when you can’t utilize the precious resources for your workloads.
It is much easier to tailor such EBS volumes with tools like Packer but there is still a **friction to test newly created EBS volumes**
since one needs to start and run a EC2 Mac Instance and it’s not possible to test things locally. Similarly it is even harder
to test beta versions of macOS that require manual interaction with a running instance.
## Solution
Tart can help with all the compromises! Tart virtual machines (VMs) have nearly native performance thanks to utilizing
native `Virtualization.Framework` that was developed along the first Apple Silicon chip. **Tart VMs can be copied/disposed
instantly and booting a fresh Tart VM takes only several seconds**. It is also possible to run two different Tart VMs in parallel
that can have completely different versions of macOS and packages. For example, it is possible to have the latest stable macOS
with the release version of Xcode along with the next version of macOS with the latest beta of Xcode.
Creation of Tart VMs can be automated with [a Packer plugin](https://github.com/cirruslabs/packer-plugin-tart) the same way as
creation of EC2 AMIs with one caveat that **Tart Packer Plugin works locally so you can test the same virtual machine locally
as you would run it in the cloud**.
Lightweight nature of Tart VMs with a focus on an easy-to-integrate Tart CLI compliments any macOS automation and helps to reduce
the feedback cycle and improves reproducibility of macOS environments even further.
## Conclusion
We are excited to bring [official AMIs that include Tart installation optimized to work within AWS](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws).
In the coming weeks when macOS Sonoma will become available on AWS we’ll release another update specifically targeting EC2 Mac Instances.
This update will simplify access to local SSDs of Mac Instances that are slightly faster than EBS volumes. Stay tuned and don’t hesitate
to ask any [questions](https://tart.run/licensing/).
+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.
+2 -2
View File
@@ -13,7 +13,7 @@ task:
name: hello
macos_instance:
# can be a remote or a local virtual machine
image: ghcr.io/cirruslabs/macos-monterey-base:latest
image: ghcr.io/cirruslabs/macos-sonoma-base:latest
hello_script:
- echo "Hello from within a Tart VM!"
- echo "Here is my CPU info:"
@@ -45,7 +45,7 @@ exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tas
task:
name: Build
macos_instance:
image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
image: ghcr.io/cirruslabs/macos-sonoma-xcode:latest
build_script: swift build --product tart
binary_artifacts:
path: .build/debug/tart
+91 -16
View File
@@ -1,32 +1,107 @@
# GitHub Actions
# Cirrus Runners for GitHub Actions
Tart already powers several CI services mentioned above including our own [Cirrus CI](https://cirrus-ci.org/guide/macOS/) which offers unlimited concurrency with per-second billing.
For services that haven't leveraged Tart yet, we offer fully managed runners via a monthly subscription.
*Cirrus Runners* is the fastest way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
Your actions will be executed in clean macOS virtual machines with 4 Apple M2 cores, compared to GitHub's own macOS runners with just 3 cores and only supporting the outdated Apple–Intel architecture.
## Testimonials from customers
Sebastian Jachec, Mobile Engineer at [Daybridge](https://www.daybridge.com/).
> It’s been plain-sailing with the Cirrus Runners — they’ve been great! They’re consistently 60+% faster on workflows that we previously used Github Actions’ macOS runners for.
Max Lapides, Senior Mobile Engineer at [Tonal](https://www.tonal.com/).
Max Lapides, Senior Mobile Engineer at [Tonal](https://www.tonal.com/):
> Previously, we were using the GitHub‑hosted macOS runners and our iOS build took ~30 minutes. Now with Cirrus Runners, the iOS build only takes ~12 minutes. That’s a huge boost to our productivity, and for only $150/month per runner it is much less expensive too.
John A., Software Engineer at [GitKraken](https://www.gitkraken.com/):
> GitHub Actions MacOS-x86 runners have become increasingly unreliable, so we're moving our Mac builds over to arm64 because Cirrus Labs' M1 runners are not only ~3 times faster, they've also been far more stable.
Sebastian Jachec, Mobile Engineer at [Daybridge](https://www.daybridge.com/):
> It’s been plain-sailing with the Cirrus Runners — they’ve been great! They’re consistently 60+% faster on workflows that we previously used Github Actions’ macOS runners for.
## Pricing
Each Cirrus Runner costs $150 a month and there is no limit on the amount of minutes for your actions.
We recommend to purchase several Cirrus Runners depending on your team size, so you can run actions in
parallel. Note that you can change your subscription at any time via [this page](https://billing.stripe.com/p/login/3cs7vNbzo92p7fy3cc)
or by emailing [support@cirruslabs.org](mailto:support@cirruslabs.org).
### Priority Support
Subscriptions of 20 or more Cirrus Runners include access to [Priority Support](../licensing.md#priority-support).
Please contact [sales@cirruslabs.org](mailto:sales@cirruslabs.org) in order to get all the details.
### CPU and Memory resources of Cirrus Runners
By default, a single Cirrus Runner is allocated with 4 M2 cores and 12 GB of unified memory which is enough for most of the workloads.
For workloads that require more resources it is possible to use XL Cirrus Runners which have twice the resources: a full M2 chip with 8 cores
and 24 GB of unified memory. Note that a single XL Cirrus Runner also uses twice the concurrency.
In order to use an XL Cirrus Runner for a job please append `-xl` suffix to your `runs-on` property. More on that down below.
## Installation
Once you configure [Cirrus Runners App](https://github.com/apps/cirrus-runners) for your organization, you'll be redirected
to a checkout page powered by Stripe. During the checkout process you'll be able to configure a subscription for
a desired amount of parallel Cirrus Runners and try it for free for 10 days.
Once configured, please follow instruction below. If you have any questions please contact [support@cirruslabs.org](mailto:support@cirruslabs.org).
Subscriptions with more than 10 runners also include Priority Support
## Configuring Cirrus Runners
Configuring Cirrus Runners for GitHub Actions is as simple as installing [Cirrus Runners App](https://github.com/apps/cirrus-runners).
After successful installation and subscription configuration, use any of [Ventura images managed by us](https://github.com/cirruslabs/macos-image-templates) in `runs-on`:
In order for Cirrus Runners to be used by your GitHub Actions workflow jobs, specify a desired image in the `runs-on` property.
```yaml
name: Test Suite
jobs:
test:
runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest
```
=== "Default Cirrus Runner"
```yaml
name: Tests
jobs:
test:
runs-on: ghcr.io/cirruslabs/macos-sonoma-xcode:latest
```
=== "XL Cirrus Runner"
```yaml
name: Integration Tests
jobs:
test:
runs-on: ghcr.io/cirruslabs/macos-sonoma-xcode:latest-xl
```
List of all available images can be found in [this repository](https://github.com/cirruslabs/macos-image-templates).
Note that Tart VM images don't have the same set of pre-installed packages as the official Intel GitHub runners.
If something is missing please [create an issue within this repository](https://github.com/cirruslabs/macos-image-templates/issues/new).
When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`.
Note that Cirrus Runners will get added to the default runner group.
!!! tip "Using Cirrus Runners with public repositories"
By default, only private repositories can access runners in a default runner group, but you can override this in your organization's settings:
```https://github.com/organizations/<YOUR ORGANIZATION NAME>/settings/actions/runner-groups/1```
![](/assets/images/TartGHARunners.png)
## Data handling flow
By design Cirrus Runners service never sees any of your secrets or source code and acts as compute platform with the lastest
Apple Silicon hardware that can quickly allocate CPU/Memory resources for your jobs.
Here is a high-level overview of how Cirrus Runners service manages runners for your organization:
- Cirrus Runner GitHub App is subscribed to [`workflow_job`](https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_job).
- Upon receiving a new event targeting Cirrus Runners via `runs-on` property the following steps take place:
- Non-personal information about your job is saved to perform health checking of Cirrus Runners execution.
- Cirrus Runners GitHub App has only one permission that allows generating temporary registration tokens for
self-hosted GitHub Actions Runners. Note that Cirrus Runners GitHub App itself doesn't have access to contents of
repositories in your organization.
- Cirrus Runners Service creates a new single use Tart VM, generates a temporary registration tokens for self-hosted runners
and passes it without storing inside the VM for the GitHub Actions Runner service to [start a ephemeral runner](https://github.blog/changelog/2021-09-20-github-actions-ephemeral-self-hosted-runners-new-webhooks-for-auto-scaling/).
- Cirrus Runners service continuously monitors health of the Tart VM executing your job to make sure it runs to completion.
- After the job finishes the ephemeral Tart VM is getting destroyed with all the information of the job run.
If you have any questions or concerns please feel free to reach out to [support@cirruslabs.org](mailto:support@cirruslabs.org).
+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-sonoma-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).
+7 -5
View File
@@ -11,8 +11,8 @@ Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` fi
use `latest` instead of a path to `*.ipsw` to download the latest available version:
```bash
tart create --from-ipsw=latest monterey-vanilla
tart run monterey-vanilla
tart create --from-ipsw=latest sonoma-vanilla
tart run sonoma-vanilla
```
After the initial booting of the VM you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
@@ -54,7 +54,7 @@ Please refer to `tart set --help` for additional details.
## Building with Packer
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
Here is an example of a template to build `monterey-base` local image based of a remote image:
Here is an example of a template to build a local image based of a remote image:
```hcl
packer {
@@ -67,8 +67,8 @@ packer {
}
source "tart-cli" "tart" {
vm_base_name = "ghcr.io/cirruslabs/macos-ventura-base:latest"
vm_name = "my-custom-ventura"
vm_base_name = "ghcr.io/cirruslabs/macos-sonoma-base:latest"
vm_name = "my-custom-sonoma"
cpu_count = 4
memory_gb = 8
disk_size_gb = 70
@@ -92,7 +92,9 @@ Here is a [repository with Packer templates](https://github.com/cirruslabs/macos
## Working with a Remote OCI Container Registry
<!-- markdownlint-disable MD034 -->
For example, let's say you want to push/pull images to a registry hosted at https://acme.io/.
<!-- markdownlint-enable MD034 -->
### Registry Authorization
+56 -17
View File
@@ -3,37 +3,76 @@ 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
of the host CPU. Instead, Tart VMs automatically alternate between these types of cores depending on the workload
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.
??? note "Host CPU Core usage"
The virtual CPU cores of Tart VMs are not tied to specific physical cores of the host CPU. Instead, for optimal performance
Tart VMs will automatically try to balance compute between all available cores of the host CPU. As a result,
all performance and energy-efficient cores of the host CPU are always counted towards the license usage.
# 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.
## Free Tier
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).
By default, when no [license is purchased](#get-the-license), it is assumed that an organization is using a Free Tier license.
You can find the Free Tier license text in [Tart](https://github.com/cirruslabs/tart/blob/main/LICENSE) and [Orchard](https://github.com/cirruslabs/orchard/blob/main/LICENSE) repositories.
Free Tier license has a 100 CPU core limit for Tart and 4 Orchard Workers limit for Orchard.
??? info "Usage Scenarios Examples"
Here are a few examples that fit into the free tier:
- Using Tart on 12 Mac Minis with 8 CPUs each running up to 24 VMs in parallel.
- Creating an Orchard cluster of 4 Mac Studio workers with 24 CPUs each.
Here are a few examples that do not fit into the free tier:
- Using Tart on 13 Mac Minis with 8 CPUs each.
- Creating an Orchard cluster of 5 Mac Minis workers with 8 CPUs each.
## Gold Tier
If an organization wishes to exceed the limits of the Free Tier license, a purchase of the [Gold Tier License](#get-the-license) is required, which costs \$1000 per month.
Gold Tier license has a 500 CPU core limit for Tart and 20 Orchard Workers limit for Orchard.
## Platinum Tier
If an organization wishes to exceed the limits of the Gold Tier license, a purchase of the [Platinum Tier License](#get-the-license) is required, which costs \$5000 per month.
Platinum Tier license has a 5,000 CPU core limit for Tart and 200 Orchard Workers limit for Orchard.
## Diamond Tier
For organizations that wish to exceed the limits of the Platinum Tier license, a purchase of a [custom Diamond Tier License](#get-the-license) is required, which costs \$1 per CPU core per month and gives the ability to run unlimited Orchard Workers.
# Get the license
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).
!!! info "Running on AWS?"
There are [official AMIs for EC2 Mac Instances](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws)
with preconfigured Tart installation that is optimized to work within AWS infrastructure.
# 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 +95,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.
+38 -5
View File
@@ -3,14 +3,27 @@ hide:
- navigation
---
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
```bash
brew install cirruslabs/cli/tart
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
tart run ventura-base
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
tart run sonoma-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-sonoma-base:latest sonoma-base
./tart.app/Contents/MacOS/tart run sonoma-base
```
Please note that `./tart.app/Contents/MacOS/tart` binary is required to be used in order to trick macOS
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,9 +33,19 @@ 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 sonoma-base)
```
!!! tip "Running scripts inside Tart virtual machines"
We recommend using [Cirrus CLI](integrations/cirrus-cli.md) to run scripts and/or retrieve artifacts
from within Tart virtual machines. Alternatively, you can use plain ssh connection and `tart ip` command:
```bash
brew install sshpass
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sonoma-base) "uname -a"
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip sonoma-base) < script.sh
```
## Mounting directories
To mount a directory, run the VM with the `--dir` argument:
@@ -57,6 +80,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:
@@ -66,4 +100,3 @@ mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
```
The directory we've mounted above will be accessible from the `/mnt/shared/project` path inside a guest VM.
+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;
}
+40 -9
View File
@@ -1,5 +1,11 @@
{% extends "base.html" %}
{% block announce %}
<a href="/blog/2023/10/06/tart-is-now-available-on-aws-marketplace/">
☁️☁️☁️&nbsp&nbspTart is now available on <strong>AWS Marketplace</strong>&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.2/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>
@@ -214,12 +242,15 @@
}
let counterElement = document.getElementById('installation-counter');
if (counterElement) {
counterElement.textContent = Math.round(allDownloads / 1000) + ",000"
// Live installation count is available starting version 1.0.0
// Prior Tart was installed a little over 14,000 times, let's count them too
let installationPriorV1 = 14
counterElement.textContent = (installationPriorV1 + Math.round(allDownloads / 1000)) + ",000"
}
})
</script>
<h2>
With more than <strong id="installation-counter">10,000</strong> installations to date, Tart has been adopted for various scenarios.
With more than <strong id="installation-counter">25,000</strong> installations to date, Tart has been adopted for various scenarios.
Its applications range from powering CI/CD pipelines and reproducible local development environments,
to helping in the testing of device management systems without actual physical devices.
</h2>
@@ -246,7 +277,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 +301,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 +325,7 @@
</figure>
<figure class="mdx-users__testimonial">
<img
src="assets/images/users/max-lapides.jpeg"
src="assets/images/users/max-lapides.webp"
alt="Max Lapides"
loading="lazy"
width="200"
+1
View File
@@ -3,3 +3,4 @@ testcontainers
requests
bitmath
pytest-dependency
paramiko
+9 -4
View File
@@ -7,10 +7,9 @@ class Tart:
def __init__(self):
self.tmp_dir = tempfile.TemporaryDirectory(dir=os.environ.get("CIRRUS_WORKING_DIR"))
# Link to the users IPSW cache to make things faster
src = os.path.join(os.path.expanduser("~"), ".tart", "cache", "IPSWs")
dst = os.path.join(self.tmp_dir.name, "cache", "IPSWs")
os.makedirs(os.path.join(self.tmp_dir.name, "cache"))
# Link to the users cache to make things faster
src = os.path.join(os.path.expanduser("~"), ".tart", "cache")
dst = os.path.join(self.tmp_dir.name, "cache")
os.symlink(src, dst)
def __enter__(self):
@@ -31,3 +30,9 @@ class Tart:
completed_process.check_returncode()
return completed_process.stdout.decode("utf-8"), completed_process.stderr.decode("utf-8")
def run_async(self, args) -> subprocess.Popen:
env = os.environ.copy()
env.update({"TART_HOME": self.tmp_dir.name})
return subprocess.Popen(["tart"] + args, env=env)
+1 -1
View File
@@ -6,5 +6,5 @@ def test_clone(tart):
tart.run(["clone", "debian", "ubuntu"])
# Ensure that we have now 2 VMs
stdout, _, = tart.run(["list", "--quiet"])
stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "debian\nubuntu\n"
+2 -2
View File
@@ -3,7 +3,7 @@ def test_create_macos(tart):
tart.run(["create", "--from-ipsw", "latest", "macos-vm"])
# Ensure that the VM was created
stdout, _ = tart.run(["list", "--quiet"])
stdout, _ = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "macos-vm\n"
@@ -12,5 +12,5 @@ def test_create_linux(tart):
tart.run(["create", "--linux", "linux-vm"])
# Ensure that the VM was created
stdout, _ = tart.run(["list", "--quiet"])
stdout, _ = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "linux-vm\n"
+2 -2
View File
@@ -3,12 +3,12 @@ def test_delete(tart):
tart.run(["create", "--linux", "debian"])
# Ensure that the VM exists
stdout, _, = tart.run(["list", "--quiet"])
stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "debian\n"
# Delete the VM
tart.run(["delete", "debian"])
# Ensure that the VM was removed
stdout, _, = tart.run(["list", "--quiet"])
stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == ""
+1 -1
View File
@@ -6,5 +6,5 @@ def test_rename(tart):
tart.run(["rename", "debian", "ubuntu"])
# Ensure that the VM is now named "ubuntu"
stdout, _, = tart.run(["list", "--quiet"])
stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "ubuntu\n"
+30
View File
@@ -0,0 +1,30 @@
import uuid
from paramiko.client import SSHClient, AutoAddPolicy
def test_run(tart):
vm_name = f"integration-test-run-{uuid.uuid4()}"
# Instantiate a VM with admin:admin SSH access
tart.run(["clone", "ghcr.io/cirruslabs/macos-sonoma-base:latest", vm_name])
# Run the VM asynchronously
tart_run_process = tart.run_async(["run", vm_name])
# Obtain the VM's IP
stdout, _ = tart.run(["ip", vm_name, "--wait", "120"])
ip = stdout.strip()
# Connect to the VM over SSH and shutdown it
client = SSHClient()
client.set_missing_host_key_policy(AutoAddPolicy)
client.connect(ip, username="admin", password="admin")
client.exec_command("sudo shutdown -h now")
# Wait for the "tart run" to finish successfully
tart_run_process.wait()
assert tart_run_process.returncode == 0
# Delete the VM
_, _ = tart.run(["delete", vm_name])
+6 -1
View File
@@ -42,6 +42,7 @@ extra_css:
plugins:
- blog
- privacy
- rss:
match_path: blog/posts/.*
date_from_meta:
@@ -81,15 +82,19 @@ markdown_extensions:
- pymdownx.tasklist:
custom_checkbox: true
- pymdownx.tilde
- attr_list
- md_in_html
nav:
- "Home": index.md
- "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
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/sh
# helper script to build and run a signed tart binary
# usage: ./scripts/run-signed.sh run ventura-base
# usage: ./scripts/run-signed.sh run sonoma-base
set -e