Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b242f27f49 | ||
|
|
b566d07bc4 | ||
|
|
d65f530bcb | ||
|
|
def779e20b | ||
|
|
92b35dbcf2 | ||
|
|
b8ff7474c3 | ||
|
|
f34aa5f072 | ||
|
|
ecc5de18be | ||
|
|
41af674a88 | ||
|
|
9c48d66674 | ||
|
|
31b106a67a | ||
|
|
abb1d7192a | ||
|
|
8b8faa2f1a | ||
|
|
c8c22e8c4d | ||
|
|
bd87e1a8b1 | ||
|
|
ba4f00fa78 |
@@ -94,3 +94,16 @@ task:
|
|||||||
- sentry-cli releases new $SENTRY_RELEASE
|
- sentry-cli releases new $SENTRY_RELEASE
|
||||||
- sentry-cli releases set-commits $SENTRY_RELEASE --auto
|
- sentry-cli releases set-commits $SENTRY_RELEASE --auto
|
||||||
- sentry-cli releases finalize $SENTRY_RELEASE
|
- 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
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
*.png filter=lfs diff=lfs merge=lfs -text
|
|
||||||
*.gif filter=lfs diff=lfs merge=lfs -text
|
|
||||||
@@ -13,3 +13,6 @@ tart.xcodeproj/
|
|||||||
|
|
||||||
# GoReleaser
|
# GoReleaser
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# mkdocs
|
||||||
|
.cache
|
||||||
|
|||||||
@@ -39,10 +39,15 @@ brews:
|
|||||||
name: homebrew-cli
|
name: homebrew-cli
|
||||||
caveats: See the GitHub repository for more information
|
caveats: See the GitHub repository for more information
|
||||||
homepage: https://github.com/cirruslabs/tart
|
homepage: https://github.com/cirruslabs/tart
|
||||||
|
license: "AGPL-3.0"
|
||||||
description: Run macOS VMs on Apple Silicon
|
description: Run macOS VMs on Apple Silicon
|
||||||
skip_upload: auto
|
skip_upload: auto
|
||||||
dependencies:
|
dependencies:
|
||||||
- "cirruslabs/cli/softnet"
|
- "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: |
|
custom_block: |
|
||||||
depends_on :macos => :monterey
|
depends_on :macos => :monterey
|
||||||
|
|
||||||
|
|||||||
@@ -18,15 +18,6 @@
|
|||||||
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "puppy",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/sushichop/Puppy",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "3e8d87f714f14244878752a6bb71ea465119f8a1",
|
|
||||||
"version" : "0.5.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "sentry-cocoa",
|
"identity" : "sentry-cocoa",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -81,15 +72,6 @@
|
|||||||
"version" : "1.0.3"
|
"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",
|
"identity" : "swift-numerics",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -116,6 +98,15 @@
|
|||||||
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
|
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
|
||||||
"version" : "0.50.6"
|
"version" : "0.50.6"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "texttable",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/cfilipov/TextTable",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "master",
|
||||||
|
"revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 2
|
"version" : 2
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ let package = Package(
|
|||||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
|
.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/apple/swift-async-algorithms", branch: "main"),
|
||||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
|
.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/antlr/antlr4", branch: "dev"),
|
||||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
||||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
|
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
|
||||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "7.31.3"),
|
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "7.31.3"),
|
||||||
|
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(name: "tart", dependencies: [
|
.executableTarget(name: "tart", dependencies: [
|
||||||
@@ -28,11 +28,10 @@ let package = Package(
|
|||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
.product(name: "Dynamic", package: "Dynamic"),
|
.product(name: "Dynamic", package: "Dynamic"),
|
||||||
.product(name: "SwiftDate", package: "SwiftDate"),
|
.product(name: "SwiftDate", package: "SwiftDate"),
|
||||||
.product(name: "Puppy", package: "Puppy"),
|
|
||||||
.product(name: "Antlr4Static", package: "Antlr4"),
|
.product(name: "Antlr4Static", package: "Antlr4"),
|
||||||
.product(name: "Atomics", package: "swift-atomics"),
|
.product(name: "Atomics", package: "swift-atomics"),
|
||||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||||
|
.product(name: "TextTable", package: "TextTable"),
|
||||||
], exclude: [
|
], exclude: [
|
||||||
"OCI/Reference/Makefile",
|
"OCI/Reference/Makefile",
|
||||||
"OCI/Reference/Reference.g4",
|
"OCI/Reference/Reference.g4",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) 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:
|
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.
|
* Push/Pull virtual machines from any OCI-compatible container registry.
|
||||||
* Use Tart Packer Plugin to automate VM creation.
|
* Use Tart Packer Plugin to automate VM creation.
|
||||||
* Built-in CI integration.
|
* 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>
|
<a href="https://ahrefs.com/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
|
||||||
</a>
|
</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>
|
<a href="https://suran.com/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
@@ -42,356 +45,11 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
|||||||
|
|
||||||
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
|
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
|
||||||
|
|
||||||
```shell
|
```bash
|
||||||
brew install cirruslabs/cli/tart
|
brew install cirruslabs/cli/tart
|
||||||
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
|
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
|
||||||
tart run ventura-base
|
tart run ventura-base
|
||||||
```
|
```
|
||||||
|
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartScreenshot.png"/>
|
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.
|
||||||
## 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:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
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
|
|
||||||
|
|
||||||
Linux VMs are supported on hosts running macOS 13.0 (Ventura) or newer.
|
|
||||||
|
|
||||||
```shell
|
|
||||||
# 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:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
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`:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
tart run --dir=project:~/src/project:ro vm
|
|
||||||
```
|
|
||||||
|
|
||||||
To mount multiple directories, repeat the `--dir` argument for each directory:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
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:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
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/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`:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
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:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>How to connect to a VM over SSH?</summary>
|
|
||||||
|
|
||||||
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
ssh admin@$(tart ip macos-monterey-base)
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 131 B After Width: | Height: | Size: 120 KiB |
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:8a3a324193c4bd7797102765ab16f44adf58e49ca615bac3963cefd0d3a10594
|
|
||||||
size 339678
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:23728bb5438c88b3d0826170a5fa4b7aaad0fbd94a4769c8d492c9f75b57ba81
|
|
||||||
size 155885
|
|
||||||
|
Before Width: | Height: | Size: 132 B After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 131 B After Width: | Height: | Size: 570 KiB |
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 589 B |
|
Before Width: | Height: | Size: 129 B After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 130 B After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 129 B After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 130 B After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 130 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 129 B After Width: | Height: | Size: 5.2 KiB |
@@ -39,6 +39,7 @@ struct Clone: AsyncParsableCommand {
|
|||||||
try tmpVMDirLock.lock()
|
try tmpVMDirLock.lock()
|
||||||
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
try await withTaskCancellationHandler(operation: {
|
||||||
|
// Acquire a global lock
|
||||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||||
try lock.lock()
|
try lock.lock()
|
||||||
|
|
||||||
@@ -52,15 +53,3 @@ struct Clone: AsyncParsableCommand {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate extension VMDirectory {
|
|
||||||
func macAddress() throws -> String {
|
|
||||||
try VMConfig(fromURL: configURL).macAddress.string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate extension VMStorageLocal {
|
|
||||||
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
|
||||||
try list().contains { try $1.macAddress() == macAddress }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,52 +1,31 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
fileprivate struct VMInfo: Encodable {
|
||||||
|
let CPU: Int
|
||||||
|
let Memory: UInt64
|
||||||
|
let Disk: Int
|
||||||
|
let Display: String
|
||||||
|
let Running: Bool
|
||||||
|
}
|
||||||
|
|
||||||
struct Get: AsyncParsableCommand {
|
struct Get: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
||||||
|
|
||||||
@Argument(help: "VM name.")
|
@Argument(help: "VM name.")
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Flag(help: "Number of VM CPUs.")
|
@Option(help: "Output format: text or json")
|
||||||
var cpu: Bool = false
|
var format: Format = .text
|
||||||
|
|
||||||
@Flag(help: "VM memory size in megabytes.")
|
|
||||||
var memory: Bool = false
|
|
||||||
|
|
||||||
@Flag(help: "Disk size in gigabytes.")
|
|
||||||
var diskSize: Bool = false
|
|
||||||
|
|
||||||
@Flag(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800.")
|
|
||||||
var display: Bool = false
|
|
||||||
|
|
||||||
func validate() throws {
|
|
||||||
if [cpu, memory, diskSize, display].filter({$0}).count > 1 {
|
|
||||||
throw ValidationError("Options --cpu, --memory, --disk-size and --display are mutually exclusive")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
let diskSizeInGb = try vmDir.sizeBytes() / 1000 / 1000 / 1000
|
let diskSizeInGb = try vmDir.sizeGB()
|
||||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||||
|
let running = try PIDLock(lockURL: vmDir.configURL).pid() > 0
|
||||||
|
|
||||||
if cpu {
|
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb, Display: vmConfig.display.description, Running: running)
|
||||||
print(vmConfig.cpuCount)
|
print(format.renderSingle(info))
|
||||||
} else if memory {
|
|
||||||
print(memorySizeInMb)
|
|
||||||
} else if diskSize {
|
|
||||||
print(diskSizeInGb)
|
|
||||||
} else if display {
|
|
||||||
print("\(vmConfig.display.width)x\(vmConfig.display.height)")
|
|
||||||
} else {
|
|
||||||
print(
|
|
||||||
"CPU\tMemory\tDisk\tDisplay\n" +
|
|
||||||
"\(vmConfig.cpuCount)\t" +
|
|
||||||
"\(memorySizeInMb) MB\t" +
|
|
||||||
"\(diskSizeInGb) GB\t" +
|
|
||||||
"\(vmConfig.display)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,24 @@ import ArgumentParser
|
|||||||
import Dispatch
|
import Dispatch
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
fileprivate struct VMInfo: Encodable {
|
||||||
|
let Source: String
|
||||||
|
let Name: String
|
||||||
|
let Size: Int
|
||||||
|
}
|
||||||
|
|
||||||
struct List: AsyncParsableCommand {
|
struct List: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "List created VMs")
|
static var configuration = CommandConfiguration(abstract: "List created VMs")
|
||||||
|
|
||||||
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
|
|
||||||
var quiet: Bool = false
|
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
|
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
|
||||||
var source: String?
|
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 validate() throws {
|
func validate() throws {
|
||||||
guard let source = source else {
|
guard let source = source else {
|
||||||
return
|
return
|
||||||
@@ -22,27 +31,28 @@ struct List: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
if !quiet {
|
var infos: [VMInfo] = []
|
||||||
print("Source\tName")
|
|
||||||
}
|
|
||||||
|
|
||||||
if source == nil || source == "local" {
|
if source == nil || source == "local" {
|
||||||
displayTable("local", try VMStorageLocal().list())
|
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
||||||
|
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if source == nil || source == "oci" {
|
if source == nil || source == "oci" {
|
||||||
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
|
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 displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
|
private func sortedInfos(_ infos: [VMInfo]) -> [VMInfo] {
|
||||||
for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) {
|
infos.sorted(by: { left, right in left.Name < right.Name })
|
||||||
if quiet {
|
|
||||||
print(name)
|
|
||||||
} else {
|
|
||||||
let source = source.padding(toLength: "Source".count, withPad: " ", startingAt: 0)
|
|
||||||
print("\(source)\t\(name)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,10 +100,9 @@ struct Prune: AsyncParsableCommand {
|
|||||||
|
|
||||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||||
try prunable.delete()
|
try prunable.delete()
|
||||||
puppy.info("deleting \(prunable.url)...")
|
|
||||||
}
|
|
||||||
|
|
||||||
puppy.info("reclaimed \(cacheReclaimedBytes) bytes")
|
try SentrySDK.span?.setExtra(value: prunable.sizeBytes(), key: prunable.url.path);
|
||||||
|
}
|
||||||
|
|
||||||
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)!")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,9 +38,6 @@ struct Run: AsyncParsableCommand {
|
|||||||
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
||||||
var vncExperimental: Bool = false
|
var vncExperimental: Bool = false
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp(visibility: .private))
|
|
||||||
var withSoftnet: Bool = false
|
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("""
|
@Option(help: ArgumentHelp("""
|
||||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
|
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
|
||||||
""", discussion: """
|
""", discussion: """
|
||||||
@@ -88,11 +85,6 @@ struct Run: AsyncParsableCommand {
|
|||||||
if vnc && vncExperimental {
|
if vnc && vncExperimental {
|
||||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||||
}
|
}
|
||||||
|
|
||||||
if withSoftnet && netBridged != nil {
|
|
||||||
throw ValidationError("--with-softnet and --net-bridged are mutually exclusive")
|
|
||||||
}
|
|
||||||
|
|
||||||
if netBridged != nil && netSoftnet {
|
if netBridged != nil && netSoftnet {
|
||||||
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
|
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
|
||||||
}
|
}
|
||||||
@@ -203,7 +195,7 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
|
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
|
||||||
if withSoftnet || netSoftnet {
|
if netSoftnet {
|
||||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
|
|
||||||
return try Softnet(vmMACAddress: config.macAddress.string)
|
return try Softnet(vmMACAddress: config.macAddress.string)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,8 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import Puppy
|
|
||||||
import Sentry
|
import Sentry
|
||||||
|
|
||||||
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)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct Root: AsyncParsableCommand {
|
struct Root: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(
|
static var configuration = CommandConfiguration(
|
||||||
@@ -27,10 +19,13 @@ struct Root: AsyncParsableCommand {
|
|||||||
IP.self,
|
IP.self,
|
||||||
Pull.self,
|
Pull.self,
|
||||||
Push.self,
|
Push.self,
|
||||||
|
Import.self,
|
||||||
|
Export.self,
|
||||||
Prune.self,
|
Prune.self,
|
||||||
Rename.self,
|
Rename.self,
|
||||||
Stop.self,
|
Stop.self,
|
||||||
Delete.self,
|
Delete.self,
|
||||||
|
ReportInstallation.self,
|
||||||
])
|
])
|
||||||
|
|
||||||
public static func main() async throws {
|
public static func main() async throws {
|
||||||
@@ -77,18 +72,16 @@ struct Root: AsyncParsableCommand {
|
|||||||
// Set line-buffered output for stdout
|
// Set line-buffered output for stdout
|
||||||
setlinebuf(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
|
// Parse and run command
|
||||||
do {
|
do {
|
||||||
var command = try parseAsRoot()
|
var command = try parseAsRoot()
|
||||||
|
|
||||||
// Run garbage-collection before each command (shouldn't take too long)
|
// Run garbage-collection before each command (shouldn't take too long)
|
||||||
try Config().gc()
|
do {
|
||||||
|
try Config().gc()
|
||||||
|
} catch {
|
||||||
|
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
|
||||||
|
}
|
||||||
|
|
||||||
if var asyncCommand = command as? AsyncParsableCommand {
|
if var asyncCommand = command as? AsyncParsableCommand {
|
||||||
try await asyncCommand.run()
|
try await asyncCommand.run()
|
||||||
@@ -96,21 +89,19 @@ struct Root: AsyncParsableCommand {
|
|||||||
try command.run()
|
try command.run()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if exitCode(for: error).rawValue == 0 {
|
|
||||||
exit(withError: error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture the error into Sentry
|
// Capture the error into Sentry
|
||||||
SentrySDK.capture(error: error)
|
SentrySDK.capture(error: error)
|
||||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||||
|
|
||||||
print(error)
|
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
|
||||||
|
|
||||||
if let errorWithExitCode = error as? HasExitCode {
|
if let errorWithExitCode = error as? HasExitCode {
|
||||||
|
print(error)
|
||||||
|
|
||||||
Foundation.exit(errorWithExitCode.exitCode)
|
Foundation.exit(errorWithExitCode.exitCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
Foundation.exit(1)
|
// Handle any other exception, including ArgumentParser's ones
|
||||||
|
exit(withError: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,11 +68,21 @@ struct VMDirectory: Prunable {
|
|||||||
try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
|
try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
|
||||||
|
|
||||||
// Re-generate MAC address
|
// Re-generate MAC address
|
||||||
var newVMConfig = try VMConfig(fromURL: to.configURL)
|
|
||||||
if generateMAC {
|
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 {
|
func resizeDisk(_ sizeGB: UInt16) throws {
|
||||||
@@ -97,6 +107,10 @@ struct VMDirectory: Prunable {
|
|||||||
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sizeGB() throws -> Int {
|
||||||
|
try sizeBytes() / 1000 / 1000 / 1000
|
||||||
|
}
|
||||||
|
|
||||||
func markExplicitlyPulled() {
|
func markExplicitlyPulled() {
|
||||||
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ enum RuntimeError : Error {
|
|||||||
case VMTerminationFailed(_ message: String)
|
case VMTerminationFailed(_ message: String)
|
||||||
case InvalidCredentials(_ message: String)
|
case InvalidCredentials(_ message: String)
|
||||||
case VMDirectoryAlreadyInitialized(_ message: String)
|
case VMDirectoryAlreadyInitialized(_ message: String)
|
||||||
|
case ExportFailed(_ message: String)
|
||||||
|
case ImportFailed(_ message: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol HasExitCode {
|
protocol HasExitCode {
|
||||||
@@ -86,6 +88,10 @@ extension RuntimeError : CustomStringConvertible {
|
|||||||
return message
|
return message
|
||||||
case .VMDirectoryAlreadyInitialized(let message):
|
case .VMDirectoryAlreadyInitialized(let message):
|
||||||
return message
|
return message
|
||||||
|
case .ExportFailed(let message):
|
||||||
|
return "VM export failed: \(message)"
|
||||||
|
case .ImportFailed(let message):
|
||||||
|
return "VM import failed: \(message)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,4 +62,8 @@ class VMStorageLocal {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
||||||
|
try list().contains { try $1.macAddress() == macAddress }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,16 +166,25 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
let availableCapacityBytes = max(UInt64(capacityImportant), UInt64(capacityAvailable))
|
let availableCapacityBytes = max(UInt64(capacityImportant), UInt64(capacityAvailable))
|
||||||
|
|
||||||
if capacityImportant == 0 || capacityAvailable == 0 {
|
if capacityImportant == 0 || capacityAvailable == 0 {
|
||||||
puppy.warning("important capacity \(capacityImportant) bytes, "
|
SentrySDK.capture(message: "Zero capacity") { scope in
|
||||||
+ "available capacity is \(capacityAvailable) bytes")
|
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.
|
// There is a suspicious that occasionally capacity is returned as zero which can't be true.
|
||||||
// Let's validate to avoid unnecessary pruning.
|
// Let's validate to avoid unnecessary pruning.
|
||||||
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
|
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
|
||||||
puppy.info("pruning cache to accommodate \(name) with a disk of size \(uncompressedDiskSize) bytes ("
|
let transaction = SentrySDK.startTransaction(name: "Automatically Pruning Cache", operation: "prune", bindToScope: true)
|
||||||
+ "available capacity is \(availableCapacityBytes) bytes, required capacity "
|
transaction.setData(value: name, key: "name")
|
||||||
+ "is \(requiredCapacityBytes) bytes)")
|
transaction.setData(value: uncompressedDiskSize, key: "uncompressedDiskSize")
|
||||||
|
transaction.setData(value: availableCapacityBytes, key: "availableCapacity")
|
||||||
|
transaction.setData(value: requiredCapacityBytes, key: "requiredCapacity")
|
||||||
|
defer { transaction.finish() }
|
||||||
|
|
||||||
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
tart.run
|
||||||
|
www.tart.run
|
||||||
@@ -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 |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[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`.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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`.
|
||||||
|
|
||||||
|

|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
template: overrides/home.html
|
||||||
|
title: Tart
|
||||||
|
---
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: *
|
||||||
|
Disallow:
|
||||||
|
Sitemap: https://tart.run/sitemap.xml
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 using Apple’s native
|
||||||
|
<i>Virtualization.Framework</i> that was developed along with
|
||||||
|
architecting the first M1 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 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‑demand GitHub Actions Runners</a
|
||||||
|
>
|
||||||
|
and
|
||||||
|
<a href="https://cirrus-ci.org/guide/macOS/" target="_blank"
|
||||||
|
>Cirrus CI</a
|
||||||
|
>. Double the performance of your macOS actions with
|
||||||
|
a couple lines of 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’s been plain-sailing with the
|
||||||
|
<a href="/github-actions">Cirrus Runners</a> —
|
||||||
|
they’ve been great! They’re consistently 60+%
|
||||||
|
faster on workflows that we previously used Github
|
||||||
|
Actions’ 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‑hosted macOS runners
|
||||||
|
and our iOS build took ~30 minutes. Now with
|
||||||
|
<a href="/github-actions">Cirrus Runners</a>, the iOS build only
|
||||||
|
takes ~12 minutes. That’s a huge boost to our productivity,
|
||||||
|
and for only $150/month per runner it is much less expensive too.
|
||||||
|
</cite>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -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.
|
||||||
@@ -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'
|
||||||