Compare commits

..
39 Commits
Author SHA1 Message Date
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
52 changed files with 867 additions and 228 deletions
+11 -3
View File
@@ -1,11 +1,12 @@
use_compute_credits: true use_compute_credits: true
env: env:
XCODE_TAG: 15-beta-2 XCODE_TAG: 15-beta-5
task: task:
name: Test on Ventura name: Test on Ventura
alias: test alias: test
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
persistent_worker: persistent_worker:
labels: labels:
name: dev-mini name: dev-mini
@@ -30,6 +31,7 @@ task:
task: task:
name: Lint name: Lint
alias: lint alias: lint
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
lint_script: lint_script:
@@ -40,9 +42,10 @@ task:
format: swiftformat format: swiftformat
task: task:
only_if: $CIRRUS_TAG == ''
name: Build name: Build
alias: build alias: build
only_if: $CIRRUS_TAG == '' use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
build_script: swift build --product tart build_script: swift build --product tart
@@ -51,11 +54,12 @@ task:
path: .build/debug/tart path: .build/debug/tart
task: task:
name: Release (Dry Run)
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin') only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
name: Release (Dry Run)
depends_on: depends_on:
- lint - lint
- build - build
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
env: env:
@@ -91,6 +95,7 @@ task:
- lint - lint
- test - test
- build - build
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
env: env:
@@ -123,9 +128,11 @@ task:
# Generate and upload symbols # Generate and upload symbols
- dsymutil tart - dsymutil tart
- sentry-cli debug-files upload tart.dSYM/ - sentry-cli debug-files upload tart.dSYM/
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.dSYM/
# Bundle and upload sources # Bundle and upload sources
- sentry-cli debug-files bundle-sources tart.dSYM - sentry-cli debug-files bundle-sources tart.dSYM
- sentry-cli debug-files upload tart.src.zip - sentry-cli debug-files upload tart.src.zip
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.src.zip
create_sentry_release_script: create_sentry_release_script:
- export SENTRY_RELEASE="tart@$CIRRUS_TAG" - export SENTRY_RELEASE="tart@$CIRRUS_TAG"
- sentry-cli releases new $SENTRY_RELEASE - sentry-cli releases new $SENTRY_RELEASE
@@ -135,6 +142,7 @@ task:
task: task:
name: Deploy Documentation name: Deploy Documentation
only_if: $CIRRUS_BRANCH == 'main' only_if: $CIRRUS_BRANCH == 'main'
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
container: container:
image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!] registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
+5 -7
View File
@@ -4,6 +4,9 @@ before:
hooks: hooks:
- .ci/set-version.sh - .ci/set-version.sh
- swift build -c release --product tart - 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: builds:
- builder: prebuilt - builder: prebuilt
@@ -11,11 +14,9 @@ builds:
- darwin - darwin
goarch: goarch:
- arm64 - arm64
binary: tart.app/Contents/MacOS/tart
prebuilt: prebuilt:
path: .build/arm64-apple-macosx/release/tart path: tart.app/Contents/MacOS/tart
hooks:
post:
- gon gon.hcl
archives: archives:
- name_template: "{{ .ProjectName }}" - name_template: "{{ .ProjectName }}"
@@ -23,9 +24,6 @@ archives:
- src: Resources/embedded.provisionprofile - src: Resources/embedded.provisionprofile
dst: tart.app/Contents dst: tart.app/Contents
strip_parent: true strip_parent: true
- src: ".build/arm64-apple-macosx/release/tart"
dst: tart.app/Contents/MacOS
strip_parent: true
- LICENSE - LICENSE
release: release:
+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
+2 -2
View File
@@ -23,8 +23,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa", "location" : "https://github.com/getsentry/sentry-cocoa",
"state" : { "state" : {
"revision" : "ac224c437a3070ffe34460137ac8761eddaf2852", "revision" : "d277532e1c8af813981ba01f591b15bbdd735615",
"version" : "8.3.3" "version" : "8.8.0"
} }
}, },
{ {
+1 -1
View File
@@ -18,7 +18,7 @@ let package = Package(
.package(url: "https://github.com/antlr/antlr4", branch: "dev"), .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/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/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/cfilipov/TextTable", branch: "master"),
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.0.0"), .package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.0.0"),
], ],
+11 -2
View File
@@ -6,9 +6,18 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722). * 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. * Push/Pull virtual machines from any OCI-compatible container registry.
* Use Tart Packer Plugin to automate VM creation. * 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"> <p align="center">
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank> <a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+21 -2
View File
@@ -3,7 +3,20 @@ import Foundation
import SystemConfiguration import SystemConfiguration
struct Clone: AsyncParsableCommand { 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") @Argument(help: "source VM name")
var sourceName: String var sourceName: String
@@ -31,7 +44,6 @@ struct Clone: AsyncParsableCommand {
} }
let sourceVM = try VMStorageHelper.open(sourceName) let sourceVM = try VMStorageHelper.open(sourceName)
let tmpVMDir = try VMDirectory.temporary() let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection // Lock the temporary VM directory to prevent it's garbage collection
@@ -44,10 +56,17 @@ struct Clone: AsyncParsableCommand {
try lock.lock() try lock.lock()
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress()) let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
&& sourceVM.state() != "suspended"
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC) try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
try localStorage.move(newName, from: tmpVMDir) try localStorage.move(newName, from: tmpVMDir)
try lock.unlock() 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: { }, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL) try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
}) })
+2 -1
View File
@@ -7,6 +7,7 @@ fileprivate struct VMInfo: Encodable {
let Disk: Int let Disk: Int
let Display: String let Display: String
let Running: Bool let Running: Bool
let State: String
} }
struct Get: AsyncParsableCommand { struct Get: AsyncParsableCommand {
@@ -25,7 +26,7 @@ struct Get: AsyncParsableCommand {
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024 let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb, 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)) print(format.renderSingle(info))
} }
} }
+3 -2
View File
@@ -7,6 +7,7 @@ fileprivate struct VMInfo: Encodable {
let Name: String let Name: String
let Size: Int let Size: Int
let Running: Bool let Running: Bool
let State: String
} }
struct List: AsyncParsableCommand { struct List: AsyncParsableCommand {
@@ -36,13 +37,13 @@ struct List: AsyncParsableCommand {
if source == nil || source == "local" { if source == nil || source == "local" {
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in 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" { if source == nil || source == "oci" {
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in 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())
}) })
} }
+11 -6
View File
@@ -17,6 +17,9 @@ struct Login: AsyncParsableCommand {
@Flag(help: "connect to the OCI registry via insecure HTTP protocol") @Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false var insecure: Bool = false
@Flag(help: "skip validation of the registry's credentials before logging-in")
var noValidate: Bool = false
func validate() throws { func validate() throws {
let usernameProvided = username != nil let usernameProvided = username != nil
let passwordProvided = passwordStdin let passwordProvided = passwordStdin
@@ -45,12 +48,14 @@ struct Login: AsyncParsableCommand {
host: (user, password) host: (user, password)
]) ])
do { if !noValidate {
let registry = try Registry(host: host, namespace: "", insecure: insecure, do {
credentialsProviders: [credentialsProvider]) let registry = try Registry(host: host, namespace: "", insecure: insecure,
try await registry.ping() credentialsProviders: [credentialsProvider])
} catch { try await registry.ping()
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)") } catch {
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
}
} }
try KeychainCredentialsProvider().store(host: host, user: user, password: password) 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)
}
}
+99 -20
View File
@@ -5,23 +5,40 @@ import SwiftUI
import SwiftDate import SwiftDate
struct Prune: AsyncParsableCommand { 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.", discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
valueName: "n")) valueName: "n"))
var olderThan: UInt? 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", @Option(help: .hidden)
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
valueName: "n"))
var cacheBudget: UInt? 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) @Flag(help: .hidden)
var gc: Bool = false var gc: Bool = false
func validate() throws { mutating func validate() throws {
if olderThan == nil && cacheBudget == nil && !gc { // --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") throw ValidationError("at least one pruning criteria must be specified")
} }
} }
@@ -31,43 +48,53 @@ struct Prune: AsyncParsableCommand {
try VMStorageOCI().gc() 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 // Clean up cache entries based on last accessed date
if let olderThan = olderThan { if let olderThan = olderThan {
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval 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 // Clean up cache entries based on imposed cache size limit and entry's last accessed date
if let cacheBudget = cacheBudget { if let spaceBudget = spaceBudget {
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024) try Prune.pruneSpaceBudget(prunableStorages: prunableStorages, spaceBudgetBytes: UInt64(spaceBudget) * 1024 * 1024 * 1024)
} }
} }
static func pruneOlderThan(olderThanDate: Date) throws { static func pruneOlderThan(prunableStorages: [PrunableStorage], olderThanDate: Date) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() } let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() } try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
} }
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws { static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() } .flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() } .sorted { try $0.accessDate() > $1.accessDate() }
var cacheBudgetBytes = cacheBudgetBytes var spaceBudgetBytes = spaceBudgetBytes
var prunablesToDelete: [Prunable] = [] var prunablesToDelete: [Prunable] = []
for prunable in prunables { for prunable in prunables {
let prunableSizeBytes = UInt64(try prunable.sizeBytes()) let prunableSizeBytes = UInt64(try prunable.sizeBytes())
if prunableSizeBytes <= cacheBudgetBytes { if prunableSizeBytes <= spaceBudgetBytes {
// Don't mark for deletion as // Don't mark for deletion as
// there's a budget available // there's a budget available
cacheBudgetBytes -= prunableSizeBytes spaceBudgetBytes -= prunableSizeBytes
} else { } else {
// Mark for deletion // Mark for deletion
prunablesToDelete.append(prunable) prunablesToDelete.append(prunable)
@@ -77,7 +104,54 @@ struct Prune: AsyncParsableCommand {
try prunablesToDelete.forEach { try $0.delete() } 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 prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() } .flatMap { try $0.prunables() }
@@ -98,7 +172,12 @@ struct Prune: AsyncParsableCommand {
break break
} }
try SentrySDK.span?.setExtra(value: prunable.sizeBytes(), key: prunable.url.path) if prunable.url == initiator?.url.resolvingSymlinksInPath() {
// do not prune the initiator
continue
}
try SentrySDK.span?.setData(value: prunable.sizeBytes(), key: prunable.url.path)
cacheReclaimedBytes += try prunable.sizeBytes() cacheReclaimedBytes += try prunable.sizeBytes()
+10 -1
View File
@@ -3,7 +3,16 @@ import Dispatch
import SwiftUI import SwiftUI
struct Pull: AsyncParsableCommand { 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") @Argument(help: "remote VM name")
var remoteName: String var remoteName: String
+1 -1
View File
@@ -100,7 +100,7 @@ struct Push: AsyncParsableCommand {
_ = try await registry.pushManifest(reference: reference, manifest: remoteManifest) _ = try await registry.pushManifest(reference: reference, manifest: remoteManifest)
} }
return RemoteName(host: registry.baseURL.host!, namespace: registry.namespace, return RemoteName(host: registry.host!, namespace: registry.namespace,
reference: Reference(digest: digest)) reference: Reference(digest: digest))
} }
} }
+168 -58
View File
@@ -73,13 +73,17 @@ struct Run: AsyncParsableCommand {
var rosettaTag: String? var rosettaTag: String?
@Option(help: ArgumentHelp(""" @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: """ """, discussion: """
Requires host to be macOS 13.0 (Ventura) or newer. 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". 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. 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] = [] var dir: [String] = []
@Option(help: ArgumentHelp(""" @Option(help: ArgumentHelp("""
@@ -87,28 +91,54 @@ struct Run: AsyncParsableCommand {
""", discussion: """ """, discussion: """
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces. Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
""", valueName: "interface name")) """, valueName: "interface name"))
var netBridged: String? var netBridged: [String] = []
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking", @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")) discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
var netSoftnet: Bool = false 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 { if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive") 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") throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
} }
if graphics && noGraphics { if graphics && noGraphics {
throw ValidationError("--graphics and --no-graphics are mutually exclusive") throw ValidationError("--graphics and --no-graphics are mutually exclusive")
} }
let localStorage = VMStorageLocal()
let vmDir = try localStorage.open(name)
if try vmDir.state() == "suspended" {
suspendable = true
}
} }
@MainActor @MainActor
func run() async throws { 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() { if netSoftnet && isInteractiveSession() {
try Softnet.configureSUIDBitIfNeeded() try Softnet.configureSUIDBitIfNeeded()
@@ -153,7 +183,8 @@ struct Run: AsyncParsableCommand {
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(), network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
additionalDiskAttachments: additionalDiskAttachments, additionalDiskAttachments: additionalDiskAttachments,
directorySharingDevices: directoryShares() + rosettaDirectoryShare(), directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
serialPorts: serialPorts serialPorts: serialPorts,
suspendable: suspendable
) )
let vncImpl: VNC? = try { let vncImpl: VNC? = try {
@@ -184,8 +215,25 @@ struct Run: AsyncParsableCommand {
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!") throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
} }
// now VM state will return "running" so we can unlock
try storageLock.unlock()
let task = Task { let task = Task {
do { 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 { if let vncImpl = vncImpl {
let vncURL = try await vncImpl.waitForURL() 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 { if let vncImpl = vncImpl {
try vncImpl.stop() try vncImpl.stop()
@@ -215,17 +263,50 @@ struct Run: AsyncParsableCommand {
} }
} }
// "tart stop" support
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT) let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSrc.setEventHandler { sigintSrc.setEventHandler {
task.cancel() task.cancel()
} }
sigintSrc.activate() 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 let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
if noGraphics || useVNCWithoutGraphics { if noGraphics || useVNCWithoutGraphics {
dispatchMain() dispatchMain()
} else { } else {
runUI() runUI(suspendable)
} }
} }
@@ -250,23 +331,19 @@ struct Run: AsyncParsableCommand {
return try Softnet(vmMACAddress: config.macAddress.string) return try Softnet(vmMACAddress: config.macAddress.string)
} }
if let netBridged = netBridged { if netBridged.count > 0 {
let matchingInterfaces = VZBridgedNetworkInterface.networkInterfaces.filter { interface in func findBridgedInterface(_ name: String) throws -> VZBridgedNetworkInterface {
interface.identifier == netBridged || interface.localizedDisplayName == netBridged 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 { return NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
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 nil return nil
@@ -315,46 +392,31 @@ struct Run: AsyncParsableCommand {
throw UnsupportedOSError("directory sharing", "is") throw UnsupportedOSError("directory sharing", "is")
} }
struct DirectoryShare {
let name: String
let path: URL
let readOnly: Bool
}
var directoryShares: [DirectoryShare] = [] var directoryShares: [DirectoryShare] = []
var allNamedShares = true
for rawDir in dir { for rawDir in dir {
let splits = rawDir.split(maxSplits: 2) { $0 == ":" } let directoryShare = try DirectoryShare(parseFrom: rawDir)
if (directoryShare.name == nil) {
if splits.count < 2 { allNamedShares = false
throw ValidationError("invalid --dir syntax: should at least include name and path, colon-separated")
} }
directoryShares.append(directoryShare)
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)
)
} }
var directories: [String : VZSharedDirectory] = Dictionary()
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag) let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
sharingDevice.share = VZMultipleDirectoryShare(directories: directories) if allNamedShares {
var directories: [String : VZSharedDirectory] = Dictionary()
directoryShares.forEach { directories[$0.name!] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
} else if dir.count > 1 {
throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named")
} else if dir.count == 1 {
let directoryShare = directoryShares.first!
let singleDirectoryShare = VZSingleDirectoryShare(directory: VZSharedDirectory(url: directoryShare.path, readOnly: directoryShare.readOnly))
sharingDevice.share = singleDirectoryShare
}
return [sharingDevice] return [sharingDevice]
} }
@@ -384,7 +446,7 @@ struct Run: AsyncParsableCommand {
return [device] return [device]
} }
private func runUI() { private func runUI(_ suspendable: Bool) {
let nsApp = NSApplication.shared let nsApp = NSApplication.shared
nsApp.setActivationPolicy(.regular) nsApp.setActivationPolicy(.regular)
nsApp.activate(ignoringOtherApps: true) nsApp.activate(ignoringOtherApps: true)
@@ -392,6 +454,8 @@ struct Run: AsyncParsableCommand {
nsApp.applicationIconImage = NSImage(data: AppIconData) nsApp.applicationIconImage = NSImage(data: AppIconData)
struct MainApp: App { struct MainApp: App {
static var disappearSignal: Int32 = SIGINT
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate @NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
var body: some Scene { var body: some Scene {
@@ -400,7 +464,7 @@ struct Run: AsyncParsableCommand {
VMView(vm: vm!).onAppear { VMView(vm: vm!).onAppear {
NSWindow.allowsAutomaticWindowTabbing = false NSWindow.allowsAutomaticWindowTabbing = false
}.onDisappear { }.onDisappear {
let ret = kill(getpid(), SIGINT) let ret = kill(getpid(), MainApp.disappearSignal)
if ret != 0 { if ret != 0 {
// Fallback to the old termination method that doesn't // Fallback to the old termination method that doesn't
// propagate the cancellation to Task's in case graceful // propagate the cancellation to Task's in case graceful
@@ -436,11 +500,17 @@ struct Run: AsyncParsableCommand {
Button("Request Stop") { Button("Request Stop") {
Task { try vm!.virtualMachine.requestStop() } Task { try vm!.virtualMachine.requestStop() }
} }
if #available(macOS 14, *) {
Button("Suspend") {
kill(getpid(), SIGUSR1)
}
}
} }
} }
} }
} }
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
MainApp.main() MainApp.main()
} }
} }
@@ -512,3 +582,43 @@ struct VMView: NSViewRepresentable {
nsView.virtualMachine = vm.virtualMachine nsView.virtualMachine = vm.virtualMachine
} }
} }
struct DirectoryShare {
let name: String?
let path: URL
let readOnly: Bool
init(parseFrom: String) throws {
let splits = parseFrom.split(maxSplits: 2) { $0 == ":" }
if splits.count == 3 {
if splits[2] == "ro" {
readOnly = true
} else {
throw ValidationError("invalid --dir syntax: optional read-only specifier can only be \"ro\"")
}
name = String(splits[0])
path = String(splits[1]).toFilePathURL()
} else if splits.count == 2 {
if splits[1] == "ro" {
name = nil
path = String(splits[0]).toFilePathURL()
readOnly = true
} else {
name = String(splits[0])
path = String(splits[1]).toFilePathURL()
readOnly = false
}
} else {
name = nil
path = String(splits[0]).toFilePathURL()
readOnly = false
}
}
}
extension String {
func toFilePathURL() -> URL {
URL(fileURLWithPath: NSString(string: self).expandingTildeInPath)
}
}
+15
View File
@@ -14,6 +14,21 @@ struct Stop: AsyncParsableCommand {
func run() async throws { func run() async throws {
let vmDir = try VMStorageLocal().open(name) 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) let lock = try PIDLock(lockURL: vmDir.configURL)
// Find the VM's PID // 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)\"")
}
}
}
@@ -15,6 +15,10 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host) return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
} }
if let defaultCredsStore = config.credsStore {
return try executeHelper(binaryName: "docker-credential-\(defaultCredsStore)", host: host)
}
return nil return nil
} }
@@ -59,6 +63,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
struct DockerConfig: Codable { struct DockerConfig: Codable {
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary() var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
var credHelpers: Dictionary<String, String>? = Dictionary() var credHelpers: Dictionary<String, String>? = Dictionary()
var credsStore: String? = nil
} }
struct DockerAuthConfig: Codable { struct DockerAuthConfig: Codable {
@@ -2,6 +2,11 @@ import Foundation
class EnvironmentCredentialsProvider: CredentialsProvider { class EnvironmentCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? { 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 username = ProcessInfo.processInfo.environment["TART_REGISTRY_USERNAME"]
let password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"] let password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"]
if let username = username, let password = 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())") 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 { extension OSStatus {
+10 -4
View File
@@ -26,10 +26,16 @@ enum Format: String, ExpressibleByArgument, CaseIterable {
} }
let table = TextTable<T> { (item: T) in let table = TextTable<T> { (item: T) in
let mirroredObject = Mirror(reflecting: item) let mirroredObject = Mirror(reflecting: item)
return mirroredObject.children.enumerated().map { (_, element) in return mirroredObject.children.enumerated()
let fieldName = element.label! .filter {(_, element) in
return Column(title: fieldName, value: element.value) // 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) ?? "" return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
case .json: case .json:
+1 -1
View File
@@ -1,7 +1,7 @@
import Virtualization import Virtualization
protocol Network { protocol Network {
func attachment() -> VZNetworkDeviceAttachment func attachments() -> [VZNetworkDeviceAttachment]
func run(_ sema: DispatchSemaphore) throws func run(_ sema: DispatchSemaphore) throws
func stop() async throws func stop() async throws
} }
+5 -5
View File
@@ -2,14 +2,14 @@ import Foundation
import Virtualization import Virtualization
class NetworkBridged: Network { class NetworkBridged: Network {
let interface: VZBridgedNetworkInterface let interfaces: [VZBridgedNetworkInterface]
init(interface: VZBridgedNetworkInterface) { init(interfaces: [VZBridgedNetworkInterface]) {
self.interface = interface self.interfaces = interfaces
} }
func attachment() -> VZNetworkDeviceAttachment { func attachments() -> [VZNetworkDeviceAttachment] {
VZBridgedNetworkDeviceAttachment(interface: interface) interfaces.map { VZBridgedNetworkDeviceAttachment(interface: $0) }
} }
func run(_ sema: DispatchSemaphore) throws { func run(_ sema: DispatchSemaphore) throws {
+2 -2
View File
@@ -2,8 +2,8 @@ import Foundation
import Virtualization import Virtualization
class NetworkShared: Network { class NetworkShared: Network {
func attachment() -> VZNetworkDeviceAttachment { func attachments() -> [VZNetworkDeviceAttachment] {
VZNATNetworkDeviceAttachment() [VZNATNetworkDeviceAttachment()]
} }
func run(_ sema: DispatchSemaphore) throws { 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) let fh = FileHandle.init(fileDescriptor: vmFD)
return VZFileHandleNetworkDeviceAttachment(fileHandle: fh) return [VZFileHandleNetworkDeviceAttachment(fileHandle: fh)]
} }
static func configureSUIDBitIfNeeded() throws { static func configureSUIDBitIfNeeded() throws {
+11 -4
View File
@@ -5,6 +5,7 @@ let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
// Annotations // Annotations
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size" let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
struct OCIManifest: Codable, Equatable { struct OCIManifest: Codable, Equatable {
var schemaVersion: Int = 2 var schemaVersion: Int = 2
@@ -13,15 +14,21 @@ struct OCIManifest: Codable, Equatable {
var layers: [OCIManifestLayer] = Array() var layers: [OCIManifestLayer] = Array()
var annotations: Dictionary<String, String>? 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.config = config
self.layers = layers self.layers = layers
var annotations: [String: String] = [:]
if let uncompressedDiskSize = uncompressedDiskSize { if let uncompressedDiskSize = uncompressedDiskSize {
annotations = [ annotations[uncompressedDiskSizeAnnotation] = String(uncompressedDiskSize)
uncompressedDiskSizeAnnotation: String(uncompressedDiskSize)
]
} }
if let uploadDate = uploadDate {
annotations[uploadTimeAnnotation] = uploadDate.toISO()
}
self.annotations = annotations
} }
init(fromJSON: Data) throws { init(fromJSON: Data) throws {
+21 -4
View File
@@ -45,7 +45,8 @@ struct TokenResponse: Decodable, Authentication {
let defaultIssuedAt = Date() let defaultIssuedAt = Date()
let defaultExpiresIn = 60 let defaultExpiresIn = 60
var token: String var token: String?
var accessToken: String?
var expiresIn: Int? var expiresIn: Int?
var issuedAt: Date? var issuedAt: Date?
@@ -65,7 +66,13 @@ struct TokenResponse: Decodable, Authentication {
return dateFormatter.date(from: dateString) ?? Date() 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 { var tokenExpiresAt: Date {
@@ -83,7 +90,7 @@ struct TokenResponse: Decodable, Authentication {
} }
func header() -> (String, String) { func header() -> (String, String) {
("Authorization", "Bearer \(token)") return ("Authorization", "Bearer \(token ?? accessToken ?? "")")
} }
func isValid() -> Bool { func isValid() -> Bool {
@@ -92,12 +99,22 @@ struct TokenResponse: Decodable, Authentication {
} }
class Registry { class Registry {
let baseURL: URL private let baseURL: URL
let namespace: String let namespace: String
let credentialsProviders: [CredentialsProvider] let credentialsProviders: [CredentialsProvider]
var currentAuthToken: Authentication? = nil 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, init(urlComponents: URLComponents,
namespace: String, namespace: String,
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()] credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
+5
View File
@@ -8,6 +8,11 @@ class PIDLock {
init(lockURL: URL) throws { init(lockURL: URL) throws {
url = lockURL url = lockURL
fd = open(lockURL.path, O_RDWR) 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 { deinit {
+21 -3
View File
@@ -6,7 +6,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
} }
} }
struct Darwin: Platform { struct Darwin: PlatformSuspendable {
var ecid: VZMacMachineIdentifier var ecid: VZMacMachineIdentifier
var hardwareModel: VZMacHardwareModel var hardwareModel: VZMacHardwareModel
@@ -100,17 +100,35 @@ struct Darwin: Platform {
func keyboards() -> [VZKeyboardConfiguration] { func keyboards() -> [VZKeyboardConfiguration] {
if #available(macOS 14, *) { if #available(macOS 14, *) {
return [VZMacKeyboardConfiguration()] // Mac keyboard is only supported by guests starting with macOS Ventura
return [VZMacKeyboardConfiguration(), VZUSBKeyboardConfiguration()]
} else { } else {
return [VZUSBKeyboardConfiguration()] return [VZUSBKeyboardConfiguration()]
} }
} }
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
if #available(macOS 14, *) {
return [VZMacKeyboardConfiguration()]
} else {
return []
}
}
func pointingDevices() -> [VZPointingDeviceConfiguration] { func pointingDevices() -> [VZPointingDeviceConfiguration] {
if #available(macOS 13, *) { if #available(macOS 13, *) {
return [VZMacTrackpadConfiguration()] // Trackpad is only supported by guests starting with macOS Ventura
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
} else { } else {
return [VZUSBScreenCoordinatePointingDeviceConfiguration()] return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
} }
} }
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
if #available(macOS 13, *) {
return [VZMacTrackpadConfiguration()]
} else {
return []
}
}
} }
+5
View File
@@ -8,3 +8,8 @@ protocol Platform: Codable {
func keyboards() -> [VZKeyboardConfiguration] func keyboards() -> [VZKeyboardConfiguration]
func pointingDevices() -> [VZPointingDeviceConfiguration] func pointingDevices() -> [VZPointingDeviceConfiguration]
} }
protocol PlatformSuspendable: Platform {
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration]
func keyboardsSuspendable() -> [VZKeyboardConfiguration]
}
+12 -4
View File
@@ -16,6 +16,7 @@ struct Root: AsyncParsableCommand {
Get.self, Get.self,
List.self, List.self,
Login.self, Login.self,
Logout.self,
IP.self, IP.self,
Pull.self, Pull.self,
Push.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, // Ensure the default SIGINT handled is disabled,
// otherwise there's a race between two handlers // otherwise there's a race between two handlers
signal(SIGINT, SIG_IGN); signal(SIGINT, SIG_IGN);
@@ -76,10 +82,12 @@ struct Root: AsyncParsableCommand {
var command = try parseAsRoot() var command = try parseAsRoot()
// Run garbage-collection before each command (shouldn't take too long) // Run garbage-collection before each command (shouldn't take too long)
do { if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
try Config().gc() do {
} catch { try Config().gc()
fputs("Failed to perform garbage collection!\n\(error)\n", stderr) } catch {
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
}
} }
if var asyncCommand = command as? AsyncParsableCommand { if var asyncCommand = command as? AsyncParsableCommand {
+70 -28
View File
@@ -26,6 +26,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Virtualization.Framework's virtual machine // Virtualization.Framework's virtual machine
@Published var virtualMachine: VZVirtualMachine @Published var virtualMachine: VZVirtualMachine
// Virtualization.Framework's virtual machine configuration
var configuration: VZVirtualMachineConfiguration
// Semaphore used to communicate with the VZVirtualMachineDelegate // Semaphore used to communicate with the VZVirtualMachineDelegate
var sema = DispatchSemaphore(value: 0) var sema = DispatchSemaphore(value: 0)
@@ -41,7 +44,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
network: Network = NetworkShared(), network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [], additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [], directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
serialPorts: [VZSerialPortConfiguration] = [] serialPorts: [VZSerialPortConfiguration] = [],
suspendable: Bool = false
) throws { ) throws {
name = vmDir.name name = vmDir.name
config = try VMConfig.init(fromURL: vmDir.configURL) config = try VMConfig.init(fromURL: vmDir.configURL)
@@ -52,11 +56,12 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Initialize the virtual machine and its configuration // Initialize the virtual machine and its configuration
self.network = network self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
nvramURL: vmDir.nvramURL, vmConfig: config, nvramURL: vmDir.nvramURL, vmConfig: config,
network: network, additionalDiskAttachments: additionalDiskAttachments, network: network, additionalDiskAttachments: additionalDiskAttachments,
directorySharingDevices: directorySharingDevices, directorySharingDevices: directorySharingDevices,
serialPorts: serialPorts serialPorts: serialPorts,
suspendable: suspendable
) )
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
@@ -179,11 +184,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Initialize the virtual machine and its configuration // Initialize the virtual machine and its configuration
self.network = network self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
vmConfig: config, network: network, vmConfig: config, network: network,
additionalDiskAttachments: additionalDiskAttachments, additionalDiskAttachments: additionalDiskAttachments,
directorySharingDevices: directorySharingDevices, directorySharingDevices: directorySharingDevices,
serialPorts: serialPorts serialPorts: serialPorts
) )
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
@@ -220,11 +225,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
return try VM(vmDir: vmDir) return try VM(vmDir: vmDir)
} }
func run(_ recovery: Bool) async throws { func start(recovery: Bool, resume shouldResume: Bool) async throws {
try network.run(sema) try network.run(sema)
try await start(recovery) if shouldResume {
try await resume()
} else {
try await start(recovery)
}
}
func run() async throws {
await withTaskCancellationHandler(operation: { await withTaskCancellationHandler(operation: {
// Wait for the VM to finish running // Wait for the VM to finish running
// or for the exit condition // or for the exit condition
@@ -253,6 +264,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
} }
@MainActor
private func resume() async throws {
try await virtualMachine.resume()
}
@MainActor @MainActor
private func stop() async throws { private func stop() async throws {
try await self.virtualMachine.stop() try await self.virtualMachine.stop()
@@ -265,7 +281,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
network: Network = NetworkShared(), network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment], additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
directorySharingDevices: [VZDirectorySharingDeviceConfiguration], directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
serialPorts: [VZSerialPortConfiguration] serialPorts: [VZSerialPortConfiguration],
suspendable: Bool = false
) throws -> VZVirtualMachineConfiguration { ) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration() let configuration = VZVirtualMachineConfiguration()
@@ -283,23 +300,32 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)] configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
// Audio // Audio
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration() if !suspendable {
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration() let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource() let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration() inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink() let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration] outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
configuration.audioDevices = [soundDeviceConfiguration] soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
configuration.audioDevices = [soundDeviceConfiguration]
}
// Keyboard and mouse // Keyboard and mouse
configuration.keyboards = vmConfig.platform.keyboards() if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
configuration.pointingDevices = vmConfig.platform.pointingDevices() configuration.keyboards = platformSuspendable.keyboardsSuspendable()
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
} else {
configuration.keyboards = vmConfig.platform.keyboards()
configuration.pointingDevices = vmConfig.platform.pointingDevices()
}
// Networking // Networking
let vio = VZVirtioNetworkDeviceConfiguration() configuration.networkDevices = network.attachments().map {
vio.attachment = network.attachment() let vio = VZVirtioNetworkDeviceConfiguration()
vio.macAddress = vmConfig.macAddress vio.attachment = $0
configuration.networkDevices = [vio] vio.macAddress = vmConfig.macAddress
return vio
}
// Storage // Storage
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)] var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
@@ -307,7 +333,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) } configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
// Entropy // Entropy
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()] if !suspendable {
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
}
// Directory sharing devices // Directory sharing devices
configuration.directorySharingDevices = directorySharingDevices configuration.directorySharingDevices = directorySharingDevices
@@ -315,6 +343,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Serial Port // Serial Port
configuration.serialPorts = serialPorts configuration.serialPorts = serialPorts
// Version console device
//
// A dummy console device useful for implementing
// host feature checks in the guest agent software.
if #available(macOS 13, *) {
let consolePort = VZVirtioConsolePortConfiguration()
consolePort.name = "tart-version-\(CI.version)"
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
consoleDevice.ports[0] = consolePort
configuration.consoleDevices.append(consoleDevice)
}
try configuration.validate() try configuration.validate()
return configuration return configuration
+3 -2
View File
@@ -148,7 +148,8 @@ extension VMDirectory {
let manifest = OCIManifest( let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest), config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
layers: layers, layers: layers,
uncompressedDiskSize: UInt64(mappedDiskReadOffset) uncompressedDiskSize: UInt64(mappedDiskReadOffset),
uploadDate: Date()
) )
// Manifest // Manifest
@@ -159,7 +160,7 @@ extension VMDirectory {
} }
let pushedReference = Reference(digest: try manifest.digest()) 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)
} }
} }
+38 -1
View File
@@ -1,5 +1,6 @@
import Foundation import Foundation
import Virtualization import Virtualization
import CryptoKit
struct VMDirectory: Prunable { struct VMDirectory: Prunable {
var baseURL: URL var baseURL: URL
@@ -13,6 +14,9 @@ struct VMDirectory: Prunable {
var nvramURL: URL { var nvramURL: URL {
baseURL.appendingPathComponent("nvram.bin") baseURL.appendingPathComponent("nvram.bin")
} }
var stateURL: URL {
baseURL.appendingPathComponent("state.vzvmsave")
}
var explicitlyPulledMark: URL { var explicitlyPulledMark: URL {
baseURL.appendingPathComponent(".explicitly-pulled") baseURL.appendingPathComponent(".explicitly-pulled")
@@ -27,7 +31,26 @@ struct VMDirectory: Prunable {
} }
func running() throws -> Bool { 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 { static func temporary() throws -> VMDirectory {
@@ -37,6 +60,17 @@ struct VMDirectory: Prunable {
return VMDirectory(baseURL: tmpDir) 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 { var initialized: Bool {
FileManager.default.fileExists(atPath: configURL.path) && FileManager.default.fileExists(atPath: configURL.path) &&
FileManager.default.fileExists(atPath: diskURL.path) && FileManager.default.fileExists(atPath: diskURL.path) &&
@@ -70,6 +104,7 @@ struct VMDirectory: Prunable {
try FileManager.default.copyItem(at: configURL, to: to.configURL) try FileManager.default.copyItem(at: configURL, to: to.configURL)
try FileManager.default.copyItem(at: nvramURL, to: to.nvramURL) try FileManager.default.copyItem(at: nvramURL, to: to.nvramURL)
try FileManager.default.copyItem(at: diskURL, to: to.diskURL) try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
try? FileManager.default.copyItem(at: stateURL, to: to.stateURL)
// Re-generate MAC address // Re-generate MAC address
if generateMAC { if generateMAC {
@@ -85,6 +120,8 @@ struct VMDirectory: Prunable {
var vmConfig = try VMConfig(fromURL: configURL) var vmConfig = try VMConfig(fromURL: configURL)
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered() vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
// cleanup state if any
try? FileManager.default.removeItem(at: stateURL)
try vmConfig.save(toURL: configURL) try vmConfig.save(toURL: configURL)
} }
+3
View File
@@ -58,6 +58,7 @@ enum RuntimeError : Error {
case ImportFailed(_ message: String) case ImportFailed(_ message: String)
case SoftnetFailed(_ message: String) case SoftnetFailed(_ message: String)
case OCIStorageError(_ message: String) case OCIStorageError(_ message: String)
case SuspendFailed(_ message: String)
} }
protocol HasExitCode { protocol HasExitCode {
@@ -101,6 +102,8 @@ extension RuntimeError : CustomStringConvertible {
return "Softnet failed: \(message)" return "Softnet failed: \(message)"
case .OCIStorageError(let message): case .OCIStorageError(let message):
return "OCI storage error: \(message)" return "OCI storage error: \(message)"
case .SuspendFailed(let message):
return "Failed to suspend the VM: \(message)"
} }
} }
} }
+7 -1
View File
@@ -1,6 +1,6 @@
import Foundation import Foundation
class VMStorageLocal { class VMStorageLocal: PrunableStorage {
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true) let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
private func vmURL(_ name: String) -> URL { private func vmURL(_ name: String) -> URL {
@@ -16,6 +16,8 @@ class VMStorageLocal {
try vmDir.validate(userFriendlyName: name) try vmDir.validate(userFriendlyName: name)
try vmDir.baseURL.updateAccessDate()
return vmDir return vmDir
} }
@@ -63,6 +65,10 @@ class VMStorageLocal {
} }
} }
func prunables() throws -> [Prunable] {
try list().map { (_, vmDir) in vmDir }
}
func hasVMsWithMACAddress(macAddress: String) throws -> Bool { func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
try list().contains { try $1.macAddress() == macAddress } try list().contains { try $1.macAddress() == macAddress }
} }
+24 -28
View File
@@ -133,6 +133,10 @@ class VMStorageOCI: PrunableStorage {
} }
func pull(_ name: RemoteName, registry: Registry) async throws { func pull(_ name: RemoteName, registry: Registry) async throws {
SentrySDK.configureScope { scope in
scope.setContext(value: ["imageName": name], key: "OCI")
}
defaultLogger.appendNewLine("pulling manifest...") defaultLogger.appendNewLine("pulling manifest...")
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value) let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
@@ -140,6 +144,12 @@ class VMStorageOCI: PrunableStorage {
let digestName = RemoteName(host: name.host, namespace: name.namespace, let digestName = RemoteName(host: name.host, namespace: name.namespace,
reference: Reference(digest: Digest.hash(manifestData))) 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 // Ensure that host directory for given RemoteName exists in OCI storage
let hostDirectoryURL = hostDirectoryURL(digestName) let hostDirectoryURL = hostDirectoryURL(digestName)
try FileManager.default.createDirectory(at: hostDirectoryURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: hostDirectoryURL, withIntermediateDirectories: true)
@@ -160,7 +170,7 @@ class VMStorageOCI: PrunableStorage {
if !exists(digestName) { if !exists(digestName) {
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true) 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 // Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL) let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
@@ -168,36 +178,13 @@ class VMStorageOCI: PrunableStorage {
// Try to reclaim some cache space if we know the VM size in advance // Try to reclaim some cache space if we know the VM size in advance
if let uncompressedDiskSize = manifest.uncompressedDiskSize() { if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024) SentrySDK.configureScope { scope in
scope.setContext(value: ["imageUncompressedDiskSize": uncompressedDiskSize], key: "OCI")
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")
}
} }
// There is a suspicious that occasionally capacity is returned as zero which can't be true. let otherVMFilesSize: UInt64 = 128 * 1024 * 1024
// 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() }
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes) try Prune.reclaimIfNeeded(uncompressedDiskSize + otherVMFilesSize)
}
} }
try await withTaskCancellationHandler(operation: { try await withTaskCancellationHandler(operation: {
@@ -222,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 { func link(from: RemoteName, to: RemoteName) throws {
if FileManager.default.fileExists(atPath: vmURL(from).path) { if FileManager.default.fileExists(atPath: vmURL(from).path) {
try FileManager.default.removeItem(at: vmURL(from)) 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)
}
}
@@ -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 **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 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 ## 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. 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 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, 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 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.** 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), 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 Sponsorship](/licensing#sponsorships) 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 Sponsorship](/licensing#sponsorships) (\$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 sponsorships will include priority feature development and SLAs on support with urgent issues.** (\$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? ## Have we considered alternatives?
+1
View File
@@ -28,5 +28,6 @@ jobs:
``` ```
When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`. When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`.
Note that Cirrus Runners will get added to the default runner group. By default, only private repositories can access runners in a default runner group, but you can override this in your organization's settings.
![](/assets/images/TartGHARunners.png) ![](/assets/images/TartGHARunners.png)
+49 -15
View File
@@ -6,35 +6,69 @@ hide:
Both [Tart Virtualization](https://github.com/cirruslabs/tart) and [Orchard Orchestration](https://github.com/cirruslabs/orchard) 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, 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) 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 sponsorship. will be required to obtain a paid license.
??? note "Performance and Efficiency Cores" ??? note "Host CPU Core usage"
The virtual CPU cores in Tart VMs do not differentiate between the high-performance and high-efficient cores The virtual CPU cores of Tart VMs are not tied to specific physical cores of the host CPU. Instead, for optimal performance
of the host CPU. Instead, Tart VMs automatically alternate between these types of cores depending on the workload Tart VMs will automatically try to balance compute between all available cores of the host CPU. As a result,
being executed within the virtual machines. As a result, both performance and energy-efficient cores of the host CPU all performance and energy-efficient cores of the host CPU are always counted towards the license usage.
are treated equally in terms of licensing.
# Sponsorships # License Tiers
When an organization surpasses the 100 CPU cores limit, it is required to obtain a Gold Sponsorship, which costs \$1000 per month. ## Free Tier
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.
If your organization is interested in purchasing one of the sponsorships, please email [licensing@cirruslabs.org](mailto:licensing@cirruslabs.org). By default, when no [license is purchased](#get-the-license), it is assumed that an organization is using a Free Tier license.
You can see a template of a sponsorship subscription agreement [here](assets/TartSponsorshipSubscriptionTemplate.pdf). 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).
# General Support # 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). 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 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). 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. Our support team will answer it by replying to the issue or by updating the documentation.
# Priority Support # 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 | | Severity | Support Impact | First Response Time SLA | Hours | How to Submit |
|----------|-----------------------------------------------------------------------------------------------|-------------------------|-------|--------------------------------------------------------------------------------------------------| |----------|-----------------------------------------------------------------------------------------------|-------------------------|-------|--------------------------------------------------------------------------------------------------|
@@ -57,5 +91,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. 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" !!! 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. will get access to separate support emails specified in your subscription contract.
+22 -1
View File
@@ -33,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: If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
```bash ```bash
ssh admin@$(tart ip macos-ventura-base) ssh admin@$(tart ip ventura-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 ventura-base) "uname -a"
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip ventura-base) < script.sh
```
## Mounting directories ## Mounting directories
To mount a directory, run the VM with the `--dir` argument: To mount a directory, run the VM with the `--dir` argument:
@@ -70,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. 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 ### 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: To be able to access the shared directories from the Linux guest, you need to manually mount the virtual filesystem first:
+1 -1
View File
@@ -81,7 +81,7 @@
} }
</style> </style>
<script src="https://unpkg.com/@dotlottie/player-component@latest/dist/dotlottie-player.js"></script> <script src="https://unpkg.com/@dotlottie/player-component@1.4.2/dist/dotlottie-player.js"></script>
<!-- landing page for landing page --> <!-- landing page for landing page -->
<!-- Hero --> <!-- Hero -->
+1
View File
@@ -3,3 +3,4 @@ testcontainers
requests requests
bitmath bitmath
pytest-dependency pytest-dependency
paramiko
+9 -4
View File
@@ -7,10 +7,9 @@ class Tart:
def __init__(self): def __init__(self):
self.tmp_dir = tempfile.TemporaryDirectory(dir=os.environ.get("CIRRUS_WORKING_DIR")) self.tmp_dir = tempfile.TemporaryDirectory(dir=os.environ.get("CIRRUS_WORKING_DIR"))
# Link to the users IPSW cache to make things faster # Link to the users cache to make things faster
src = os.path.join(os.path.expanduser("~"), ".tart", "cache", "IPSWs") src = os.path.join(os.path.expanduser("~"), ".tart", "cache")
dst = os.path.join(self.tmp_dir.name, "cache", "IPSWs") dst = os.path.join(self.tmp_dir.name, "cache")
os.makedirs(os.path.join(self.tmp_dir.name, "cache"))
os.symlink(src, dst) os.symlink(src, dst)
def __enter__(self): def __enter__(self):
@@ -31,3 +30,9 @@ class Tart:
completed_process.check_returncode() completed_process.check_returncode()
return completed_process.stdout.decode("utf-8"), completed_process.stderr.decode("utf-8") 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"]) tart.run(["clone", "debian", "ubuntu"])
# Ensure that we have now 2 VMs # Ensure that we have now 2 VMs
stdout, _, = tart.run(["list", "--quiet"]) stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "debian\nubuntu\n" 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"]) tart.run(["create", "--from-ipsw", "latest", "macos-vm"])
# Ensure that the VM was created # Ensure that the VM was created
stdout, _ = tart.run(["list", "--quiet"]) stdout, _ = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "macos-vm\n" assert stdout == "macos-vm\n"
@@ -12,5 +12,5 @@ def test_create_linux(tart):
tart.run(["create", "--linux", "linux-vm"]) tart.run(["create", "--linux", "linux-vm"])
# Ensure that the VM was created # Ensure that the VM was created
stdout, _ = tart.run(["list", "--quiet"]) stdout, _ = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "linux-vm\n" assert stdout == "linux-vm\n"
+2 -2
View File
@@ -3,12 +3,12 @@ def test_delete(tart):
tart.run(["create", "--linux", "debian"]) tart.run(["create", "--linux", "debian"])
# Ensure that the VM exists # Ensure that the VM exists
stdout, _, = tart.run(["list", "--quiet"]) stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "debian\n" assert stdout == "debian\n"
# Delete the VM # Delete the VM
tart.run(["delete", "debian"]) tart.run(["delete", "debian"])
# Ensure that the VM was removed # Ensure that the VM was removed
stdout, _, = tart.run(["list", "--quiet"]) stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "" assert stdout == ""
+1 -1
View File
@@ -6,5 +6,5 @@ def test_rename(tart):
tart.run(["rename", "debian", "ubuntu"]) tart.run(["rename", "debian", "ubuntu"])
# Ensure that the VM is now named "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" 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-ventura-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])