Compare commits

...
26 Commits
Author SHA1 Message Date
Fedor Korotkov b242f27f49 Ignore GC errors (#405)
* Ignore GC errors

Such errors are not critical

* Print GC error to stderr
2023-02-09 00:15:34 +04:00
Nikolay Edigaryev b566d07bc4 Avoid URL.formatted() method (#408) 2023-02-08 06:52:01 -05:00
Fedor Korotkov d65f530bcb Updated data (#406) 2023-02-07 23:56:23 -05:00
Fedor Korotkov def779e20b Report to Sentry instead of Puppy (#402)
Instead of logging locally let's report to Sentry.
2023-02-07 20:04:45 +04:00
Khachatur Ashotyan 92b35dbcf2 Add Krisp logo in users section (#403) 2023-02-07 19:06:57 +04:00
Fedor Korotkov b8ff7474c3 Removed --with-softnet flag (#401)
Fixes #276
2023-02-05 21:56:23 +04:00
Fedor Korotkov f34aa5f072 JSON output for get and list commands (#394)
* JSON output for `get` and `list` commands

In the light of the upcoming `1.0.0` release and stabilizing of the API, let's introduce some breaking changes for the good.

Removed all the `--cpu`, `--memory`, `--disk` and `--display` flags and replaced with a single `--json` flag for machine-readable output.

Added `--json` option to the `list` command to output a single JSON list. Notably removed `--quite` flag since it seemed unnecessary.

Fixes #297

* Added Size to `list` output

Fixes #379

* Added running state to `get`

Fixes #393

* Better signature

* Updated tests

* More test fixes
2023-02-04 11:40:39 +04:00
Fedor Korotkov ecc5de18be Collect installation information (#390) 2023-02-02 06:12:28 -05:00
Nikolay Edigaryev 41af674a88 Introduce "tart import" and "tart export" commands (#386)
* Introduce "tart import" and "tart export" commands

* Use AppleArchive instead of ZIP and simply {ar,un}chive the VM dir

* Fix formatting

* Link to Apple's docs

* Print "importing..." and "exporting..." lines
2023-02-01 15:27:05 -05:00
Ekaterina Martyshevskaia 9c48d66674 Docs: add Spotlights and Testimonials section for landing page (#389)
* Draft

* Keep media queries at the end + get rid of dduplicate values

* Open external links in a separate window

* Minors

* Remove paragraph from Hero + add links

* Update img

* Remove unnecessary

* Layout adjustment

* Change animated logo position

* Shrink space between sections in mobile
2023-01-31 11:12:20 -05:00
Fedor Korotkov 31b106a67a Move FAQ to documentation website (#383)
Also reworked most of the questions and add new one about accessing services running on host.
2023-01-18 11:58:21 -05:00
fedor abb1d7192a Documentation code block tweaks 2023-01-17 15:14:03 -05:00
fedor 8b8faa2f1a Enable social plugin for documentation 2023-01-17 14:57:06 -05:00
Fedor Korotkov c8c22e8c4d Removed Git LFS (#382) 2023-01-17 23:47:35 +04:00
Fedor KorotkovandNikolay Edigaryev bd87e1a8b1 Documentation Website (#380)
* Move to mkdocs for docs

* Deploy task

* Custom landing page

* Setup Google Analytics

* Cropped animation

* Update docs/theme/overrides/home.html

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

* Direct to website and discussions

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2023-01-17 19:08:30 +00:00
Nikolay Edigaryev ba4f00fa78 Fix ArgumentParser's exception printing (#367) 2022-12-21 18:06:01 +04:00
Nikolay Edigaryev 1380ece108 Use separate exception codes for better Sentry grouping (#363) 2022-12-19 17:40:55 +00:00
Fedor Korotkov 9cdb7087f2 Fixed --help (#361)
* Fixed --help

Apparently `--help` works via exceptions 🤷‍♂️

Fixes #360

* lint issue
2022-12-17 20:57:17 +04:00
Nikolay Edigaryev c3e37854c3 .cirrus.yml: install Sentry CLI (#356) 2022-12-15 22:28:21 +00:00
Fedor Korotkov 828351a8fb Report pull metrics to Sentry (#354)
* Report GC events to Sentry

* Formatting

* Use transactions for measuring pull duration on cache miss

* Fixed linting

* Explicitly enable capturing of failed requests

* Allow customizing tracesSampleRate

* bindToScope

* Measure compressed disk size

* Don't capture GC events
2022-12-15 16:53:34 -05:00
Nikolay Edigaryev f9ac994e18 Fix inverted VMStorageOCI.link() logic (#355) 2022-12-15 21:18:33 +00:00
marc-48k fdeaf979c2 Get command (#353)
* (wip) First pass at Get command.

* Adds validation in case multiple options are supplied.
2022-12-15 22:52:22 +04:00
Nikolay Edigaryev d9f1c37cdd Sentry integration (#352)
* Ditch Foundation.exit()'s where feasible

* Sentry integration

* SwiftFormat

* Upload symbols and sources to Sentry

* Use Sentry Releases

* Do not use ExitCode exceptions

* Clarify why we need CustomNSError extension
2022-12-15 13:50:21 +00:00
Fedor Korotkov 81253417a4 Optimal buffer sizes for Softnet socket (#351)
* Optimal buffer sizes for Softnet socket

* Formatting
2022-12-14 23:18:39 +04:00
marc-48kandNikolay Edigaryev c9caeab098 Filter tart list by source (#349)
* Adds Option to filter VMs by 'source'

* Tidy up columns for table on some Terminals

* Don't show headers if there's an error.

* Fixes linting errors.

* Update Sources/tart/Commands/List.swift

- removing the short name for now, as we don't know if we may need -s in the future
- removing the capitalization for the word "source" and adding an example instead

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

* source should be an Optional flag. Reverts header change.

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2022-12-14 13:08:35 -05:00
marc-48kandFedor Korotkov 6d7dc40c57 Doco fixups (#350)
* Fix-ups and SSH howto

* Minor corrections, additions and normalizations

* Fixes SSH command.

* Update README.md

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

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2022-12-14 21:49:37 +04:00
76 changed files with 1894 additions and 781 deletions
+30 -1
View File
@@ -61,6 +61,9 @@ task:
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
SENTRY_ORG: cirrus-labs
SENTRY_PROJECT: persistent-workers
SENTRY_AUTH_TOKEN: ENCRYPTED[!c16a5cf7da5f856b4bc2f21fe8cb7aa2a6c981f851c094ed4d3025fd02ea59a58a86cee8b193a69a1fc20fa217e56ac3!]
setup_script:
- cd $HOME
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
@@ -71,10 +74,36 @@ task:
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
install_script:
- brew install go goreleaser/tap/goreleaser-pro
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
- brew install mitchellh/gon/gon
info_script:
- security find-identity -v
- xcodebuild -version
- swift -version
release_script: goreleaser
upload_sentry_debug_files_script:
- cd .build/arm64-apple-macosx/debug/
# Generate and upload symbols
- dsymutil tart
- sentry-cli debug-files upload tart.dSYM/
# Bundle and upload sources
- sentry-cli debug-files bundle-sources tart.dSYM
- sentry-cli debug-files upload tart.src.zip
create_sentry_release_script:
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
- sentry-cli releases new $SENTRY_RELEASE
- sentry-cli releases set-commits $SENTRY_RELEASE --auto
- sentry-cli releases finalize $SENTRY_RELEASE
task:
name: Deploy Documentation
only_if: $CIRRUS_BRANCH == 'main'
container:
image: ghcr.io/squidfunk/mkdocs-material:latest
env:
DEPLOY_TOKEN: ENCRYPTED[!45ed45666558902ed1c2400add734ec063103bec31841847e8c8764802fca229bfa6d85c690e16ad159e047574b48793!]
deploy_script:
- git config --global user.name "Cirrus CI"
- git config --global user.name "hello@cirruslabs.org"
- git remote set-url origin https://$DEPLOY_TOKEN@github.com/cirruslabs/tart/
- mkdocs --verbose gh-deploy --force --remote-branch gh-pages
-2
View File
@@ -1,2 +0,0 @@
*.png filter=lfs diff=lfs merge=lfs -text
*.gif filter=lfs diff=lfs merge=lfs -text
+3
View File
@@ -13,3 +13,6 @@ tart.xcodeproj/
# GoReleaser
dist/
# mkdocs
.cache
+5
View File
@@ -39,10 +39,15 @@ brews:
name: homebrew-cli
caveats: See the GitHub repository for more information
homepage: https://github.com/cirruslabs/tart
license: "AGPL-3.0"
description: Run macOS VMs on Apple Silicon
skip_upload: auto
dependencies:
- "cirruslabs/cli/softnet"
post_install: |
ENV["SENTRY_DSN"] = "https://606fab64ced94f0991109ac5467e23da@o4504250314522624.ingest.sentry.io/4504606552424448"
system "#{bin}/tart report-installation"
ENV.delete("SENTRY_DSN")
custom_block: |
depends_on :macos => :monterey
+13 -13
View File
@@ -19,12 +19,12 @@
}
},
{
"identity" : "puppy",
"identity" : "sentry-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sushichop/Puppy",
"location" : "https://github.com/getsentry/sentry-cocoa",
"state" : {
"revision" : "3e8d87f714f14244878752a6bb71ea465119f8a1",
"version" : "0.5.1"
"revision" : "c3c19e29f775ee95b77aa3168f3e2fd6c20deba6",
"version" : "7.31.3"
}
},
{
@@ -72,15 +72,6 @@
"version" : "1.0.3"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "6fe203dc33195667ce1759bf0182975e4653ba1c",
"version" : "1.4.4"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
@@ -107,6 +98,15 @@
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
"version" : "0.50.6"
}
},
{
"identity" : "texttable",
"kind" : "remoteSourceControl",
"location" : "https://github.com/cfilipov/TextTable",
"state" : {
"branch" : "master",
"revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
}
}
],
"version" : 2
+4 -2
View File
@@ -15,10 +15,11 @@ let package = Package(
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
.package(url: "https://github.com/sushichop/Puppy", from: "0.5.1"),
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "7.31.3"),
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
],
targets: [
.executableTarget(name: "tart", dependencies: [
@@ -27,9 +28,10 @@ let package = Package(
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Dynamic", package: "Dynamic"),
.product(name: "SwiftDate", package: "SwiftDate"),
.product(name: "Puppy", package: "Puppy"),
.product(name: "Antlr4Static", package: "Antlr4"),
.product(name: "Atomics", package: "swift-atomics"),
.product(name: "Sentry", package: "sentry-cocoa"),
.product(name: "TextTable", package: "TextTable"),
], exclude: [
"OCI/Reference/Makefile",
"OCI/Reference/Reference.g4",
+9 -339
View File
@@ -1,9 +1,9 @@
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon.
Built by CI engineers for your automation needs. Here are some highlights of Tart:
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/14966395?baseline=14966339).
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722).
* Push/Pull virtual machines from any OCI-compatible container registry.
* Use Tart Packer Plugin to automate VM creation.
* Built-in CI integration.
@@ -28,6 +28,9 @@ Many more companies are using Tart in their internal setups. Here are a few of t
<a href="https://ahrefs.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
</a>
<a href="https://krisp.ai/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
</a>
<a href="https://suran.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
</a>
@@ -40,346 +43,13 @@ Many more companies are using Tart in their internal setups. Here are a few of t
## Usage
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
```shell
```bash
brew install cirruslabs/cli/tart
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
tart run ventura-base
```
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartScreenshot.png"/>
## CI Integration
Tart already powers several CI services mentioned above including our own [Cirrus CI](https://cirrus-ci.org/guide/macOS/) which offers unlimited concurrency with per-second billing.
For services that haven't leveraged Tart yet, we offer fully managed runners via a monthly subscription.
*Cirrus Runners* is the fastest way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
Please read down below about currently supported services.
### Managed runners for your CI-as-a-service
At the moment Cirrus Runners only supports GitHub Actions, but we are actively working on adding more options.
Please [email us](mailto:hello@cirruslabs.org) if you are interested in a particular one.
#### GitHub Actions
Configuring Cirrus Runners for GitHub Actions is as simple as installing [Cirrus Runners App](https://github.com/apps/cirrus-runners).
After successful installation and subscription configuration, use any of [Ventura images managed by us](https://github.com/cirruslabs/macos-image-templates) in `runs-on`:
```yaml
name: Test Suite
jobs:
test:
runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest
```
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`.
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartGHARunners.png"/>
### Self-hosted CI
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
also developed by Cirrus Labs. [Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is a command line tool with
one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
We built Cirrus CLI to solve "But it works on my machine!" problem.
Here is an example of a `.cirrus.yml` configuration file which will start a Tart VM, will copy over working directory and
will run scripts and [other instructions](https://cirrus-ci.org/guide/writing-tasks/#supported-instructions) inside the virtual machine:
```yaml
task:
name: hello
macos_instance:
# can be a remote or a local virtual machine
image: ghcr.io/cirruslabs/macos-monterey-base:latest
hello_script:
- echo "Hello from within a Tart VM!"
- echo "Here is my CPU info:"
- sysctl -n machdep.cpu.brand_string
- sleep 15
```
Put the above `.cirrus.yml` file in the root of your repository and run it with the following command:
```shell
brew install cirruslabs/cli/cirrus
cirrus run
```
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartCirrusCLI.gif"/>
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
#### Retrieving artifacts from within Tart VMs
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tasks/#artifacts-instruction):
```yaml
task:
name: Build
macos_instance:
image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
build_script: swift build --product tart
binary_artifacts:
path: .build/debug/tart
```
Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
```bash
cirrus run --artifacts-dir artifacts
```
Note that all retrieved artifacts will be prefixed with the associated task name and `artifacts` instruction name.
For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/binary/.build/debug/tart`.
## Virtual Machine Management
### Creating from scratch
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regarding of the underlying OS a particular VM image has.
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
#### Creating a macOS VM image from scratch
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
use `latest` instead of a path to `*.ipsw` to download the latest available version:
```shell
tart create --from-ipsw=latest monterey-vanilla
tart run monterey-vanilla
```
After the initial booting of the VM you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
1. Enable Auto-Login. Users & Groups -> Login Options -> Automatic login -> admin.
2. Allow SSH. Sharing -> Remote Login
3. Disable Lock Screen. Preferences -> Lock Screen -> disable "Require Password" after 5.
4. Disable Screen Saver.
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
#### Creating a Linux VM image from scratch
```bash
# Create a bare VM
tart create --linux ubuntu
# Install Ubuntu
tart run --disk focal-desktop-arm64.iso ubuntu
# Run VM
tart run ubuntu
```
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
```shell
sudo apt update
sudo apt install -y openssh-server
sudo ufw allow ssh
```
### Configuring a VM
By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command.
Please refer to `tart set --help` for additional details.
### Building with Packer
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
Here is an example of a template to build `monterey-base` local image based of a remote image:
```hcl
packer {
required_plugins {
tart = {
version = ">= 0.5.3"
source = "github.com/cirruslabs/tart"
}
}
}
source "tart-cli" "tart" {
vm_base_name = "ghcr.io/cirruslabs/macos-ventura-base:latest"
vm_name = "my-custom-ventura"
cpu_count = 4
memory_gb = 8
disk_size_gb = 70
ssh_password = "admin"
ssh_timeout = "120s"
ssh_username = "admin"
}
build {
sources = ["source.tart-cli.tart"]
provisioner "shell" {
inline = ["echo 'Disabling spotlight indexing...'", "sudo mdutil -a -i off"]
}
# more provisioners
}
```
Here is a [repository with Packer templates](https://github.com/cirruslabs/macos-image-templates) used to build [all the images managed by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
### Working with a Remote OCI Container Registry
For example, let's say you want to push/pull images to a registry hosted at https://acme.io/.
#### Registry Authorization
First, you need to log in and save credential for `acme.io` host via `tart login` command:
```shell
tart login acme.io
```
Credentials are securely stored in Keychain.
In addition, Tart supports [Docker credential helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers)
if defined in `~/.docker/config.json`.
Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override authorization
for all registries which might useful for integrating with your CI's secret management.
#### Pushing a Local Image
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
```shell
tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:v1.0.0
```
#### Pulling a Remote Image
You can either pull an image:
```shell
tart pull acme.io/remoteorg/name:latest
```
...or instantiate a VM from a remote image:
```shell
tart clone acme.io/remoteorg/name:latest my-local-vm-name
```
This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
### Mounting directories
To mount a directory, run the VM with the `--dir` argument:
```sh
tart run --dir=project:~/src/project vm
```
Here, the `project` specifies a mount name, whereas the `~/src/project` is a path to the host's directory to expose to the VM.
It is also possible to mount directories in read-only mode by adding a third parameter, `ro`:
```sh
tart run --dir=project:~/src/project:ro vm
```
To mount multiple directories, repeat the `--dir` argument for each directory:
```sh
tart run --dir=www1:~/project1/www --dir=www2:~/project2/www
```
Note that the first parameter in each `--dir` argument must be unique, otherwise only the last `--dir` argument using that name will be used.
Note: to use the directory mounting feature, the host needs to run macOS 13.0 (Ventura) or newer.
#### Accessing mounted directories in macOS guests
All shared directories are automatically mounted to `/Volumes/My Shared Files` directory.
The directory we've mounted above will be accessible from the `/Volumes/My Shared Files/project` path inside a guest VM.
Note: to use the directory mounting feature, the guest VM needs to run macOS 13.0 (Ventura) or newer.
#### 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:
```sh
mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
```
The directory we've mounted above will be accessible from the `/mnt/shared/project` path inside a guest VM.
## FAQ
<details>
<summary>How Tart is different from Anka</summary>
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
Instead of Anka Registry, Tart can work with any OCI-compatible container registry.
Tart doesn't yet have an analogue of Anka Controller for managing long living VMs. Please take a look at [CI integration](#ci-integration)
section for an option to run ephemeral VMs for your needs.
</details>
<details>
<summary>Why Tart is free and open sourced?</summary>
Apple did all the heavy lifting with their `Virtualization.Framework` and it just felt right to develop Tart in the open.
Please consider [becoming a sponsor](https://github.com/sponsors/cirruslabs) if you find Tart saving a substantial amount of money on licensing and engineering hours for your company.
</details>
<details>
<summary>How to change VM's disk size?</summary>
You can choose disk size upon creation of a virtual machine:
```shell
tart create --from-ipsw=latest --disk-size=25 monterey-vanilla
```
For an existing VM please use [Packer Plugin](https://github.com/cirruslabs/packer-plugin-tart) which can increase
disk size for new virtual machines. Here is an example of [how to change disk size in a Packer template](https://github.com/cirruslabs/macos-image-templates/blob/fb0bcf68e0b093129136875c050205a66729b596/templates/base.pkr.hcl#L15).
</details>
<details>
<summary>VM location on disk</summary>
Tart stores all it's files in `~/.tart/` directory. Local images that you can run are stored in `~/.tart/vms/`.
Remote images are pulled into `~/.tart/vms/cache/OCIs/`.
</details>
<details>
<summary>Nested virtualization support?</summary>
Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
doesn't support nested virtualization.
</details>
<details>
<summary>Changing the default NAT subnet</summary>
To change the default network to `192.168.77.1`:
```
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Address -string 192.168.77.1
```
Note that even through a network would normally be specified as `192.168.77.0`, the [vmnet framework](https://developer.apple.com/documentation/vmnet) seems to treat this as a starting address too and refuses to pick up such network-like values.
The default subnet mask `255.255.255.0` should suffice for most use-cases, however, you can also change it to `255.255.0.0`, for example:
```
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
```
</details>
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
for remaining questions.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

After

Width:  |  Height:  |  Size: 120 KiB

-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a3a324193c4bd7797102765ab16f44adf58e49ca615bac3963cefd0d3a10594
size 339678
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:23728bb5438c88b3d0826170a5fa4b7aaad0fbd94a4769c8d492c9f75b57ba81
size 155885
Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 B

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 B

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 B

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 B

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 B

After

Width:  |  Height:  |  Size: 5.2 KiB

+4
View File
@@ -4,6 +4,10 @@ struct CI {
static var version: String {
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
}
static var release: String? {
rawVersion.expanded() ? "tart@\(rawVersion)" : nil
}
}
private extension String {
+26 -45
View File
@@ -21,54 +21,35 @@ struct Clone: AsyncParsableCommand {
}
func run() async throws {
do {
let ociStorage = VMStorageOCI()
let localStorage = VMStorageLocal()
let ociStorage = VMStorageOCI()
let localStorage = VMStorageLocal()
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
// Pull the VM in case it's OCI-based and doesn't exist locally yet
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
try await ociStorage.pull(remoteName, registry: registry)
}
let sourceVM = try VMStorageHelper.open(sourceName)
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
try await withTaskCancellationHandler(operation: {
let lock = try FileLock(lockURL: Config().tartHomeDir)
try lock.lock()
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
try localStorage.move(newName, from: tmpVMDir)
try lock.unlock()
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
// Pull the VM in case it's OCI-based and doesn't exist locally yet
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
try await ociStorage.pull(remoteName, registry: registry)
}
}
}
fileprivate extension VMDirectory {
func macAddress() throws -> String {
try VMConfig(fromURL: configURL).macAddress.string
}
}
let sourceVM = try VMStorageHelper.open(sourceName)
fileprivate extension VMStorageLocal {
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
try list().contains { try $1.macAddress() == macAddress }
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
try await withTaskCancellationHandler(operation: {
// Acquire a global lock
let lock = try FileLock(lockURL: Config().tartHomeDir)
try lock.lock()
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
try localStorage.move(newName, from: tmpVMDir)
try lock.unlock()
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
}
}
+26 -34
View File
@@ -25,46 +25,38 @@ struct Create: AsyncParsableCommand {
}
func run() async throws {
do {
let tmpVMDir = try VMDirectory.temporary()
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
try await withTaskCancellationHandler(operation: {
if let fromIPSW = fromIPSW {
let ipswURL: URL
try await withTaskCancellationHandler(operation: {
if let fromIPSW = fromIPSW {
let ipswURL: URL
if fromIPSW == "latest" {
ipswURL = try await VM.latestIPSWURL()
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
ipswURL = URL(string: fromIPSW)!
} else {
ipswURL = URL(fileURLWithPath: fromIPSW)
}
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
if fromIPSW == "latest" {
ipswURL = try await VM.latestIPSWURL()
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
ipswURL = URL(string: fromIPSW)!
} else {
ipswURL = URL(fileURLWithPath: fromIPSW)
}
if linux {
if #available(macOS 13, *) {
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
} else {
throw UnsupportedOSError("Linux VMs", "are")
}
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
}
if linux {
if #available(macOS 13, *) {
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
} else {
throw UnsupportedOSError("Linux VMs", "are")
}
}
try VMStorageLocal().move(name, from: tmpVMDir)
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
try VMStorageLocal().move(name, from: tmpVMDir)
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
}
}
+2 -10
View File
@@ -9,16 +9,8 @@ struct Delete: AsyncParsableCommand {
var name: [String]
func run() async throws {
do {
for it in name {
try VMStorageHelper.delete(it)
}
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
for it in name {
try VMStorageHelper.delete(it)
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import ArgumentParser
struct Export: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Export VM to a file")
@Argument(help: "Source VM name.")
var name: String
@Argument(help: "Path to the destination file.")
var path: String
func run() async throws {
print("exporting...")
try VMStorageHelper.open(name).exportToArchive(path: path)
}
}
+19 -19
View File
@@ -1,31 +1,31 @@
import ArgumentParser
import Foundation
fileprivate struct VMInfo: Encodable {
let CPU: Int
let Memory: UInt64
let Disk: Int
let Display: String
let Running: Bool
}
struct Get: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
@Argument(help: "VM name")
@Argument(help: "VM name.")
var name: String
@Option(help: "Output format: text or json")
var format: Format = .text
func run() async throws {
do {
let vmDir = try VMStorageLocal().open(name)
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
let diskSize = try vmDir.sizeBytes() / 1000 / 1000 / 1000
let vmDir = try VMStorageLocal().open(name)
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
let diskSizeInGb = try vmDir.sizeGB()
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
let running = try PIDLock(lockURL: vmDir.configURL).pid() > 0
print("CPU\tMemory\tDisk\tDisplay")
var s = "\(vmConfig.cpuCount)\t"
s += "\(vmConfig.memorySize / 1024 / 1024) MB\t"
s += "\(diskSize) GB\t"
s += "\(vmConfig.display.width)x\(vmConfig.display.height)"
print(s)
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb, Display: vmConfig.display.description, Running: running)
print(format.renderSingle(info))
}
}
+30 -23
View File
@@ -2,6 +2,7 @@ import ArgumentParser
import Foundation
import Network
import SystemConfiguration
import Sentry
struct IP: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
@@ -13,31 +14,37 @@ struct IP: AsyncParsableCommand {
var wait: UInt16 = 0
func run() async throws {
do {
let vmDir = try VMStorageLocal().open(name)
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
let vmDir = try VMStorageLocal().open(name)
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
print("no IP address found, is your VM running?")
Foundation.exit(1)
}
if let ipViaARP = try ARPCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
}
print(ipViaDHCP)
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
throw RuntimeError.NoIPAddressFound("no IP address found, is your VM running?")
}
let arpCache = try ARPCache()
if let ipViaARP = try arpCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
// Capture the warning into Sentry
SentrySDK.capture(message: "DHCP lease and ARP cache entries for a single MAC address differ") { scope in
scope.setLevel(.warning)
scope.setContext(value: [
"MAC address": vmMACAddress,
"IP via ARP": ipViaARP,
"IP via DHCP": ipViaDHCP,
], key: "Address conflict details")
scope.add(Attachment(path: "/var/db/dhcpd_leases", filename: "dhcpd_leases.txt", contentType: "text/plain"))
scope.add(Attachment(data: arpCache.arpCommandOutput, filename: "arp-an-output.txt", contentType: "text/plain"))
}
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
}
print(ipViaDHCP)
}
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
+51
View File
@@ -0,0 +1,51 @@
import ArgumentParser
import Foundation
struct Import: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Import VM from a file")
@Argument(help: "Path to a file created with \"tart export\".")
var path: String
@Argument(help: "Destination VM name.")
var name: String
func validate() throws {
if name.contains("/") {
throw ValidationError("<name> should be a local name")
}
}
func run() async throws {
let localStorage = VMStorageLocal()
// Create a temporary VM directory to which we will load the export file
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
// while we're running
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
// Populate the temporary VM directory with the export file contents
print("importing...")
try tmpVMDir.importFromArchive(path: path)
try await withTaskCancellationHandler(operation: {
// Acquire a global lock
let lock = try FileLock(lockURL: Config().tartHomeDir)
try lock.lock()
// Re-generate the VM's MAC address importing it will result in address collision
if try localStorage.hasVMsWithMACAddress(macAddress: tmpVMDir.macAddress()) {
try tmpVMDir.regenerateMACAddress()
}
try localStorage.move(name, from: tmpVMDir)
try lock.unlock()
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
}
}
+42 -21
View File
@@ -2,36 +2,57 @@ import ArgumentParser
import Dispatch
import SwiftUI
fileprivate struct VMInfo: Encodable {
let Source: String
let Name: String
let Size: Int
}
struct List: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "List created VMs")
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names"))
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
var source: String?
@Option(help: "Output format: text or json")
var format: Format = .text
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
var quiet: Bool = false
func run() async throws {
do {
if !quiet {
print("Source\tName")
}
func validate() throws {
guard let source = source else {
return
}
displayTable("local", try VMStorageLocal().list())
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
if !["local", "oci"].contains(source) {
throw ValidationError("'\(source)' is not a valid <source>")
}
}
private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) {
if quiet {
print(name)
} else {
print("\(source)\t\(name)")
}
func run() async throws {
var infos: [VMInfo] = []
if source == nil || source == "local" {
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB())
})
}
if source == nil || source == "oci" {
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB())
})
}
if (quiet) {
for info in infos {
print(info.Name)
}
} else {
print(format.renderList(infos))
}
}
private func sortedInfos(_ infos: [VMInfo]) -> [VMInfo] {
infos.sorted(by: { left, right in left.Name < right.Name })
}
}
+21 -31
View File
@@ -27,40 +27,30 @@ struct Login: AsyncParsableCommand {
}
func run() async throws {
do {
var user: String
var password: String
var user: String
var password: String
if let username = username {
user = username
if let username = username {
user = username
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
password = String(decoding: passwordData, as: UTF8.self)
} else {
(user, password) = try StdinCredentials.retrieve()
}
let credentialsProvider = DictionaryCredentialsProvider([
host: (user, password)
])
do {
let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProviders: [credentialsProvider])
try await registry.ping()
} catch {
print("invalid credentials: \(error)")
Foundation.exit(1)
}
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
password = String(decoding: passwordData, as: UTF8.self)
} else {
(user, password) = try StdinCredentials.retrieve()
}
let credentialsProvider = DictionaryCredentialsProvider([
host: (user, password)
])
do {
let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProviders: [credentialsProvider])
try await registry.ping()
} catch {
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
}
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
}
}
+16 -22
View File
@@ -1,5 +1,6 @@
import ArgumentParser
import Dispatch
import Sentry
import SwiftUI
import SwiftDate
@@ -26,29 +27,21 @@ struct Prune: AsyncParsableCommand {
}
func run() async throws {
do {
if gc {
try VMStorageOCI().gc()
}
if gc {
try VMStorageOCI().gc()
}
// Clean up cache entries based on last accessed date
if let olderThan = olderThan {
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
// Clean up cache entries based on last accessed date
if let olderThan = olderThan {
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
}
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
}
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
if let cacheBudget = cacheBudget {
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
}
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
if let cacheBudget = cacheBudget {
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
}
}
@@ -107,9 +100,10 @@ struct Prune: AsyncParsableCommand {
cacheReclaimedBytes += try prunable.sizeBytes()
try prunable.delete()
puppy.info("deleting \(prunable.url)...")
try SentrySDK.span?.setExtra(value: prunable.sizeBytes(), key: prunable.url.path);
}
puppy.info("reclaimed \(cacheReclaimedBytes) bytes")
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
}
}
+12 -20
View File
@@ -12,27 +12,19 @@ struct Pull: AsyncParsableCommand {
var insecure: Bool = false
func run() async throws {
do {
// Be more liberal when accepting local image as argument,
// see https://github.com/cirruslabs/tart/issues/36
if VMStorageLocal().exists(remoteName) {
print("\"\(remoteName)\" is a local image, nothing to pull here!")
// Be more liberal when accepting local image as argument,
// see https://github.com/cirruslabs/tart/issues/36
if VMStorageLocal().exists(remoteName) {
print("\"\(remoteName)\" is a local image, nothing to pull here!")
Foundation.exit(0)
}
let remoteName = try RemoteName(remoteName)
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
defaultLogger.appendNewLine("pulling \(remoteName)...")
try await VMStorageOCI().pull(remoteName, registry: registry)
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
return
}
let remoteName = try RemoteName(remoteName)
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
defaultLogger.appendNewLine("pulling \(remoteName)...")
try await VMStorageOCI().pull(remoteName, registry: registry)
}
}
+31 -39
View File
@@ -28,54 +28,46 @@ struct Push: AsyncParsableCommand {
var populateCache: Bool = false
func run() async throws {
do {
let localVMDir = try VMStorageLocal().open(localName)
let localVMDir = try VMStorageLocal().open(localName)
// Parse remote names supplied by the user
let remoteNames = try remoteNames.map{
try RemoteName($0)
}
// Parse remote names supplied by the user
let remoteNames = try remoteNames.map{
try RemoteName($0)
}
// Group remote names by registry
struct RegistryIdentifier: Hashable, Equatable {
var host: String
var namespace: String
}
// Group remote names by registry
struct RegistryIdentifier: Hashable, Equatable {
var host: String
var namespace: String
}
let registryGroups = Dictionary(grouping: remoteNames, by: {
RegistryIdentifier(host: $0.host, namespace: $0.namespace)
})
let registryGroups = Dictionary(grouping: remoteNames, by: {
RegistryIdentifier(host: $0.host, namespace: $0.namespace)
})
// Push VM
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
insecure: insecure)
// Push VM
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
insecure: insecure)
defaultLogger.appendNewLine("pushing \(localName) to "
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
defaultLogger.appendNewLine("pushing \(localName) to "
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
let pushedRemoteName = try await localVMDir.pushToRegistry(
registry: registry,
references: remoteNamesForRegistry.map{ $0.reference.value },
chunkSizeMb: chunkSize
)
let pushedRemoteName = try await localVMDir.pushToRegistry(
registry: registry,
references: remoteNamesForRegistry.map{ $0.reference.value },
chunkSizeMb: chunkSize
)
// Populate the local cache (if requested)
if populateCache {
let ociStorage = VMStorageOCI()
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
for remoteName in remoteNamesForRegistry {
try ociStorage.link(from: remoteName, to: pushedRemoteName)
}
// Populate the local cache (if requested)
if populateCache {
let ociStorage = VMStorageOCI()
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
for remoteName in remoteNamesForRegistry {
try ociStorage.link(from: remoteName, to: pushedRemoteName)
}
}
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
}
}
+9 -17
View File
@@ -17,24 +17,16 @@ struct Rename: AsyncParsableCommand {
}
func run() async throws {
do {
let localStorage = VMStorageLocal()
let localStorage = VMStorageLocal()
if !localStorage.exists(name) {
throw ValidationError("failed to rename a non-existent VM: \(name)")
}
if localStorage.exists(newName) {
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
}
try localStorage.rename(name, newName)
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
if !localStorage.exists(name) {
throw ValidationError("failed to rename a non-existent VM: \(name)")
}
if localStorage.exists(newName) {
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
}
try localStorage.rename(name, newName)
}
}
@@ -0,0 +1,25 @@
import ArgumentParser
import Foundation
import Sentry
struct ReportInstallation: AsyncParsableCommand {
static var configuration = CommandConfiguration(
commandName: "report-installation",
abstract: "Send installation event to Sentry if configured",
discussion: """
Reports macOS version and device model for analytics purposes.
Helps Cirrus Labs team to prioritize testing on most popular devices.
""",
shouldDisplay: false
)
func run() async throws {
let installationEvent = Event()
installationEvent.message = SentryMessage(formatted: "installed")
installationEvent.level = SentryLevel.info
installationEvent.user = nil
installationEvent.stacktrace = nil
let id = SentrySDK.capture(event: installationEvent)
print("Captured installation event #\(id)!")
}
}
+9 -13
View File
@@ -2,6 +2,7 @@ import ArgumentParser
import Dispatch
import SwiftUI
import Virtualization
import Sentry
var vm: VM?
@@ -37,9 +38,6 @@ struct Run: AsyncParsableCommand {
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
var vncExperimental: Bool = false
@Flag(help: ArgumentHelp(visibility: .private))
var withSoftnet: Bool = false
@Option(help: ArgumentHelp("""
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
""", discussion: """
@@ -87,11 +85,6 @@ struct Run: AsyncParsableCommand {
if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
}
if withSoftnet && netBridged != nil {
throw ValidationError("--with-softnet and --net-bridged are mutually exclusive")
}
if netBridged != nil && netSoftnet {
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
}
@@ -116,9 +109,8 @@ struct Run: AsyncParsableCommand {
}
if try !FileLock(lockURL: additionalDiskAttachment.url).trylock() {
print("disk \(additionalDiskAttachment.url.path) seems to be already in use, "
throw RuntimeError.DiskAlreadyInUse("disk \(additionalDiskAttachment.url.path) seems to be already in use, "
+ "unmount it first in Finder")
Foundation.exit(1)
}
}
@@ -154,8 +146,7 @@ struct Run: AsyncParsableCommand {
// [1]: https://man.openbsd.org/fcntl
let lock = try PIDLock(lockURL: vmDir.configURL)
if try !lock.trylock() {
print("Virtual machine \"\(name)\" is already running!")
Foundation.exit(2)
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
}
let task = Task {
@@ -179,7 +170,12 @@ struct Run: AsyncParsableCommand {
Foundation.exit(0)
} catch {
// Capture the error into Sentry
SentrySDK.capture(error: error)
SentrySDK.flush(timeout: 2.seconds.timeInterval)
print(error)
Foundation.exit(1)
}
}
@@ -199,7 +195,7 @@ struct Run: AsyncParsableCommand {
}
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
if withSoftnet || netSoftnet {
if netSoftnet {
let config = try VMConfig.init(fromURL: vmDir.configURL)
return try Softnet(vmMACAddress: config.macAddress.string)
+19 -27
View File
@@ -20,38 +20,30 @@ struct Set: AsyncParsableCommand {
var diskSize: UInt16?
func run() async throws {
do {
let vmDir = try VMStorageLocal().open(name)
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
let vmDir = try VMStorageLocal().open(name)
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
if let cpu = cpu {
try vmConfig.setCPU(cpuCount: Int(cpu))
if let cpu = cpu {
try vmConfig.setCPU(cpuCount: Int(cpu))
}
if let memory = memory {
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
}
if let display = display {
if (display.width > 0) {
vmConfig.display.width = display.width
}
if let memory = memory {
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
if (display.height > 0) {
vmConfig.display.height = display.height
}
}
if let display = display {
if (display.width > 0) {
vmConfig.display.width = display.width
}
if (display.height > 0) {
vmConfig.display.height = display.height
}
}
try vmConfig.save(toURL: vmDir.configURL)
try vmConfig.save(toURL: vmDir.configURL)
if diskSize != nil {
try vmDir.resizeDisk(diskSize!)
}
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
if diskSize != nil {
try vmDir.resizeDisk(diskSize!)
}
}
}
+36 -48
View File
@@ -13,60 +13,48 @@ struct Stop: AsyncParsableCommand {
var timeout: UInt64 = 30
func run() async throws {
do {
let vmDir = try VMStorageLocal().open(name)
let lock = try PIDLock(lockURL: vmDir.configURL)
let vmDir = try VMStorageLocal().open(name)
let lock = try PIDLock(lockURL: vmDir.configURL)
// Find the VM's PID
var pid = try lock.pid()
// Find the VM's PID
var pid = try lock.pid()
if pid == 0 {
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
}
// Try to gracefully terminate the VM
//
// Note that we don't check the return code here
// to provide a clean exit from "tart stop" in cases
// when the VM is already shutting down and we hit
// a race condition.
//
// We check the return code in the kill(2) below, though,
// because it's a less common scenario and it would be
// nice to know for the user that we've tried all methods
// and failed to shutdown the VM.
kill(pid, SIGINT)
// Ensure that the VM has terminated
var gracefulWaitDuration = Measurement(value: Double(timeout), unit: UnitDuration.seconds)
let gracefulTickDuration = Measurement(value: Double(100), unit: UnitDuration.milliseconds)
while gracefulWaitDuration.value > 0 {
pid = try lock.pid()
if pid == 0 {
print("VM \(name) is not running")
Foundation.exit(2)
return
}
// Try to gracefully terminate the VM
//
// Note that we don't check the return code here
// to provide a clean exit from "tart stop" in cases
// when the VM is already shutting down and we hit
// a race condition.
//
// We check the return code in the kill(2) below, though,
// because it's a less common scenario and it would be
// nice to know for the user that we've tried all methods
// and failed to shutdown the VM.
kill(pid, SIGINT)
try await Task.sleep(nanoseconds: UInt64(gracefulTickDuration.converted(to: .nanoseconds).value))
gracefulWaitDuration = gracefulWaitDuration - gracefulTickDuration
}
// Ensure that the VM has terminated
var gracefulWaitDuration = Measurement(value: Double(timeout), unit: UnitDuration.seconds)
let gracefulTickDuration = Measurement(value: Double(100), unit: UnitDuration.milliseconds)
// Seems that VM is still running, proceed with forceful termination
let ret = kill(pid, SIGKILL)
if ret != 0 {
let details = Errno(rawValue: CInt(errno))
while gracefulWaitDuration.value > 0 {
pid = try lock.pid()
if pid == 0 {
Foundation.exit(0)
}
try await Task.sleep(nanoseconds: UInt64(gracefulTickDuration.converted(to: .nanoseconds).value))
gracefulWaitDuration = gracefulWaitDuration - gracefulTickDuration
}
// Seems that VM is still running, proceed with forceful termination
let ret = kill(pid, SIGKILL)
if ret != 0 {
let details = Errno(rawValue: CInt(errno))
print("failed to forcefully terminate the VM \(name): \(details)")
Foundation.exit(1)
}
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
throw RuntimeError.VMTerminationFailed("failed to forcefully terminate the VM \"\(name)\": \(details)")
}
}
}
+41
View File
@@ -0,0 +1,41 @@
import ArgumentParser
import Foundation
import TextTable
enum Format: String, ExpressibleByArgument, CaseIterable {
case text, json
private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"}
func renderSingle<T>(_ data: T) -> String where T: Encodable {
switch self {
case .text:
return renderList([data])
case .json:
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return try! encoder.encode(data).asText()
}
}
func renderList<T>(_ data: Array<T>) -> String where T: Encodable {
switch self {
case .text:
if (data.count == 0) {
return ""
}
let table = TextTable<T> { (item: T) in
let mirroredObject = Mirror(reflecting: item)
return mirroredObject.children.enumerated().map { (_, element) in
let fieldName = element.label!
return Column(title: fieldName, value: element.value)
}
}
return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
case .json:
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return try! encoder.encode(data).asText()
}
}
}
+10 -3
View File
@@ -39,7 +39,9 @@ struct ARPCacheInternalError: Error, CustomStringConvertible {
}
struct ARPCache {
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
let arpCommandOutput: Data
init() throws {
let process = Process.init()
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
process.arguments = ["-an"]
@@ -58,10 +60,15 @@ struct ARPCache {
terminationStatus: process.terminationStatus)
}
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
}
let lines = String(decoding: rawLines, as: UTF8.self)
self.arpCommandOutput = arpCommandOutput
}
func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
let lines = String(decoding: arpCommandOutput, as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: "\n")
+7 -3
View File
@@ -69,15 +69,19 @@ class Softnet: Network {
}
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
var option_value = sizeBytes
let option_len = socklen_t(MemoryLayout<Int>.size)
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &option_value, option_len)
// The system expects the value of SO_RCVBUF to be at least double the value of SO_SNDBUF,
// and for optimal performance, the recommended value of SO_RCVBUF is four times the value of SO_SNDBUF.
// See: https://developer.apple.com/documentation/virtualization/vzfilehandlenetworkdeviceattachment/3969266-maximumtransmissionunit
var receiveBufferSize = 4 * sizeBytes
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &receiveBufferSize, option_len)
if ret != 0 {
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)")
}
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &option_value, option_len)
var sendBufferSize = sizeBytes
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sendBufferSize, option_len)
if ret != 0 {
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
}
+2 -2
View File
@@ -106,7 +106,7 @@ struct RemoteName: Comparable, Hashable, CustomStringConvertible {
try ParseTreeWalker().walk(referenceCollector, try parser.root())
if let error = errorCollector.error {
throw RuntimeError("failed to parse remote name: \(error)")
throw RuntimeError.FailedToParseRemoteName("\(error)")
}
host = referenceCollector.host!
@@ -120,7 +120,7 @@ struct RemoteName: Comparable, Hashable, CustomStringConvertible {
} else if reference.starts(with: ":") {
self.reference = Reference(tag: String(reference.dropFirst(1)))
} else {
throw RuntimeError("failed to parse remote name: unknown reference format")
throw RuntimeError.FailedToParseRemoteName("unknown reference format")
}
} else {
self.reference = Reference(tag: "latest")
+1 -1
View File
@@ -44,7 +44,7 @@ class PIDLock {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError("\(message): \(details)")
throw RuntimeError.PIDLockFailed("\(message): \(details)")
}
return (true, result)
+60 -16
View File
@@ -1,14 +1,7 @@
import ArgumentParser
import Darwin
import Foundation
import Puppy
var puppy = Puppy.default
class LogFormatter: LogFormattable {
func formatMessage(_ level: LogLevel, message: String, tag: String, function: String, file: String, line: UInt, swiftLogInfo: [String: String], label: String, date: Date, threadID: UInt64) -> String {
"\(date) \(level) \(message)"
}
}
import Sentry
@main
struct Root: AsyncParsableCommand {
@@ -26,13 +19,45 @@ struct Root: AsyncParsableCommand {
IP.self,
Pull.self,
Push.self,
Import.self,
Export.self,
Prune.self,
Rename.self,
Stop.self,
Delete.self,
ReportInstallation.self,
])
public static func main() async throws {
// Initialize Sentry
if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
SentrySDK.start { options in
options.dsn = dsn
options.releaseName = CI.release
options.tracesSampleRate = Float(
ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
) as NSNumber?
// By default only 5XX are captured
// Let's capture everything but 401 (unauthorized)
options.enableCaptureFailedRequests = true
options.failedRequestStatusCodes = [
HttpStatusCodeRange(min: 400, max: 400),
HttpStatusCodeRange(min: 402, max: 599)
]
}
}
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
// Enrich future events with Cirrus CI-specific tags
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
SentrySDK.configureScope { scope in
for (key, value) in tags.split(separator: ",").compactMap({ parseCirrusSentryTag($0) }) {
scope.setTag(value: value, key: key)
}
}
}
// Ensure the default SIGINT handled is disabled,
// otherwise there's a race between two handlers
signal(SIGINT, SIG_IGN);
@@ -47,18 +72,16 @@ struct Root: AsyncParsableCommand {
// Set line-buffered output for stdout
setlinebuf(stdout)
// Initialize file logger
let logFileURL = try Config().tartHomeDir.appendingPathComponent("tart.log")
let fileLogger = try FileLogger("org.cirruslabs.tart", fileURL: logFileURL)
fileLogger.format = LogFormatter()
puppy.add(fileLogger)
// Parse and run command
do {
var command = try parseAsRoot()
// Run garbage-collection before each command (shouldn't take too long)
try Config().gc()
do {
try Config().gc()
} catch {
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
}
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
@@ -66,7 +89,28 @@ struct Root: AsyncParsableCommand {
try command.run()
}
} catch {
// Capture the error into Sentry
SentrySDK.capture(error: error)
SentrySDK.flush(timeout: 2.seconds.timeInterval)
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
if let errorWithExitCode = error as? HasExitCode {
print(error)
Foundation.exit(errorWithExitCode.exitCode)
}
// Handle any other exception, including ArgumentParser's ones
exit(withError: error)
}
}
private static func parseCirrusSentryTag(_ tag: String.SubSequence) -> (String, String)? {
let splits = tag.split(separator: "=", maxSplits: 1)
if splits.count != 2 {
return nil
}
return (String(splits[0]), String(splits[1]))
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ extension URL {
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
let ret = utimes(path, times)
if ret != 0 {
throw RuntimeError("utimes(2) failed: \(ret.explanation())")
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(ret.explanation())")
}
}
}
+6
View File
@@ -35,6 +35,12 @@ struct VMDisplayConfig: Codable {
var height: Int = 768
}
extension VMDisplayConfig: CustomStringConvertible {
var description: String {
"\(width)x\(height)"
}
}
struct VMConfig: Codable {
var version: Int = 1
var os: OS
+95
View File
@@ -0,0 +1,95 @@
import System
import AppleArchive
fileprivate let permissions = FilePermissions(rawValue: 0o644)
// Compresses VMDirectory using Apple's proprietary archive format[1] and LZFSE compression,
// which is recommended on Apple platforms[2].
//
// [1]: https://developer.apple.com/documentation/accelerate/compressing_file_system_directories
// [2]: https://developer.apple.com/documentation/compression/algorithm/lzfse
extension VMDirectory {
func exportToArchive(path: String) throws {
guard let fileStream = ArchiveByteStream.fileStream(
path: FilePath(path),
mode: .writeOnly,
options: [.create, .truncate],
permissions: permissions
) else {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.ExportFailed("ArchiveByteStream.fileStream() failed: \(details)")
}
defer {
try? fileStream.close()
}
guard let compressionStream = ArchiveByteStream.compressionStream(
using: .lzfse,
writingTo: fileStream
) else {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.ExportFailed("ArchiveByteStream.compressionStream() failed: \(details)")
}
defer {
try? compressionStream.close()
}
guard let encodeStream = ArchiveStream.encodeStream(writingTo: compressionStream) else {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.ExportFailed("ArchiveStream.encodeStream() failed: \(details)")
}
defer {
try? encodeStream.close()
}
guard let keySet = ArchiveHeader.FieldKeySet("TYP,PAT,LNK,DEV,DAT,UID,GID,MOD,FLG,MTM,BTM,CTM") else {
return
}
try encodeStream.writeDirectoryContents(archiveFrom: FilePath(baseURL.path), keySet: keySet)
}
func importFromArchive(path: String) throws {
guard let fileStream = ArchiveByteStream.fileStream(path: FilePath(path), mode: .readOnly, options: [],
permissions: permissions) else {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.ImportFailed("ArchiveByteStream.fileStream() failed: \(details)")
}
defer {
try? fileStream.close()
}
guard let decompressionStream = ArchiveByteStream.decompressionStream(readingFrom: fileStream) else {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.ImportFailed("ArchiveByteStream.decompressionStream() failed: \(details)")
}
defer {
try? decompressionStream.close()
}
guard let decodeStream = ArchiveStream.decodeStream(readingFrom: decompressionStream) else {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.ImportFailed("ArchiveStream.decodeStream() failed: \(details)")
}
defer {
try? decodeStream.close()
}
guard let extractStream = ArchiveStream.extractStream(extractingTo: FilePath(baseURL.path)) else {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.ImportFailed("ArchiveStream.extractStream() failed: \(details)")
}
defer {
try? extractStream.close()
}
_ = try ArchiveStream.process(readingFrom: decodeStream, writingTo: extractStream)
}
}
+2
View File
@@ -1,5 +1,6 @@
import Foundation
import Compression
import Sentry
enum OCIError: Error {
case ShouldBeExactlyOneLayer
@@ -77,6 +78,7 @@ extension VMDirectory {
}
try filter.finalize()
try disk.close()
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte);
// Pull VM's NVRAM file layer and store it in an NVRAM file
defaultLogger.appendNewLine("pulling NVRAM...")
+20 -6
View File
@@ -41,7 +41,7 @@ struct VMDirectory: Prunable {
func initialize(overwrite: Bool = false) throws {
if !overwrite && initialized {
throw RuntimeError("VM directory is already initialized, preventing overwrite")
throw RuntimeError.VMDirectoryAlreadyInitialized("VM directory is already initialized, preventing overwrite")
}
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
@@ -53,11 +53,11 @@ struct VMDirectory: Prunable {
func validate() throws {
if !FileManager.default.fileExists(atPath: baseURL.path) {
throw RuntimeError("the specified VM does not exist")
throw RuntimeError.VMDoesNotExist(name: baseURL.lastPathComponent)
}
if !initialized {
throw RuntimeError("VM is missing some of its files (\(configURL.lastPathComponent),"
throw RuntimeError.VMMissingFiles("VM is missing some of its files (\(configURL.lastPathComponent),"
+ " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))")
}
}
@@ -68,11 +68,21 @@ struct VMDirectory: Prunable {
try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
// Re-generate MAC address
var newVMConfig = try VMConfig(fromURL: to.configURL)
if generateMAC {
newVMConfig.macAddress = VZMACAddress.randomLocallyAdministered()
try to.regenerateMACAddress()
}
try newVMConfig.save(toURL: to.configURL)
}
func macAddress() throws -> String {
try VMConfig(fromURL: configURL).macAddress.string
}
func regenerateMACAddress() throws {
var vmConfig = try VMConfig(fromURL: configURL)
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
try vmConfig.save(toURL: configURL)
}
func resizeDisk(_ sizeGB: UInt16) throws {
@@ -97,6 +107,10 @@ struct VMDirectory: Prunable {
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
}
func sizeGB() throws -> Int {
try sizeBytes() / 1000 / 1000 / 1000
}
func markExplicitlyPulled() {
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
}
+76 -8
View File
@@ -26,7 +26,7 @@ class VMStorageHelper {
return try closure()
} catch {
if error.isFileNotFound() {
throw RuntimeError("source VM \"\(name)\" not found, is it listed in \"tart list\"?")
throw RuntimeError.VMDoesNotExist(name: name)
}
throw error
@@ -40,14 +40,82 @@ extension Error {
}
}
class RuntimeError: Error, CustomStringConvertible {
let message: String
enum RuntimeError : Error {
case VMDoesNotExist(name: String)
case VMMissingFiles(_ message: String)
case VMNotRunning(_ message: String)
case VMAlreadyRunning(_ message: String)
case NoIPAddressFound(_ message: String)
case DiskAlreadyInUse(_ message: String)
case FailedToUpdateAccessDate(_ message: String)
case PIDLockFailed(_ message: String)
case FailedToParseRemoteName(_ message: String)
case VMTerminationFailed(_ message: String)
case InvalidCredentials(_ message: String)
case VMDirectoryAlreadyInitialized(_ message: String)
case ExportFailed(_ message: String)
case ImportFailed(_ message: String)
}
init(_ message: String) {
self.message = message
}
protocol HasExitCode {
var exitCode: Int32 { get }
}
var description: String {
message
extension RuntimeError : CustomStringConvertible {
public var description: String {
switch self {
case .VMDoesNotExist(let name):
return "the specified VM \"\(name)\" does not exist"
case .VMMissingFiles(let message):
return message
case .VMNotRunning(let message):
return message
case .VMAlreadyRunning(let message):
return message
case .NoIPAddressFound(let message):
return message
case .DiskAlreadyInUse(let message):
return message
case .FailedToUpdateAccessDate(let message):
return message
case .PIDLockFailed(let message):
return message
case .FailedToParseRemoteName(let cause):
return "failed to parse remote name: \(cause)"
case .VMTerminationFailed(let message):
return message
case .InvalidCredentials(let message):
return message
case .VMDirectoryAlreadyInitialized(let message):
return message
case .ExportFailed(let message):
return "VM export failed: \(message)"
case .ImportFailed(let message):
return "VM import failed: \(message)"
}
}
}
extension RuntimeError : HasExitCode {
var exitCode: Int32 {
switch self {
case .VMNotRunning:
return 2
case .VMAlreadyRunning:
return 2
default:
return 1
}
}
}
// Customize error description for Sentry[1]
//
// [1]: https://docs.sentry.io/platforms/apple/guides/ios/usage/#customizing-error-descriptions
extension RuntimeError : CustomNSError {
var errorUserInfo: [String : Any] {
[
NSDebugDescriptionErrorKey: description,
]
}
}
+4
View File
@@ -62,4 +62,8 @@ class VMStorageLocal {
throw error
}
}
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
try list().contains { try $1.macAddress() == macAddress }
}
}
+22 -9
View File
@@ -1,4 +1,5 @@
import Foundation
import Sentry
class VMStorageOCI: PrunableStorage {
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
@@ -148,6 +149,7 @@ class VMStorageOCI: PrunableStorage {
}
if !exists(digestName) {
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
@@ -164,16 +166,25 @@ class VMStorageOCI: PrunableStorage {
let availableCapacityBytes = max(UInt64(capacityImportant), UInt64(capacityAvailable))
if capacityImportant == 0 || capacityAvailable == 0 {
puppy.warning("important capacity \(capacityImportant) bytes, "
+ "available capacity is \(capacityAvailable) bytes")
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's validate to avoid unnecessary pruning.
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
puppy.info("pruning cache to accommodate \(name) with a disk of size \(uncompressedDiskSize) bytes ("
+ "available capacity is \(availableCapacityBytes) bytes, required capacity "
+ "is \(requiredCapacityBytes) bytes)")
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)
}
@@ -182,7 +193,9 @@ class VMStorageOCI: PrunableStorage {
try await withTaskCancellationHandler(operation: {
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
try move(digestName, from: tmpVMDir)
transaction.finish()
}, onCancel: {
transaction.finish(status: SentrySpanStatus.cancelled)
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
} else {
@@ -191,7 +204,7 @@ class VMStorageOCI: PrunableStorage {
if name != digestName {
// Create new or overwrite the old symbolic link
try link(from: digestName, to: name)
try link(from: name, to: digestName)
} else {
// Ensure that images pulled by content digest
// are excluded from garbage collection
@@ -200,11 +213,11 @@ class VMStorageOCI: PrunableStorage {
}
func link(from: RemoteName, to: RemoteName) throws {
if FileManager.default.fileExists(atPath: vmURL(to).path) {
try FileManager.default.removeItem(at: vmURL(to))
if FileManager.default.fileExists(atPath: vmURL(from).path) {
try FileManager.default.removeItem(at: vmURL(from))
}
try FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
try gc()
}
+2
View File
@@ -0,0 +1,2 @@
tart.run
www.tart.run
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="146px" height="165px" viewBox="0 0 146 165" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>cirrus-logo</title>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round">
<g id="cirrus-logo" transform="translate(7.000000, 7.000000)" stroke="#333333" stroke-width="13.5">
<path d="M0,126.494118 L111,126.494118" id="Shape" fill="#000000" fill-rule="nonzero"></path>
<path d="M111,126.494118 C122.59798,126.494118 132,117.055227 132,105.411765 C132,93.7683027 122.59798,84.3294118 111,84.3294118" id="Shape"></path>
<path d="M0,84.3294118 L111,84.3294118" id="Shape" fill="#000000" fill-rule="nonzero"></path>
<path d="M111,84.3294118 C122.59798,84.3294118 132,74.8905208 132,63.2470588 C132,51.6035968 122.59798,42.1647059 111,42.1647059" id="Shape"></path>
<path d="M0,42.1647059 L111,42.1647059" id="Shape" fill="#000000" fill-rule="nonzero"></path>
<path d="M111,42.1647059 C122.59798,42.1647057 132,32.7258148 132,21.0823529 C132,9.43889104 122.59798,1.73501101e-07 111,-3.55271368e-15" id="Shape"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

+66
View File
@@ -0,0 +1,66 @@
---
hide:
- navigation
---
# Cirrus CLI
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
also developed by Cirrus Labs. [Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is a command line tool with
one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
We built Cirrus CLI to solve "But it works on my machine!" problem.
Here is an example of a `.cirrus.yml` configuration file which will start a Tart VM, will copy over working directory and
will run scripts and [other instructions](https://cirrus-ci.org/guide/writing-tasks/#supported-instructions) inside the virtual machine:
```yaml
task:
name: hello
macos_instance:
# can be a remote or a local virtual machine
image: ghcr.io/cirruslabs/macos-monterey-base:latest
hello_script:
- echo "Hello from within a Tart VM!"
- echo "Here is my CPU info:"
- sysctl -n machdep.cpu.brand_string
- sleep 15
```
Put the above `.cirrus.yml` file in the root of your repository and run it with the following command:
```bash
brew install cirruslabs/cli/cirrus
cirrus run
```
![](/assets/images/TartCirrusCLI.gif)
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
## Retrieving artifacts from within Tart VMs
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tasks/#artifacts-instruction):
```yaml
task:
name: Build
macos_instance:
image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
build_script: swift build --product tart
binary_artifacts:
path: .build/debug/tart
```
Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
```bash
cirrus run --artifacts-dir artifacts
```
Note that all retrieved artifacts will be prefixed with the associated task name and `artifacts` instruction name.
For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/binary/.build/debug/tart`.
+56
View File
@@ -0,0 +1,56 @@
---
hide:
- navigation
---
## How Tart is different from Anka?
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
Instead of Anka Registry, Tart can work with any OCI-compatible container registry. This provides a much more consistent
and scalable experience for distributing virtual machines.
Tart doesn't yet have an analogue of Anka Controller for managing long living VMs but [soon will be](https://github.com/cirruslabs/tart/issues/372).
## VM location on disk
Tart stores all it's files in `~/.tart/` directory. Local images that you can run are stored in `~/.tart/vms/`.
Remote images are pulled into `~/.tart/cache/OCIs/`.
## Nested virtualization support?
Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
doesn't support nested virtualization.
## Connecting to a service running on host
To connect from within a virtual machine to a service running on the host machine
please first make sure that the service is binded to `0.0.0.0`.
Then from within a virtual machine you can access the service using the router's IP address that you can get either from `Preferences -> Network`
or by running the following command in the Terminal:
```bash
netstat -nr | grep default | head -n 1 | awk '{print $2}'
```
Note: that accessing host is only possible with the default NAT network. If you are running your virtual machines with
[Softnet](https://github.com/cirruslabs/softnet) (via `tart run --net-softnet <VM NAME>)`, then the network isolation
is stricter and it's not only possible to access the host.
## Changing the default NAT subnet
To change the default network to `192.168.77.1`:
```bash
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Address -string 192.168.77.1
```
Note that even through a network would normally be specified as `192.168.77.0`, the [vmnet framework](https://developer.apple.com/documentation/vmnet) seems to treat this as a starting address too and refuses to pick up such network-like values.
The default subnet mask `255.255.255.0` should suffice for most use-cases, however, you can also change it to `255.255.0.0`, for example:
```bash
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
```
+26
View File
@@ -0,0 +1,26 @@
---
hide:
- navigation
---
# GitHub Actions
Tart already powers several CI services mentioned above including our own [Cirrus CI](https://cirrus-ci.org/guide/macOS/) which offers unlimited concurrency with per-second billing.
For services that haven't leveraged Tart yet, we offer fully managed runners via a monthly subscription.
*Cirrus Runners* is the fastest way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
## Configuring Cirrus Runners
Configuring Cirrus Runners for GitHub Actions is as simple as installing [Cirrus Runners App](https://github.com/apps/cirrus-runners).
After successful installation and subscription configuration, use any of [Ventura images managed by us](https://github.com/cirruslabs/macos-image-templates) in `runs-on`:
```yaml
name: Test Suite
jobs:
test:
runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest
```
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`.
![](/assets/images/TartGHARunners.png)
+4
View File
@@ -0,0 +1,4 @@
---
template: overrides/home.html
title: Tart
---
+69
View File
@@ -0,0 +1,69 @@
---
hide:
- navigation
---
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
```bash
brew install cirruslabs/cli/tart
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
tart run ventura-base
```
<p align="center">
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartScreenshot.png"/>
</p>
## SSH access
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
```bash
ssh admin@$(tart ip macos-monterey-base)
```
## Mounting directories
To mount a directory, run the VM with the `--dir` argument:
```bash
tart run --dir=project:~/src/project vm
```
Here, the `project` specifies a mount name, whereas the `~/src/project` is a path to the host's directory to expose to the VM.
It is also possible to mount directories in read-only mode by adding a third parameter, `ro`:
```bash
tart run --dir=project:~/src/project:ro vm
```
To mount multiple directories, repeat the `--dir` argument for each directory:
```bash
tart run --dir=www1:~/project1/www --dir=www2:~/project2/www
```
Note that the first parameter in each `--dir` argument must be unique, otherwise only the last `--dir` argument using that name will be used.
Note: to use the directory mounting feature, the host needs to run macOS 13.0 (Ventura) or newer.
### Accessing mounted directories in macOS guests
All shared directories are automatically mounted to `/Volumes/My Shared Files` directory.
The directory we've mounted above will be accessible from the `/Volumes/My Shared Files/project` path inside a guest VM.
Note: to use the directory mounting feature, the guest VM needs to run macOS 13.0 (Ventura) or newer.
### 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:
```bash
mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
```
The directory we've mounted above will be accessible from the `/mnt/shared/project` path inside a guest VM.
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: *
Disallow:
Sitemap: https://tart.run/sitemap.xml
+34
View File
@@ -0,0 +1,34 @@
/* Remove default title on the page */
.md-content__inner h1:first-child {
display: none;
}
/* Adjust to 2px to align with the title */
.md-logo {
padding-top: 6px;
}
.btn {
border: none;
padding: 14px 28px;
cursor: pointer;
display: inline-block;
background: #009688;
color: white;
}
.btn:hover {
background: #00bfa5;
color: white;
}
.center {
display: block;
margin-left: auto;
margin-right: auto;
}
.text-center {
text-align: center;
}
+291
View File
@@ -0,0 +1,291 @@
.tx-container {
background: linear-gradient(
to bottom,
var(--md-primary-fg-color),
var(--md-default-bg-color) 100%
);
}
[data-md-color-scheme="slate"] .tx-container {
background: linear-gradient(
to bottom,
var(--md-primary-fg-color),
var(--md-default-bg-color) 100%
);
}
.tx-landing {
margin: 0 0.8rem;
color: var(--md-primary-bg-color);
}
.tx-landing__logos {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
.tx-landing__quote {
display: flex;
border-radius: 1em;
padding: 1em 1em 5em 1em;
text-align: center;
background: var(--md-primary-fg-color);
}
.tx-landing__quote blockquote {
border: 0;
color: #fff;
}
.tx-landing__quotes figure {
margin: 2em auto 2em auto;
}
.tx-landing__logos img {
height: 8vh;
max-height: 81px; /* max height of images */
width: auto;
margin: 2vh;
vertical-align: middle;
}
.tx-landing__quote a img {
height: 6vh;
max-height: 81px; /* max height of images */
display: block;
margin-left: auto;
margin-right: auto;
}
.tx-landing__content p a {
color: inherit;
text-decoration: underline;
}
.tx-landing__content p a:hover {
color: darkblue;
text-decoration: underline;
}
.tx-landing .md-button {
margin-top: 0.5rem;
margin-right: 0.5rem;
color: var(--md-primary-bg-color);
}
.tx-landing .md-button:hover,
.tx-landing .md-button:focus {
color: var(--md-default-bg-color);
background-color: var(--md-default-fg-color);
border-color: var(--md-default-fg-color);
}
.tx-landing__testimonials {
width: 100%;
text-align: center;
}
.tx-landing h1 {
margin-bottom: 1rem;
color: currentColor;
font-weight: 700;
}
.md-typeset h2 + h3 {
font-size: 1em;
margin-top: -0.8em;
}
.md-typeset figure {
display: flex;
}
.md-content header {
display: block;
}
.mdx-spotlight {
margin: 2em 0;
}
.mdx-spotlight__feature {
display: flex;
flex: 1 0 48%;
flex-flow: row nowrap;
gap: 3.2rem;
margin: 0 0 3.2rem;
}
.mdx-spotlight__feature:last-child {
margin-bottom: 1em;
}
.mdx-spotlight__feature > img {
display: block;
flex-shrink: 0;
border-radius: 0.2rem;
box-shadow: var(--md-shadow-z2);
width: 25rem;
max-width: 100%;
}
.mdx-spotlight__feature figcaption {
margin-top: 0.8rem;
}
.mdx-parallax__group {
background-color: var(--md-default-bg-color);
color: var(--md-typeset-color);
display: block;
position: relative;
transform-style: preserve-3d;
}
.mdx-parallax__group:first-child {
background-color: initial;
contain: strict;
height: 140vh;
}
.mdx-parallax__group:last-child {
background-color: var(--md-default-bg-color);
}
.mdx-users {
display: flex;
gap: 3.2rem;
margin: 2.4rem 0;
}
.mdx-users__testimonial {
display: flex;
flex: 1;
flex-direction: column;
gap: 1.2rem;
margin: 0;
text-align: center;
}
.mdx-users__testimonial img {
border-radius: 5rem;
height: auto;
margin-left: auto;
margin-right: auto;
width: 10rem;
}
.mdx-users__testimonial figcaption {
display: block;
}
.mdx-users__testimonial hr {
margin-left: auto;
margin-right: auto;
width: 5rem;
}
.mdx-users__testimonial cite {
display: block;
-webkit-hyphens: auto;
hyphens: auto;
text-align: justify;
}
/* General media */
@media screen and (max-width: 30em) {
.tx-landing h1 {
font-size: 1.4rem;
}
}
@media screen and (max-width: 59.9375em) {
.mdx-spotlight__feature {
flex-direction: column;
gap: 0;
}
.mdx-spotlight__feature > img {
margin-left: auto;
margin-right: auto;
height: auto;
}
.mdx-users {
flex-direction: column;
}
/* Reset one padding between sections */
.md-content__inner-testimonials {
padding: 0px 0px 2.2rem !important;
}
}
@media screen and (min-width: 60em) {
.tx-container {
padding-bottom: 7vw;
}
.tx-landing {
display: flex;
align-items: stretch;
height: 85%;
}
.tx-landing__content {
align-self: center;
max-width: 19rem;
margin-top: 3.5rem;
}
.tx-landing__image {
order: 1;
width: 38rem;
}
.tx-landing__quotes {
margin: 1em 5em;
}
.mdx-spotlight__feature:nth-child(odd) {
flex-direction: row-reverse;
}
}
/* Extra media for .mdx-parallax__group:first-child */
@media (min-width: 125vh) {
.mdx-parallax__group:first-child {
height: 120vw;
}
}
@media (min-width: 137.5vh) {
.mdx-parallax__group:first-child {
height: 125vw;
}
}
@media (min-width: 150vh) {
.mdx-parallax__group:first-child {
height: 130vw;
}
}
@media (min-width: 162.5vh) {
.mdx-parallax__group:first-child {
height: 135vw;
}
}
@media (min-width: 175vh) {
.mdx-parallax__group:first-child {
height: 140vw;
}
}
@media (min-width: 187.5vh) {
.mdx-parallax__group:first-child {
height: 145vw;
}
}
@media (min-width: 200vh) {
.mdx-parallax__group:first-child {
height: 150vw;
}
}
+281
View File
@@ -0,0 +1,281 @@
{% extends "base.html" %}
<!-- Render landing page under tabs -->
{% block tabs %} {{ super() }}
<!-- Additional styles for landing page -->
<style>
body {
overflow-x: hidden;
}
.md-content__inner {
margin-bottom: 0;
padding: 2.2rem 0;
}
.md-content__inner:before {
display: none;
}
/* Application header should be static for the landing page */
.md-header {
position: initial;
}
/* Remove spacing, as we cannot hide it completely */
.md-main__inner {
margin: 0;
}
/* Hide sidebar, preventing unnecessary margins on the page */
.md-main__inner > .md-content,
.md-main__inner > .md-sidebar--secondary {
display: none;
}
/* Prevent removing default title on the page */
.md-content__inner h1:first-child {
display: block;
}
.tx-landing__image {
margin-top: 45px;
}
/* Prevent layout shift after image loading */
.tx-landing__image dotlottie-player {
aspect-ratio: 1.66;
}
@media (max-width: 959px) {
.tx-landing__image {
margin-bottom: 10px;
}
}
@media (max-width: 600px) {
.md-typeset .headerlink {
display: none;
}
}
/* Hide table of contents */
@media screen and (min-width: 60em) {
.md-sidebar--secondary {
display: none;
}
}
/* Hide navigation */
@media screen and (min-width: 76.25em) {
.md-sidebar--primary {
display: none;
}
}
</style>
<!-- landing page for landing page -->
<!-- Hero -->
<section class="tx-container">
<div class="md-grid md-typeset">
<div class="tx-landing">
<!-- landing image -->
<div class="tx-landing__image">
<script src="https://unpkg.com/@dotlottie/player-component@latest/dist/dotlottie-player.js"></script>
<dotlottie-player
src="/assets/animations/TartLogo.lottie"
mode="normal"
style="width: 75%; margin: auto"
autoplay
/>
</div>
<!-- landing content -->
<div class="tx-landing__content">
<h2>
<strong>Tart</strong> is a virtualization toolset to build, run and
manage <i>macOS</i> and <i>Linux</i> virtual machines on
<i>Apple Silicon.</i>
</h2>
<a href="/quick-start" title="Quick Start" class="md-button">
Learn More
</a>
</div>
</div>
</div>
</section>
<!-- Spotlights -->
<section class="mdx-parallax__group" data-md-color-scheme="default">
<div class="md-content md-grid" data-md-component="content">
<div class="md-content__inner">
<header class="md-typeset">
<h1 id="virtualization-and-beyond">
Virtualization and beyond
<a
href="#virtualization-and-beyond"
class="headerlink"
title="Permanent link"
>
</a>
</h1>
</header>
<div class="mdx-spotlight">
<figure class="mdx-spotlight__feature">
<img
src="assets/images/spotlight/virtualization-framework.png"
alt="Apple’s native Virtualization.Framework"
loading="lazy"
width="500"
height="212"
/>
<figcaption class="md-typeset">
<h2>Native performance</h2>
<p>
Tart is&nbsp;using Apple&rsquo;s native
<i>Virtualization.Framework</i> that was developed along with
architecting the first M1&nbsp;chip. This seamless integration
between hardware and software ensures smooth performance without
any drawbacks.
</p>
</figcaption>
</figure>
<figure class="mdx-spotlight__feature">
<img
src="assets/images/spotlight/supported-registries.png"
alt="OCI-compatible container registries"
loading="lazy"
width="500"
height="160"
/>
<figcaption class="md-typeset">
<p>
For storing virtual machine images Tart integrates with
OCI-compatible container registries. Work with virtual machines as
you used to&nbsp;with Docker containers.
</p>
</figcaption>
</figure>
<figure class="mdx-spotlight__feature">
<img
src="assets/images/spotlight/github-actions runners.png"
alt="GitHub Actions Runners"
loading="lazy"
width="500"
height="280"
/>
<figcaption class="md-typeset">
<p>
Tart powers several continuous integration systems including
<a href="/github-actions"
>on&#8209;demand GitHub Actions Runners</a
>
and
<a href="https://cirrus-ci.org/guide/macOS/" target="_blank"
>Cirrus&nbsp;CI</a
>. Double the performance of&nbsp;your macOS actions with
a&nbsp;couple lines of&nbsp;code.
</p>
</figcaption>
</figure>
</div>
</div>
</div>
</section>
<!-- Testimonials -->
<section class="mdx-parallax__group" data-md-color-scheme="default">
<div class="md-content md-grid" data-md-component="content">
<div class="md-content__inner md-content__inner-testimonials">
<header class="md-typeset">
<h1 id="what-our-users-say">
What our users say
<a
href="#what-our-users-say"
class="headerlink"
title="Permanent link"
>
</a>
</h1>
</header>
<div class="mdx-users">
<figure class="mdx-users__testimonial">
<img
src="assets/images/users/seb-jachec.jpg"
alt="Sebastian Jachec"
loading="lazy"
width="200"
height="200"
/>
<figcaption class="md-typeset">
<h2>Sebastian Jachec</h2>
<h3>
Mobile Engineer at
<a href="https://daybridge.com/" target="_blank">Daybridge</a>
</h3>
<hr />
<cite>
It&rsquo;s been plain-sailing with the
<a href="/github-actions">Cirrus Runners</a>&nbsp;&mdash;
they&rsquo;ve been great! They&rsquo;re consistently&nbsp;60+%
faster on&nbsp;workflows that we&nbsp;previously used Github
Actions&rsquo; macOS runners for.
</cite>
</figcaption>
</figure>
<figure class="mdx-users__testimonial">
<img
src="assets/images/users/mikhail-tokarev.jpeg"
alt="Mikhail Tokarev"
loading="lazy"
width="200"
height="200"
/>
<figcaption class="md-typeset">
<h2>Mikhail Tokarev</h2>
<h3>
CTO at
<a href="https://codemagic.io/start/" target="_blank"
>Codemagic</a
>
</h3>
<hr />
<cite>
Thanks to the minimal overhead of using the Apple Virtualization
API, we’ve seen some performance improvements in booting new
virtual machines compared with Anka.
</cite>
</figcaption>
</figure>
<figure class="mdx-users__testimonial">
<img
src="assets/images/users/max-lapides.jpeg"
alt="Max Lapides"
loading="lazy"
width="200"
height="200"
/>
<figcaption class="md-typeset">
<h2>Max Lapides</h2>
<h3>
Senior Mobile Engineer at
<a href="https://www.tonal.com/" target="_blank">Tonal</a>
</h3>
<hr />
<cite>
Previously, we were using the GitHub&#8209;hosted macOS runners
and our iOS build took ~30&nbsp;minutes. Now with
<a href="/github-actions">Cirrus Runners</a>, the iOS build only
takes ~12&nbsp;minutes. That’s a huge boost to our productivity,
and for only $150/month per runner it is much less expensive too.
</cite>
</figcaption>
</figure>
</div>
</div>
</div>
</section>
{% endblock %}
+140
View File
@@ -0,0 +1,140 @@
---
hide:
- navigation
---
# Managing Virtual Machine
## Creating from scratch
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regarding of the underlying OS a particular VM image has.
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
### Creating a macOS VM image from scratch
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
use `latest` instead of a path to `*.ipsw` to download the latest available version:
```bash
tart create --from-ipsw=latest monterey-vanilla
tart run monterey-vanilla
```
After the initial booting of the VM you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
1. Enable Auto-Login. Users & Groups -> Login Options -> Automatic login -> admin.
2. Allow SSH. Sharing -> Remote Login
3. Disable Lock Screen. Preferences -> Lock Screen -> disable "Require Password" after 5.
4. Disable Screen Saver.
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
### Creating a Linux VM image from scratch
Linux VMs are supported on hosts running macOS 13.0 (Ventura) or newer.
```bash
# Create a bare VM
tart create --linux ubuntu
# Install Ubuntu
tart run --disk focal-desktop-arm64.iso ubuntu
# Run VM
tart run ubuntu
```
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
```bash
sudo apt update
sudo apt install -y openssh-server
sudo ufw allow ssh
```
## Configuring a VM
By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command.
Please refer to `tart set --help` for additional details.
## Building with Packer
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
Here is an example of a template to build `monterey-base` local image based of a remote image:
```hcl
packer {
required_plugins {
tart = {
version = ">= 0.5.3"
source = "github.com/cirruslabs/tart"
}
}
}
source "tart-cli" "tart" {
vm_base_name = "ghcr.io/cirruslabs/macos-ventura-base:latest"
vm_name = "my-custom-ventura"
cpu_count = 4
memory_gb = 8
disk_size_gb = 70
ssh_password = "admin"
ssh_timeout = "120s"
ssh_username = "admin"
}
build {
sources = ["source.tart-cli.tart"]
provisioner "shell" {
inline = ["echo 'Disabling spotlight indexing...'", "sudo mdutil -a -i off"]
}
# more provisioners
}
```
Here is a [repository with Packer templates](https://github.com/cirruslabs/macos-image-templates) used to build [all the images managed by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
## Working with a Remote OCI Container Registry
For example, let's say you want to push/pull images to a registry hosted at https://acme.io/.
### Registry Authorization
First, you need to log in and save credential for `acme.io` host via `tart login` command:
```bash
tart login acme.io
```
Credentials are securely stored in Keychain.
In addition, Tart supports [Docker credential helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers)
if defined in `~/.docker/config.json`.
Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override authorization
for all registries which might useful for integrating with your CI's secret management.
### Pushing a Local Image
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
```bash
tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:v1.0.0
```
### Pulling a Remote Image
You can either pull an image:
```bash
tart pull acme.io/remoteorg/name:latest
```
...or instantiate a VM from a remote image:
```bash
tart clone acme.io/remoteorg/name:latest my-local-vm-name
```
This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
+101
View File
@@ -0,0 +1,101 @@
repo_url: https://github.com/cirruslabs/tart/
site_url: https://tart.run/
edit_uri: blob/main/docs/
site_name: Tart
site_author: Cirrus Labs
copyright: © Cirrus Labs 2017-present
site_description: >
Tart is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon.
Built by CI engineers for your automation needs.
remote_branch: main
theme:
name: 'material'
custom_dir: 'docs/theme'
favicon: 'assets/images/favicon.ico'
logo: 'assets/images/TartLogo.png'
icon:
repo: fontawesome/brands/github
language: en
palette:
- scheme: default
primary: orange
accent: orange
font:
text: Roboto
code: Roboto Mono
features:
- announce.dismiss
- content.tabs.link
- content.code.copy
- navigation.tabs
- navigation.tabs.sticky
- navigation.top
- search.suggest
- toc.follow
extra_css:
- 'stylesheets/extra.css'
- 'stylesheets/landing.css'
plugins:
- social
- search
- minify
markdown_extensions:
- markdown.extensions.admonition
- markdown.extensions.codehilite:
guess_lang: false
- markdown.extensions.def_list
- markdown.extensions.footnotes
- markdown.extensions.meta
- markdown.extensions.toc:
permalink: true
- pymdownx.arithmatex
- pymdownx.betterem:
smart_enable: all
- pymdownx.caret
- pymdownx.critic
- pymdownx.details
- pymdownx.emoji:
emoji_generator: !!python/name:pymdownx.emoji.to_svg
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.superfences
- pymdownx.keys
- pymdownx.magiclink
- pymdownx.mark
- pymdownx.smartsymbols
- pymdownx.tabbed:
alternate_style: true
- pymdownx.tasklist:
custom_checkbox: true
- pymdownx.tilde
nav:
- "Home": index.md
- "Quick Start": quick-start.md
- "GitHub Actions": github-actions.md
- "Self-hosted CI": cirrus-cli.md
- "Managing VMs": vm-management.md
- "FAQ": faq.md
extra:
analytics:
provider: google
property: G-HXBEB9D47X
consent:
title: Cookie consent
description: >-
We use cookies to recognize your repeated visits and preferences, as well
as to measure the effectiveness of our documentation and whether users
find what they're searching for. With your consent, you're helping us to
make our documentation better.
social:
- icon: fontawesome/brands/twitter
link: 'https://twitter.com/cirrus_labs'