Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b242f27f49 | ||
|
|
b566d07bc4 | ||
|
|
d65f530bcb | ||
|
|
def779e20b | ||
|
|
92b35dbcf2 | ||
|
|
b8ff7474c3 | ||
|
|
f34aa5f072 | ||
|
|
ecc5de18be | ||
|
|
41af674a88 | ||
|
|
9c48d66674 | ||
|
|
31b106a67a | ||
|
|
abb1d7192a | ||
|
|
8b8faa2f1a | ||
|
|
c8c22e8c4d | ||
|
|
bd87e1a8b1 | ||
|
|
ba4f00fa78 | ||
|
|
1380ece108 | ||
|
|
9cdb7087f2 | ||
|
|
c3e37854c3 | ||
|
|
828351a8fb | ||
|
|
f9ac994e18 | ||
|
|
fdeaf979c2 | ||
|
|
d9f1c37cdd | ||
|
|
81253417a4 | ||
|
|
c9caeab098 | ||
|
|
6d7dc40c57 |
@@ -61,6 +61,9 @@ task:
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||
SENTRY_ORG: cirrus-labs
|
||||
SENTRY_PROJECT: persistent-workers
|
||||
SENTRY_AUTH_TOKEN: ENCRYPTED[!c16a5cf7da5f856b4bc2f21fe8cb7aa2a6c981f851c094ed4d3025fd02ea59a58a86cee8b193a69a1fc20fa217e56ac3!]
|
||||
setup_script:
|
||||
- cd $HOME
|
||||
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
||||
@@ -71,10 +74,36 @@ task:
|
||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
||||
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||
install_script:
|
||||
- brew install go goreleaser/tap/goreleaser-pro
|
||||
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
|
||||
- brew install mitchellh/gon/gon
|
||||
info_script:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
- swift -version
|
||||
release_script: goreleaser
|
||||
upload_sentry_debug_files_script:
|
||||
- cd .build/arm64-apple-macosx/debug/
|
||||
# Generate and upload symbols
|
||||
- dsymutil tart
|
||||
- sentry-cli debug-files upload tart.dSYM/
|
||||
# Bundle and upload sources
|
||||
- sentry-cli debug-files bundle-sources tart.dSYM
|
||||
- sentry-cli debug-files upload tart.src.zip
|
||||
create_sentry_release_script:
|
||||
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
|
||||
- sentry-cli releases new $SENTRY_RELEASE
|
||||
- sentry-cli releases set-commits $SENTRY_RELEASE --auto
|
||||
- sentry-cli releases finalize $SENTRY_RELEASE
|
||||
|
||||
task:
|
||||
name: Deploy Documentation
|
||||
only_if: $CIRRUS_BRANCH == 'main'
|
||||
container:
|
||||
image: ghcr.io/squidfunk/mkdocs-material:latest
|
||||
env:
|
||||
DEPLOY_TOKEN: ENCRYPTED[!45ed45666558902ed1c2400add734ec063103bec31841847e8c8764802fca229bfa6d85c690e16ad159e047574b48793!]
|
||||
deploy_script:
|
||||
- git config --global user.name "Cirrus CI"
|
||||
- git config --global user.name "hello@cirruslabs.org"
|
||||
- git remote set-url origin https://$DEPLOY_TOKEN@github.com/cirruslabs/tart/
|
||||
- mkdocs --verbose gh-deploy --force --remote-branch gh-pages
|
||||
|
||||
@@ -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
|
||||
dist/
|
||||
|
||||
# mkdocs
|
||||
.cache
|
||||
|
||||
@@ -39,10 +39,15 @@ brews:
|
||||
name: homebrew-cli
|
||||
caveats: See the GitHub repository for more information
|
||||
homepage: https://github.com/cirruslabs/tart
|
||||
license: "AGPL-3.0"
|
||||
description: Run macOS VMs on Apple Silicon
|
||||
skip_upload: auto
|
||||
dependencies:
|
||||
- "cirruslabs/cli/softnet"
|
||||
post_install: |
|
||||
ENV["SENTRY_DSN"] = "https://606fab64ced94f0991109ac5467e23da@o4504250314522624.ingest.sentry.io/4504606552424448"
|
||||
system "#{bin}/tart report-installation"
|
||||
ENV.delete("SENTRY_DSN")
|
||||
custom_block: |
|
||||
depends_on :macos => :monterey
|
||||
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "puppy",
|
||||
"identity" : "sentry-cocoa",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sushichop/Puppy",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "3e8d87f714f14244878752a6bb71ea465119f8a1",
|
||||
"version" : "0.5.1"
|
||||
"revision" : "c3c19e29f775ee95b77aa3168f3e2fd6c20deba6",
|
||||
"version" : "7.31.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -72,15 +72,6 @@
|
||||
"version" : "1.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "6fe203dc33195667ce1759bf0182975e4653ba1c",
|
||||
"version" : "1.4.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-numerics",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -107,6 +98,15 @@
|
||||
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
|
||||
"version" : "0.50.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "texttable",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/cfilipov/TextTable",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
|
||||
@@ -15,10 +15,11 @@ let package = Package(
|
||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
|
||||
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
|
||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
|
||||
.package(url: "https://github.com/sushichop/Puppy", from: "0.5.1"),
|
||||
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "7.31.3"),
|
||||
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
@@ -27,9 +28,10 @@ let package = Package(
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "Dynamic", package: "Dynamic"),
|
||||
.product(name: "SwiftDate", package: "SwiftDate"),
|
||||
.product(name: "Puppy", package: "Puppy"),
|
||||
.product(name: "Antlr4Static", package: "Antlr4"),
|
||||
.product(name: "Atomics", package: "swift-atomics"),
|
||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||
.product(name: "TextTable", package: "TextTable"),
|
||||
], exclude: [
|
||||
"OCI/Reference/Makefile",
|
||||
"OCI/Reference/Reference.g4",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
|
||||
|
||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
|
||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon.
|
||||
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
||||
|
||||
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/14966395?baseline=14966339).
|
||||
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722).
|
||||
* Push/Pull virtual machines from any OCI-compatible container registry.
|
||||
* Use Tart Packer Plugin to automate VM creation.
|
||||
* Built-in CI integration.
|
||||
@@ -28,6 +28,9 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
||||
<a href="https://ahrefs.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://krisp.ai/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://suran.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
||||
</a>
|
||||
@@ -40,346 +43,13 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
||||
|
||||
## Usage
|
||||
|
||||
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
|
||||
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
|
||||
|
||||
```shell
|
||||
```bash
|
||||
brew install cirruslabs/cli/tart
|
||||
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
|
||||
tart run ventura-base
|
||||
```
|
||||
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartScreenshot.png"/>
|
||||
|
||||
## CI Integration
|
||||
|
||||
Tart already powers several CI services mentioned above including our own [Cirrus CI](https://cirrus-ci.org/guide/macOS/) which offers unlimited concurrency with per-second billing.
|
||||
For services that haven't leveraged Tart yet, we offer fully managed runners via a monthly subscription.
|
||||
*Cirrus Runners* is the fastest way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
|
||||
Please read down below about currently supported services.
|
||||
|
||||
### Managed runners for your CI-as-a-service
|
||||
|
||||
At the moment Cirrus Runners only supports GitHub Actions, but we are actively working on adding more options.
|
||||
Please [email us](mailto:hello@cirruslabs.org) if you are interested in a particular one.
|
||||
|
||||
#### GitHub Actions
|
||||
|
||||
Configuring Cirrus Runners for GitHub Actions is as simple as installing [Cirrus Runners App](https://github.com/apps/cirrus-runners).
|
||||
After successful installation and subscription configuration, use any of [Ventura images managed by us](https://github.com/cirruslabs/macos-image-templates) in `runs-on`:
|
||||
|
||||
```yaml
|
||||
name: Test Suite
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
```
|
||||
|
||||
When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`.
|
||||
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartGHARunners.png"/>
|
||||
|
||||
### Self-hosted CI
|
||||
|
||||
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
|
||||
also developed by Cirrus Labs. [Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is a command line tool with
|
||||
one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
|
||||
We built Cirrus CLI to solve "But it works on my machine!" problem.
|
||||
|
||||
Here is an example of a `.cirrus.yml` configuration file which will start a Tart VM, will copy over working directory and
|
||||
will run scripts and [other instructions](https://cirrus-ci.org/guide/writing-tasks/#supported-instructions) inside the virtual machine:
|
||||
|
||||
```yaml
|
||||
task:
|
||||
name: hello
|
||||
macos_instance:
|
||||
# can be a remote or a local virtual machine
|
||||
image: ghcr.io/cirruslabs/macos-monterey-base:latest
|
||||
hello_script:
|
||||
- echo "Hello from within a Tart VM!"
|
||||
- echo "Here is my CPU info:"
|
||||
- sysctl -n machdep.cpu.brand_string
|
||||
- sleep 15
|
||||
```
|
||||
|
||||
Put the above `.cirrus.yml` file in the root of your repository and run it with the following command:
|
||||
|
||||
```shell
|
||||
brew install cirruslabs/cli/cirrus
|
||||
cirrus run
|
||||
```
|
||||
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartCirrusCLI.gif"/>
|
||||
|
||||
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
|
||||
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
|
||||
|
||||
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
||||
|
||||
#### Retrieving artifacts from within Tart VMs
|
||||
|
||||
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
|
||||
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
|
||||
exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tasks/#artifacts-instruction):
|
||||
|
||||
```yaml
|
||||
task:
|
||||
name: Build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
|
||||
build_script: swift build --product tart
|
||||
binary_artifacts:
|
||||
path: .build/debug/tart
|
||||
```
|
||||
|
||||
Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
|
||||
|
||||
```bash
|
||||
cirrus run --artifacts-dir artifacts
|
||||
```
|
||||
|
||||
Note that all retrieved artifacts will be prefixed with the associated task name and `artifacts` instruction name.
|
||||
For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/binary/.build/debug/tart`.
|
||||
|
||||
## Virtual Machine Management
|
||||
|
||||
### Creating from scratch
|
||||
|
||||
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regarding of the underlying OS a particular VM image has.
|
||||
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
|
||||
|
||||
#### Creating a macOS VM image from scratch
|
||||
|
||||
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
|
||||
use `latest` instead of a path to `*.ipsw` to download the latest available version:
|
||||
|
||||
```shell
|
||||
tart create --from-ipsw=latest monterey-vanilla
|
||||
tart run monterey-vanilla
|
||||
```
|
||||
|
||||
After the initial booting of the VM you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
|
||||
|
||||
1. Enable Auto-Login. Users & Groups -> Login Options -> Automatic login -> admin.
|
||||
2. Allow SSH. Sharing -> Remote Login
|
||||
3. Disable Lock Screen. Preferences -> Lock Screen -> disable "Require Password" after 5.
|
||||
4. Disable Screen Saver.
|
||||
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
|
||||
|
||||
#### Creating a Linux VM image from scratch
|
||||
|
||||
```bash
|
||||
# Create a bare VM
|
||||
tart create --linux ubuntu
|
||||
|
||||
# Install Ubuntu
|
||||
tart run --disk focal-desktop-arm64.iso ubuntu
|
||||
|
||||
# Run VM
|
||||
tart run ubuntu
|
||||
```
|
||||
|
||||
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
|
||||
|
||||
```shell
|
||||
sudo apt update
|
||||
sudo apt install -y openssh-server
|
||||
sudo ufw allow ssh
|
||||
```
|
||||
|
||||
### Configuring a VM
|
||||
|
||||
By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command.
|
||||
Please refer to `tart set --help` for additional details.
|
||||
|
||||
### Building with Packer
|
||||
|
||||
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
|
||||
Here is an example of a template to build `monterey-base` local image based of a remote image:
|
||||
|
||||
```hcl
|
||||
packer {
|
||||
required_plugins {
|
||||
tart = {
|
||||
version = ">= 0.5.3"
|
||||
source = "github.com/cirruslabs/tart"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
source "tart-cli" "tart" {
|
||||
vm_base_name = "ghcr.io/cirruslabs/macos-ventura-base:latest"
|
||||
vm_name = "my-custom-ventura"
|
||||
cpu_count = 4
|
||||
memory_gb = 8
|
||||
disk_size_gb = 70
|
||||
ssh_password = "admin"
|
||||
ssh_timeout = "120s"
|
||||
ssh_username = "admin"
|
||||
}
|
||||
|
||||
build {
|
||||
sources = ["source.tart-cli.tart"]
|
||||
|
||||
provisioner "shell" {
|
||||
inline = ["echo 'Disabling spotlight indexing...'", "sudo mdutil -a -i off"]
|
||||
}
|
||||
|
||||
# more provisioners
|
||||
}
|
||||
```
|
||||
|
||||
Here is a [repository with Packer templates](https://github.com/cirruslabs/macos-image-templates) used to build [all the images managed by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
||||
|
||||
### Working with a Remote OCI Container Registry
|
||||
|
||||
For example, let's say you want to push/pull images to a registry hosted at https://acme.io/.
|
||||
|
||||
#### Registry Authorization
|
||||
|
||||
First, you need to log in and save credential for `acme.io` host via `tart login` command:
|
||||
|
||||
```shell
|
||||
tart login acme.io
|
||||
```
|
||||
|
||||
Credentials are securely stored in Keychain.
|
||||
|
||||
In addition, Tart supports [Docker credential helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers)
|
||||
if defined in `~/.docker/config.json`.
|
||||
|
||||
Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override authorization
|
||||
for all registries which might useful for integrating with your CI's secret management.
|
||||
|
||||
#### Pushing a Local Image
|
||||
|
||||
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
|
||||
|
||||
```shell
|
||||
tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:v1.0.0
|
||||
```
|
||||
|
||||
#### Pulling a Remote Image
|
||||
|
||||
You can either pull an image:
|
||||
|
||||
```shell
|
||||
tart pull acme.io/remoteorg/name:latest
|
||||
```
|
||||
|
||||
...or instantiate a VM from a remote image:
|
||||
|
||||
```shell
|
||||
tart clone acme.io/remoteorg/name:latest my-local-vm-name
|
||||
```
|
||||
|
||||
This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
|
||||
|
||||
### Mounting directories
|
||||
|
||||
To mount a directory, run the VM with the `--dir` argument:
|
||||
|
||||
```sh
|
||||
tart run --dir=project:~/src/project vm
|
||||
```
|
||||
|
||||
Here, the `project` specifies a mount name, whereas the `~/src/project` is a path to the host's directory to expose to the VM.
|
||||
|
||||
It is also possible to mount directories in read-only mode by adding a third parameter, `ro`:
|
||||
|
||||
```sh
|
||||
tart run --dir=project:~/src/project:ro vm
|
||||
```
|
||||
|
||||
To mount multiple directories, repeat the `--dir` argument for each directory:
|
||||
|
||||
```sh
|
||||
tart run --dir=www1:~/project1/www --dir=www2:~/project2/www
|
||||
```
|
||||
|
||||
Note that the first parameter in each `--dir` argument must be unique, otherwise only the last `--dir` argument using that name will be used.
|
||||
|
||||
Note: to use the directory mounting feature, the host needs to run macOS 13.0 (Ventura) or newer.
|
||||
|
||||
#### Accessing mounted directories in macOS guests
|
||||
|
||||
All shared directories are automatically mounted to `/Volumes/My Shared Files` directory.
|
||||
|
||||
The directory we've mounted above will be accessible from the `/Volumes/My Shared Files/project` path inside a guest VM.
|
||||
|
||||
Note: to use the directory mounting feature, the guest VM needs to run macOS 13.0 (Ventura) or newer.
|
||||
|
||||
#### Accessing mounted directories in Linux guests
|
||||
|
||||
To be able to access the shared directories from the Linux guest, you need to manually mount the virtual filesystem first:
|
||||
|
||||
```sh
|
||||
mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
|
||||
```
|
||||
|
||||
The directory we've mounted above will be accessible from the `/mnt/shared/project` path inside a guest VM.
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary>How Tart is different from Anka</summary>
|
||||
|
||||
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
|
||||
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
|
||||
|
||||
Instead of Anka Registry, Tart can work with any OCI-compatible container registry.
|
||||
|
||||
Tart doesn't yet have an analogue of Anka Controller for managing long living VMs. Please take a look at [CI integration](#ci-integration)
|
||||
section for an option to run ephemeral VMs for your needs.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Why Tart is free and open sourced?</summary>
|
||||
|
||||
Apple did all the heavy lifting with their `Virtualization.Framework` and it just felt right to develop Tart in the open.
|
||||
Please consider [becoming a sponsor](https://github.com/sponsors/cirruslabs) if you find Tart saving a substantial amount of money on licensing and engineering hours for your company.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>How to change VM's disk size?</summary>
|
||||
|
||||
You can choose disk size upon creation of a virtual machine:
|
||||
|
||||
```shell
|
||||
tart create --from-ipsw=latest --disk-size=25 monterey-vanilla
|
||||
```
|
||||
|
||||
For an existing VM please use [Packer Plugin](https://github.com/cirruslabs/packer-plugin-tart) which can increase
|
||||
disk size for new virtual machines. Here is an example of [how to change disk size in a Packer template](https://github.com/cirruslabs/macos-image-templates/blob/fb0bcf68e0b093129136875c050205a66729b596/templates/base.pkr.hcl#L15).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>VM location on disk</summary>
|
||||
|
||||
Tart stores all it's files in `~/.tart/` directory. Local images that you can run are stored in `~/.tart/vms/`.
|
||||
Remote images are pulled into `~/.tart/vms/cache/OCIs/`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Nested virtualization support?</summary>
|
||||
|
||||
Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
|
||||
doesn't support nested virtualization.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Changing the default NAT subnet</summary>
|
||||
|
||||
To change the default network to `192.168.77.1`:
|
||||
|
||||
```
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Address -string 192.168.77.1
|
||||
```
|
||||
|
||||
Note that even through a network would normally be specified as `192.168.77.0`, the [vmnet framework](https://developer.apple.com/documentation/vmnet) seems to treat this as a starting address too and refuses to pick up such network-like values.
|
||||
|
||||
The default subnet mask `255.255.255.0` should suffice for most use-cases, however, you can also change it to `255.255.0.0`, for example:
|
||||
|
||||
```
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
|
||||
```
|
||||
</details>
|
||||
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
|
||||
for remaining questions.
|
||||
|
||||
|
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 |
@@ -4,6 +4,10 @@ struct CI {
|
||||
static var version: String {
|
||||
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
|
||||
}
|
||||
|
||||
static var release: String? {
|
||||
rawVersion.expanded() ? "tart@\(rawVersion)" : nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
|
||||
@@ -21,54 +21,35 @@ struct Clone: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let ociStorage = VMStorageOCI()
|
||||
let localStorage = VMStorageLocal()
|
||||
let ociStorage = VMStorageOCI()
|
||||
let localStorage = VMStorageLocal()
|
||||
|
||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
try await ociStorage.pull(remoteName, registry: registry)
|
||||
}
|
||||
|
||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
try lock.lock()
|
||||
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
|
||||
try lock.unlock()
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
try await ociStorage.pull(remoteName, registry: registry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension VMDirectory {
|
||||
func macAddress() throws -> String {
|
||||
try VMConfig(fromURL: configURL).macAddress.string
|
||||
}
|
||||
}
|
||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||
|
||||
fileprivate extension VMStorageLocal {
|
||||
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
||||
try list().contains { try $1.macAddress() == macAddress }
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
// Acquire a global lock
|
||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
try lock.lock()
|
||||
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
|
||||
try lock.unlock()
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,46 +25,38 @@ struct Create: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
if let fromIPSW = fromIPSW {
|
||||
let ipswURL: URL
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
if let fromIPSW = fromIPSW {
|
||||
let ipswURL: URL
|
||||
|
||||
if fromIPSW == "latest" {
|
||||
ipswURL = try await VM.latestIPSWURL()
|
||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||
ipswURL = URL(string: fromIPSW)!
|
||||
} else {
|
||||
ipswURL = URL(fileURLWithPath: fromIPSW)
|
||||
}
|
||||
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||
if fromIPSW == "latest" {
|
||||
ipswURL = try await VM.latestIPSWURL()
|
||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||
ipswURL = URL(string: fromIPSW)!
|
||||
} else {
|
||||
ipswURL = URL(fileURLWithPath: fromIPSW)
|
||||
}
|
||||
|
||||
if linux {
|
||||
if #available(macOS 13, *) {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
} else {
|
||||
throw UnsupportedOSError("Linux VMs", "are")
|
||||
}
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||
}
|
||||
|
||||
if linux {
|
||||
if #available(macOS 13, *) {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
} else {
|
||||
throw UnsupportedOSError("Linux VMs", "are")
|
||||
}
|
||||
}
|
||||
|
||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,8 @@ struct Delete: AsyncParsableCommand {
|
||||
var name: [String]
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
for it in name {
|
||||
try VMStorageHelper.delete(it)
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
for it in name {
|
||||
try VMStorageHelper.delete(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,31 +1,31 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
fileprivate struct VMInfo: Encodable {
|
||||
let CPU: Int
|
||||
let Memory: UInt64
|
||||
let Disk: Int
|
||||
let Display: String
|
||||
let Running: Bool
|
||||
}
|
||||
|
||||
struct Get: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name.")
|
||||
var name: String
|
||||
|
||||
@Option(help: "Output format: text or json")
|
||||
var format: Format = .text
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let diskSize = try vmDir.sizeBytes() / 1000 / 1000 / 1000
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let diskSizeInGb = try vmDir.sizeGB()
|
||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||
let running = try PIDLock(lockURL: vmDir.configURL).pid() > 0
|
||||
|
||||
print("CPU\tMemory\tDisk\tDisplay")
|
||||
|
||||
var s = "\(vmConfig.cpuCount)\t"
|
||||
s += "\(vmConfig.memorySize / 1024 / 1024) MB\t"
|
||||
s += "\(diskSize) GB\t"
|
||||
s += "\(vmConfig.display.width)x\(vmConfig.display.height)"
|
||||
print(s)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb, Display: vmConfig.display.description, Running: running)
|
||||
print(format.renderSingle(info))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import ArgumentParser
|
||||
import Foundation
|
||||
import Network
|
||||
import SystemConfiguration
|
||||
import Sentry
|
||||
|
||||
struct IP: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
||||
@@ -13,31 +14,37 @@ struct IP: AsyncParsableCommand {
|
||||
var wait: UInt16 = 0
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
|
||||
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
||||
print("no IP address found, is your VM running?")
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
|
||||
if let ipViaARP = try ARPCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
||||
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
||||
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
||||
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
||||
}
|
||||
|
||||
print(ipViaDHCP)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
||||
throw RuntimeError.NoIPAddressFound("no IP address found, is your VM running?")
|
||||
}
|
||||
|
||||
let arpCache = try ARPCache()
|
||||
|
||||
if let ipViaARP = try arpCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
||||
// Capture the warning into Sentry
|
||||
SentrySDK.capture(message: "DHCP lease and ARP cache entries for a single MAC address differ") { scope in
|
||||
scope.setLevel(.warning)
|
||||
|
||||
scope.setContext(value: [
|
||||
"MAC address": vmMACAddress,
|
||||
"IP via ARP": ipViaARP,
|
||||
"IP via DHCP": ipViaDHCP,
|
||||
], key: "Address conflict details")
|
||||
|
||||
scope.add(Attachment(path: "/var/db/dhcpd_leases", filename: "dhcpd_leases.txt", contentType: "text/plain"))
|
||||
scope.add(Attachment(data: arpCache.arpCommandOutput, filename: "arp-an-output.txt", contentType: "text/plain"))
|
||||
}
|
||||
|
||||
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
||||
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
||||
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
||||
}
|
||||
|
||||
print(ipViaDHCP)
|
||||
}
|
||||
|
||||
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
|
||||
|
||||
@@ -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,36 +2,57 @@ import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
|
||||
fileprivate struct VMInfo: Encodable {
|
||||
let Source: String
|
||||
let Name: String
|
||||
let Size: Int
|
||||
}
|
||||
|
||||
struct List: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "List created VMs")
|
||||
|
||||
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names"))
|
||||
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
|
||||
var source: String?
|
||||
|
||||
@Option(help: "Output format: text or json")
|
||||
var format: Format = .text
|
||||
|
||||
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
|
||||
var quiet: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
if !quiet {
|
||||
print("Source\tName")
|
||||
}
|
||||
func validate() throws {
|
||||
guard let source = source else {
|
||||
return
|
||||
}
|
||||
|
||||
displayTable("local", try VMStorageLocal().list())
|
||||
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
if !["local", "oci"].contains(source) {
|
||||
throw ValidationError("'\(source)' is not a valid <source>")
|
||||
}
|
||||
}
|
||||
|
||||
private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
|
||||
for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) {
|
||||
if quiet {
|
||||
print(name)
|
||||
} else {
|
||||
print("\(source)\t\(name)")
|
||||
}
|
||||
func run() async throws {
|
||||
var infos: [VMInfo] = []
|
||||
if source == nil || source == "local" {
|
||||
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
||||
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB())
|
||||
})
|
||||
}
|
||||
|
||||
if source == nil || source == "oci" {
|
||||
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
||||
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB())
|
||||
})
|
||||
}
|
||||
if (quiet) {
|
||||
for info in infos {
|
||||
print(info.Name)
|
||||
}
|
||||
} else {
|
||||
print(format.renderList(infos))
|
||||
}
|
||||
}
|
||||
|
||||
private func sortedInfos(_ infos: [VMInfo]) -> [VMInfo] {
|
||||
infos.sorted(by: { left, right in left.Name < right.Name })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,40 +27,30 @@ struct Login: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
var user: String
|
||||
var password: String
|
||||
var user: String
|
||||
var password: String
|
||||
|
||||
if let username = username {
|
||||
user = username
|
||||
if let username = username {
|
||||
user = username
|
||||
|
||||
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
||||
password = String(decoding: passwordData, as: UTF8.self)
|
||||
} else {
|
||||
(user, password) = try StdinCredentials.retrieve()
|
||||
}
|
||||
let credentialsProvider = DictionaryCredentialsProvider([
|
||||
host: (user, password)
|
||||
])
|
||||
|
||||
do {
|
||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||
credentialsProviders: [credentialsProvider])
|
||||
try await registry.ping()
|
||||
} catch {
|
||||
print("invalid credentials: \(error)")
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
|
||||
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
||||
password = String(decoding: passwordData, as: UTF8.self)
|
||||
} else {
|
||||
(user, password) = try StdinCredentials.retrieve()
|
||||
}
|
||||
let credentialsProvider = DictionaryCredentialsProvider([
|
||||
host: (user, password)
|
||||
])
|
||||
|
||||
do {
|
||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||
credentialsProviders: [credentialsProvider])
|
||||
try await registry.ping()
|
||||
} catch {
|
||||
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
||||
}
|
||||
|
||||
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import Sentry
|
||||
import SwiftUI
|
||||
import SwiftDate
|
||||
|
||||
@@ -26,29 +27,21 @@ struct Prune: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
if gc {
|
||||
try VMStorageOCI().gc()
|
||||
}
|
||||
if gc {
|
||||
try VMStorageOCI().gc()
|
||||
}
|
||||
|
||||
// Clean up cache entries based on last accessed date
|
||||
if let olderThan = olderThan {
|
||||
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
||||
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
|
||||
// Clean up cache entries based on last accessed date
|
||||
if let olderThan = olderThan {
|
||||
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
||||
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
|
||||
|
||||
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
|
||||
}
|
||||
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
|
||||
}
|
||||
|
||||
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
||||
if let cacheBudget = cacheBudget {
|
||||
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
||||
if let cacheBudget = cacheBudget {
|
||||
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,9 +100,10 @@ struct Prune: AsyncParsableCommand {
|
||||
|
||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||
try prunable.delete()
|
||||
puppy.info("deleting \(prunable.url)...")
|
||||
|
||||
try SentrySDK.span?.setExtra(value: prunable.sizeBytes(), key: prunable.url.path);
|
||||
}
|
||||
|
||||
puppy.info("reclaimed \(cacheReclaimedBytes) bytes")
|
||||
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,27 +12,19 @@ struct Pull: AsyncParsableCommand {
|
||||
var insecure: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
// Be more liberal when accepting local image as argument,
|
||||
// see https://github.com/cirruslabs/tart/issues/36
|
||||
if VMStorageLocal().exists(remoteName) {
|
||||
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
||||
// Be more liberal when accepting local image as argument,
|
||||
// see https://github.com/cirruslabs/tart/issues/36
|
||||
if VMStorageLocal().exists(remoteName) {
|
||||
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
||||
|
||||
Foundation.exit(0)
|
||||
}
|
||||
|
||||
let remoteName = try RemoteName(remoteName)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
|
||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
let remoteName = try RemoteName(remoteName)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
|
||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,54 +28,46 @@ struct Push: AsyncParsableCommand {
|
||||
var populateCache: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let localVMDir = try VMStorageLocal().open(localName)
|
||||
let localVMDir = try VMStorageLocal().open(localName)
|
||||
|
||||
// Parse remote names supplied by the user
|
||||
let remoteNames = try remoteNames.map{
|
||||
try RemoteName($0)
|
||||
}
|
||||
// Parse remote names supplied by the user
|
||||
let remoteNames = try remoteNames.map{
|
||||
try RemoteName($0)
|
||||
}
|
||||
|
||||
// Group remote names by registry
|
||||
struct RegistryIdentifier: Hashable, Equatable {
|
||||
var host: String
|
||||
var namespace: String
|
||||
}
|
||||
// Group remote names by registry
|
||||
struct RegistryIdentifier: Hashable, Equatable {
|
||||
var host: String
|
||||
var namespace: String
|
||||
}
|
||||
|
||||
let registryGroups = Dictionary(grouping: remoteNames, by: {
|
||||
RegistryIdentifier(host: $0.host, namespace: $0.namespace)
|
||||
})
|
||||
let registryGroups = Dictionary(grouping: remoteNames, by: {
|
||||
RegistryIdentifier(host: $0.host, namespace: $0.namespace)
|
||||
})
|
||||
|
||||
// Push VM
|
||||
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
||||
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
|
||||
insecure: insecure)
|
||||
// Push VM
|
||||
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
||||
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
|
||||
insecure: insecure)
|
||||
|
||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||
|
||||
let pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||
registry: registry,
|
||||
references: remoteNamesForRegistry.map{ $0.reference.value },
|
||||
chunkSizeMb: chunkSize
|
||||
)
|
||||
let pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||
registry: registry,
|
||||
references: remoteNamesForRegistry.map{ $0.reference.value },
|
||||
chunkSizeMb: chunkSize
|
||||
)
|
||||
|
||||
// Populate the local cache (if requested)
|
||||
if populateCache {
|
||||
let ociStorage = VMStorageOCI()
|
||||
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
||||
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
||||
for remoteName in remoteNamesForRegistry {
|
||||
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
||||
}
|
||||
// Populate the local cache (if requested)
|
||||
if populateCache {
|
||||
let ociStorage = VMStorageOCI()
|
||||
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
||||
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
||||
for remoteName in remoteNamesForRegistry {
|
||||
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
||||
}
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,24 +17,16 @@ struct Rename: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let localStorage = VMStorageLocal()
|
||||
let localStorage = VMStorageLocal()
|
||||
|
||||
if !localStorage.exists(name) {
|
||||
throw ValidationError("failed to rename a non-existent VM: \(name)")
|
||||
}
|
||||
|
||||
if localStorage.exists(newName) {
|
||||
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
|
||||
}
|
||||
|
||||
try localStorage.rename(name, newName)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
if !localStorage.exists(name) {
|
||||
throw ValidationError("failed to rename a non-existent VM: \(name)")
|
||||
}
|
||||
|
||||
if localStorage.exists(newName) {
|
||||
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
|
||||
}
|
||||
|
||||
try localStorage.rename(name, newName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Sentry
|
||||
|
||||
struct ReportInstallation: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(
|
||||
commandName: "report-installation",
|
||||
abstract: "Send installation event to Sentry if configured",
|
||||
discussion: """
|
||||
Reports macOS version and device model for analytics purposes.
|
||||
Helps Cirrus Labs team to prioritize testing on most popular devices.
|
||||
""",
|
||||
shouldDisplay: false
|
||||
)
|
||||
|
||||
func run() async throws {
|
||||
let installationEvent = Event()
|
||||
installationEvent.message = SentryMessage(formatted: "installed")
|
||||
installationEvent.level = SentryLevel.info
|
||||
installationEvent.user = nil
|
||||
installationEvent.stacktrace = nil
|
||||
let id = SentrySDK.capture(event: installationEvent)
|
||||
print("Captured installation event #\(id)!")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
import Sentry
|
||||
|
||||
var vm: VM?
|
||||
|
||||
@@ -37,9 +38,6 @@ struct Run: AsyncParsableCommand {
|
||||
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
||||
var vncExperimental: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(visibility: .private))
|
||||
var withSoftnet: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
|
||||
""", discussion: """
|
||||
@@ -87,11 +85,6 @@ struct Run: AsyncParsableCommand {
|
||||
if vnc && vncExperimental {
|
||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||
}
|
||||
|
||||
if withSoftnet && netBridged != nil {
|
||||
throw ValidationError("--with-softnet and --net-bridged are mutually exclusive")
|
||||
}
|
||||
|
||||
if netBridged != nil && netSoftnet {
|
||||
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
|
||||
}
|
||||
@@ -116,9 +109,8 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
if try !FileLock(lockURL: additionalDiskAttachment.url).trylock() {
|
||||
print("disk \(additionalDiskAttachment.url.path) seems to be already in use, "
|
||||
throw RuntimeError.DiskAlreadyInUse("disk \(additionalDiskAttachment.url.path) seems to be already in use, "
|
||||
+ "unmount it first in Finder")
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,8 +146,7 @@ struct Run: AsyncParsableCommand {
|
||||
// [1]: https://man.openbsd.org/fcntl
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
if try !lock.trylock() {
|
||||
print("Virtual machine \"\(name)\" is already running!")
|
||||
Foundation.exit(2)
|
||||
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
|
||||
}
|
||||
|
||||
let task = Task {
|
||||
@@ -179,7 +170,12 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
// Capture the error into Sentry
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -199,7 +195,7 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
|
||||
if withSoftnet || netSoftnet {
|
||||
if netSoftnet {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
return try Softnet(vmMACAddress: config.macAddress.string)
|
||||
|
||||
@@ -20,38 +20,30 @@ struct Set: AsyncParsableCommand {
|
||||
var diskSize: UInt16?
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
|
||||
if let cpu = cpu {
|
||||
try vmConfig.setCPU(cpuCount: Int(cpu))
|
||||
if let cpu = cpu {
|
||||
try vmConfig.setCPU(cpuCount: Int(cpu))
|
||||
}
|
||||
|
||||
if let memory = memory {
|
||||
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
|
||||
}
|
||||
|
||||
if let display = display {
|
||||
if (display.width > 0) {
|
||||
vmConfig.display.width = display.width
|
||||
}
|
||||
|
||||
if let memory = memory {
|
||||
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
|
||||
if (display.height > 0) {
|
||||
vmConfig.display.height = display.height
|
||||
}
|
||||
}
|
||||
|
||||
if let display = display {
|
||||
if (display.width > 0) {
|
||||
vmConfig.display.width = display.width
|
||||
}
|
||||
if (display.height > 0) {
|
||||
vmConfig.display.height = display.height
|
||||
}
|
||||
}
|
||||
try vmConfig.save(toURL: vmDir.configURL)
|
||||
|
||||
try vmConfig.save(toURL: vmDir.configURL)
|
||||
|
||||
if diskSize != nil {
|
||||
try vmDir.resizeDisk(diskSize!)
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
if diskSize != nil {
|
||||
try vmDir.resizeDisk(diskSize!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,60 +13,48 @@ struct Stop: AsyncParsableCommand {
|
||||
var timeout: UInt64 = 30
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
|
||||
// Find the VM's PID
|
||||
var pid = try lock.pid()
|
||||
// Find the VM's PID
|
||||
var pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||
}
|
||||
|
||||
// Try to gracefully terminate the VM
|
||||
//
|
||||
// Note that we don't check the return code here
|
||||
// to provide a clean exit from "tart stop" in cases
|
||||
// when the VM is already shutting down and we hit
|
||||
// a race condition.
|
||||
//
|
||||
// We check the return code in the kill(2) below, though,
|
||||
// because it's a less common scenario and it would be
|
||||
// nice to know for the user that we've tried all methods
|
||||
// and failed to shutdown the VM.
|
||||
kill(pid, SIGINT)
|
||||
|
||||
// Ensure that the VM has terminated
|
||||
var gracefulWaitDuration = Measurement(value: Double(timeout), unit: UnitDuration.seconds)
|
||||
let gracefulTickDuration = Measurement(value: Double(100), unit: UnitDuration.milliseconds)
|
||||
|
||||
while gracefulWaitDuration.value > 0 {
|
||||
pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
print("VM \(name) is not running")
|
||||
|
||||
Foundation.exit(2)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to gracefully terminate the VM
|
||||
//
|
||||
// Note that we don't check the return code here
|
||||
// to provide a clean exit from "tart stop" in cases
|
||||
// when the VM is already shutting down and we hit
|
||||
// a race condition.
|
||||
//
|
||||
// We check the return code in the kill(2) below, though,
|
||||
// because it's a less common scenario and it would be
|
||||
// nice to know for the user that we've tried all methods
|
||||
// and failed to shutdown the VM.
|
||||
kill(pid, SIGINT)
|
||||
try await Task.sleep(nanoseconds: UInt64(gracefulTickDuration.converted(to: .nanoseconds).value))
|
||||
gracefulWaitDuration = gracefulWaitDuration - gracefulTickDuration
|
||||
}
|
||||
|
||||
// Ensure that the VM has terminated
|
||||
var gracefulWaitDuration = Measurement(value: Double(timeout), unit: UnitDuration.seconds)
|
||||
let gracefulTickDuration = Measurement(value: Double(100), unit: UnitDuration.milliseconds)
|
||||
// Seems that VM is still running, proceed with forceful termination
|
||||
let ret = kill(pid, SIGKILL)
|
||||
if ret != 0 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
while gracefulWaitDuration.value > 0 {
|
||||
pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
Foundation.exit(0)
|
||||
}
|
||||
|
||||
try await Task.sleep(nanoseconds: UInt64(gracefulTickDuration.converted(to: .nanoseconds).value))
|
||||
gracefulWaitDuration = gracefulWaitDuration - gracefulTickDuration
|
||||
}
|
||||
|
||||
// Seems that VM is still running, proceed with forceful termination
|
||||
let ret = kill(pid, SIGKILL)
|
||||
if ret != 0 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
print("failed to forcefully terminate the VM \(name): \(details)")
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
throw RuntimeError.VMTerminationFailed("failed to forcefully terminate the VM \"\(name)\": \(details)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,9 @@ struct ARPCacheInternalError: Error, CustomStringConvertible {
|
||||
}
|
||||
|
||||
struct ARPCache {
|
||||
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||
let arpCommandOutput: Data
|
||||
|
||||
init() throws {
|
||||
let process = Process.init()
|
||||
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
||||
process.arguments = ["-an"]
|
||||
@@ -58,10 +60,15 @@ struct ARPCache {
|
||||
terminationStatus: process.terminationStatus)
|
||||
}
|
||||
|
||||
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
|
||||
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||
}
|
||||
let lines = String(decoding: rawLines, as: UTF8.self)
|
||||
|
||||
self.arpCommandOutput = arpCommandOutput
|
||||
}
|
||||
|
||||
func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||
let lines = String(decoding: arpCommandOutput, as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: "\n")
|
||||
|
||||
|
||||
@@ -69,15 +69,19 @@ class Softnet: Network {
|
||||
}
|
||||
|
||||
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
|
||||
var option_value = sizeBytes
|
||||
let option_len = socklen_t(MemoryLayout<Int>.size)
|
||||
|
||||
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &option_value, option_len)
|
||||
// The system expects the value of SO_RCVBUF to be at least double the value of SO_SNDBUF,
|
||||
// and for optimal performance, the recommended value of SO_RCVBUF is four times the value of SO_SNDBUF.
|
||||
// See: https://developer.apple.com/documentation/virtualization/vzfilehandlenetworkdeviceattachment/3969266-maximumtransmissionunit
|
||||
var receiveBufferSize = 4 * sizeBytes
|
||||
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &receiveBufferSize, option_len)
|
||||
if ret != 0 {
|
||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)")
|
||||
}
|
||||
|
||||
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &option_value, option_len)
|
||||
var sendBufferSize = sizeBytes
|
||||
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sendBufferSize, option_len)
|
||||
if ret != 0 {
|
||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ struct RemoteName: Comparable, Hashable, CustomStringConvertible {
|
||||
try ParseTreeWalker().walk(referenceCollector, try parser.root())
|
||||
|
||||
if let error = errorCollector.error {
|
||||
throw RuntimeError("failed to parse remote name: \(error)")
|
||||
throw RuntimeError.FailedToParseRemoteName("\(error)")
|
||||
}
|
||||
|
||||
host = referenceCollector.host!
|
||||
@@ -120,7 +120,7 @@ struct RemoteName: Comparable, Hashable, CustomStringConvertible {
|
||||
} else if reference.starts(with: ":") {
|
||||
self.reference = Reference(tag: String(reference.dropFirst(1)))
|
||||
} else {
|
||||
throw RuntimeError("failed to parse remote name: unknown reference format")
|
||||
throw RuntimeError.FailedToParseRemoteName("unknown reference format")
|
||||
}
|
||||
} else {
|
||||
self.reference = Reference(tag: "latest")
|
||||
|
||||
@@ -44,7 +44,7 @@ class PIDLock {
|
||||
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError("\(message): \(details)")
|
||||
throw RuntimeError.PIDLockFailed("\(message): \(details)")
|
||||
}
|
||||
|
||||
return (true, result)
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import ArgumentParser
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Puppy
|
||||
|
||||
var puppy = Puppy.default
|
||||
|
||||
class LogFormatter: LogFormattable {
|
||||
func formatMessage(_ level: LogLevel, message: String, tag: String, function: String, file: String, line: UInt, swiftLogInfo: [String: String], label: String, date: Date, threadID: UInt64) -> String {
|
||||
"\(date) \(level) \(message)"
|
||||
}
|
||||
}
|
||||
import Sentry
|
||||
|
||||
@main
|
||||
struct Root: AsyncParsableCommand {
|
||||
@@ -26,13 +19,45 @@ struct Root: AsyncParsableCommand {
|
||||
IP.self,
|
||||
Pull.self,
|
||||
Push.self,
|
||||
Import.self,
|
||||
Export.self,
|
||||
Prune.self,
|
||||
Rename.self,
|
||||
Stop.self,
|
||||
Delete.self,
|
||||
ReportInstallation.self,
|
||||
])
|
||||
|
||||
public static func main() async throws {
|
||||
// Initialize Sentry
|
||||
if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
|
||||
SentrySDK.start { options in
|
||||
options.dsn = dsn
|
||||
options.releaseName = CI.release
|
||||
options.tracesSampleRate = Float(
|
||||
ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
|
||||
) as NSNumber?
|
||||
|
||||
// By default only 5XX are captured
|
||||
// Let's capture everything but 401 (unauthorized)
|
||||
options.enableCaptureFailedRequests = true
|
||||
options.failedRequestStatusCodes = [
|
||||
HttpStatusCodeRange(min: 400, max: 400),
|
||||
HttpStatusCodeRange(min: 402, max: 599)
|
||||
]
|
||||
}
|
||||
}
|
||||
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
||||
|
||||
// Enrich future events with Cirrus CI-specific tags
|
||||
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
||||
SentrySDK.configureScope { scope in
|
||||
for (key, value) in tags.split(separator: ",").compactMap({ parseCirrusSentryTag($0) }) {
|
||||
scope.setTag(value: value, key: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the default SIGINT handled is disabled,
|
||||
// otherwise there's a race between two handlers
|
||||
signal(SIGINT, SIG_IGN);
|
||||
@@ -47,18 +72,16 @@ struct Root: AsyncParsableCommand {
|
||||
// Set line-buffered output for stdout
|
||||
setlinebuf(stdout)
|
||||
|
||||
// Initialize file logger
|
||||
let logFileURL = try Config().tartHomeDir.appendingPathComponent("tart.log")
|
||||
let fileLogger = try FileLogger("org.cirruslabs.tart", fileURL: logFileURL)
|
||||
fileLogger.format = LogFormatter()
|
||||
puppy.add(fileLogger)
|
||||
|
||||
// Parse and run command
|
||||
do {
|
||||
var command = try parseAsRoot()
|
||||
|
||||
// Run garbage-collection before each command (shouldn't take too long)
|
||||
try Config().gc()
|
||||
do {
|
||||
try Config().gc()
|
||||
} catch {
|
||||
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
|
||||
}
|
||||
|
||||
if var asyncCommand = command as? AsyncParsableCommand {
|
||||
try await asyncCommand.run()
|
||||
@@ -66,7 +89,28 @@ struct Root: AsyncParsableCommand {
|
||||
try command.run()
|
||||
}
|
||||
} catch {
|
||||
// Capture the error into Sentry
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
|
||||
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
|
||||
if let errorWithExitCode = error as? HasExitCode {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(errorWithExitCode.exitCode)
|
||||
}
|
||||
|
||||
// Handle any other exception, including ArgumentParser's ones
|
||||
exit(withError: error)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseCirrusSentryTag(_ tag: String.SubSequence) -> (String, String)? {
|
||||
let splits = tag.split(separator: "=", maxSplits: 1)
|
||||
if splits.count != 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (String(splits[0]), String(splits[1]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ extension URL {
|
||||
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
||||
let ret = utimes(path, times)
|
||||
if ret != 0 {
|
||||
throw RuntimeError("utimes(2) failed: \(ret.explanation())")
|
||||
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(ret.explanation())")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ struct VMDisplayConfig: Codable {
|
||||
var height: Int = 768
|
||||
}
|
||||
|
||||
extension VMDisplayConfig: CustomStringConvertible {
|
||||
var description: String {
|
||||
"\(width)x\(height)"
|
||||
}
|
||||
}
|
||||
|
||||
struct VMConfig: Codable {
|
||||
var version: Int = 1
|
||||
var os: OS
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Compression
|
||||
import Sentry
|
||||
|
||||
enum OCIError: Error {
|
||||
case ShouldBeExactlyOneLayer
|
||||
@@ -77,6 +78,7 @@ extension VMDirectory {
|
||||
}
|
||||
try filter.finalize()
|
||||
try disk.close()
|
||||
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||
|
||||
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
||||
defaultLogger.appendNewLine("pulling NVRAM...")
|
||||
|
||||
@@ -41,7 +41,7 @@ struct VMDirectory: Prunable {
|
||||
|
||||
func initialize(overwrite: Bool = false) throws {
|
||||
if !overwrite && initialized {
|
||||
throw RuntimeError("VM directory is already initialized, preventing overwrite")
|
||||
throw RuntimeError.VMDirectoryAlreadyInitialized("VM directory is already initialized, preventing overwrite")
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
|
||||
@@ -53,11 +53,11 @@ struct VMDirectory: Prunable {
|
||||
|
||||
func validate() throws {
|
||||
if !FileManager.default.fileExists(atPath: baseURL.path) {
|
||||
throw RuntimeError("the specified VM does not exist")
|
||||
throw RuntimeError.VMDoesNotExist(name: baseURL.lastPathComponent)
|
||||
}
|
||||
|
||||
if !initialized {
|
||||
throw RuntimeError("VM is missing some of its files (\(configURL.lastPathComponent),"
|
||||
throw RuntimeError.VMMissingFiles("VM is missing some of its files (\(configURL.lastPathComponent),"
|
||||
+ " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))")
|
||||
}
|
||||
}
|
||||
@@ -68,11 +68,21 @@ struct VMDirectory: Prunable {
|
||||
try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
|
||||
|
||||
// Re-generate MAC address
|
||||
var newVMConfig = try VMConfig(fromURL: to.configURL)
|
||||
if generateMAC {
|
||||
newVMConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||
try to.regenerateMACAddress()
|
||||
}
|
||||
try newVMConfig.save(toURL: to.configURL)
|
||||
}
|
||||
|
||||
func macAddress() throws -> String {
|
||||
try VMConfig(fromURL: configURL).macAddress.string
|
||||
}
|
||||
|
||||
func regenerateMACAddress() throws {
|
||||
var vmConfig = try VMConfig(fromURL: configURL)
|
||||
|
||||
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||
|
||||
try vmConfig.save(toURL: configURL)
|
||||
}
|
||||
|
||||
func resizeDisk(_ sizeGB: UInt16) throws {
|
||||
@@ -97,6 +107,10 @@ struct VMDirectory: Prunable {
|
||||
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||
}
|
||||
|
||||
func sizeGB() throws -> Int {
|
||||
try sizeBytes() / 1000 / 1000 / 1000
|
||||
}
|
||||
|
||||
func markExplicitlyPulled() {
|
||||
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class VMStorageHelper {
|
||||
return try closure()
|
||||
} catch {
|
||||
if error.isFileNotFound() {
|
||||
throw RuntimeError("source VM \"\(name)\" not found, is it listed in \"tart list\"?")
|
||||
throw RuntimeError.VMDoesNotExist(name: name)
|
||||
}
|
||||
|
||||
throw error
|
||||
@@ -40,14 +40,82 @@ extension Error {
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeError: Error, CustomStringConvertible {
|
||||
let message: String
|
||||
enum RuntimeError : Error {
|
||||
case VMDoesNotExist(name: String)
|
||||
case VMMissingFiles(_ message: String)
|
||||
case VMNotRunning(_ message: String)
|
||||
case VMAlreadyRunning(_ message: String)
|
||||
case NoIPAddressFound(_ message: String)
|
||||
case DiskAlreadyInUse(_ message: String)
|
||||
case FailedToUpdateAccessDate(_ message: String)
|
||||
case PIDLockFailed(_ message: String)
|
||||
case FailedToParseRemoteName(_ message: String)
|
||||
case VMTerminationFailed(_ message: String)
|
||||
case InvalidCredentials(_ message: String)
|
||||
case VMDirectoryAlreadyInitialized(_ message: String)
|
||||
case ExportFailed(_ message: String)
|
||||
case ImportFailed(_ message: String)
|
||||
}
|
||||
|
||||
init(_ message: String) {
|
||||
self.message = message
|
||||
}
|
||||
protocol HasExitCode {
|
||||
var exitCode: Int32 { get }
|
||||
}
|
||||
|
||||
var description: String {
|
||||
message
|
||||
extension RuntimeError : CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .VMDoesNotExist(let name):
|
||||
return "the specified VM \"\(name)\" does not exist"
|
||||
case .VMMissingFiles(let message):
|
||||
return message
|
||||
case .VMNotRunning(let message):
|
||||
return message
|
||||
case .VMAlreadyRunning(let message):
|
||||
return message
|
||||
case .NoIPAddressFound(let message):
|
||||
return message
|
||||
case .DiskAlreadyInUse(let message):
|
||||
return message
|
||||
case .FailedToUpdateAccessDate(let message):
|
||||
return message
|
||||
case .PIDLockFailed(let message):
|
||||
return message
|
||||
case .FailedToParseRemoteName(let cause):
|
||||
return "failed to parse remote name: \(cause)"
|
||||
case .VMTerminationFailed(let message):
|
||||
return message
|
||||
case .InvalidCredentials(let message):
|
||||
return message
|
||||
case .VMDirectoryAlreadyInitialized(let message):
|
||||
return message
|
||||
case .ExportFailed(let message):
|
||||
return "VM export failed: \(message)"
|
||||
case .ImportFailed(let message):
|
||||
return "VM import failed: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RuntimeError : HasExitCode {
|
||||
var exitCode: Int32 {
|
||||
switch self {
|
||||
case .VMNotRunning:
|
||||
return 2
|
||||
case .VMAlreadyRunning:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Customize error description for Sentry[1]
|
||||
//
|
||||
// [1]: https://docs.sentry.io/platforms/apple/guides/ios/usage/#customizing-error-descriptions
|
||||
extension RuntimeError : CustomNSError {
|
||||
var errorUserInfo: [String : Any] {
|
||||
[
|
||||
NSDebugDescriptionErrorKey: description,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,4 +62,8 @@ class VMStorageLocal {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
||||
try list().contains { try $1.macAddress() == macAddress }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Sentry
|
||||
|
||||
class VMStorageOCI: PrunableStorage {
|
||||
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
@@ -148,6 +149,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
if !exists(digestName) {
|
||||
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
@@ -164,16 +166,25 @@ class VMStorageOCI: PrunableStorage {
|
||||
let availableCapacityBytes = max(UInt64(capacityImportant), UInt64(capacityAvailable))
|
||||
|
||||
if capacityImportant == 0 || capacityAvailable == 0 {
|
||||
puppy.warning("important capacity \(capacityImportant) bytes, "
|
||||
+ "available capacity is \(capacityAvailable) bytes")
|
||||
SentrySDK.capture(message: "Zero capacity") { scope in
|
||||
scope.setLevel(.warning)
|
||||
|
||||
scope.setContext(value: [
|
||||
"volumeAvailableCapacityForImportantUsageKey": capacityImportant,
|
||||
"volumeAvailableCapacityKey": capacityAvailable,
|
||||
], key: "Attributes")
|
||||
}
|
||||
}
|
||||
|
||||
// There is a suspicious that occasionally capacity is returned as zero which can't be true.
|
||||
// Let's validate to avoid unnecessary pruning.
|
||||
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
|
||||
puppy.info("pruning cache to accommodate \(name) with a disk of size \(uncompressedDiskSize) bytes ("
|
||||
+ "available capacity is \(availableCapacityBytes) bytes, required capacity "
|
||||
+ "is \(requiredCapacityBytes) bytes)")
|
||||
let transaction = SentrySDK.startTransaction(name: "Automatically Pruning Cache", operation: "prune", bindToScope: true)
|
||||
transaction.setData(value: name, key: "name")
|
||||
transaction.setData(value: uncompressedDiskSize, key: "uncompressedDiskSize")
|
||||
transaction.setData(value: availableCapacityBytes, key: "availableCapacity")
|
||||
transaction.setData(value: requiredCapacityBytes, key: "requiredCapacity")
|
||||
defer { transaction.finish() }
|
||||
|
||||
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
||||
}
|
||||
@@ -182,7 +193,9 @@ class VMStorageOCI: PrunableStorage {
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
||||
try move(digestName, from: tmpVMDir)
|
||||
transaction.finish()
|
||||
}, onCancel: {
|
||||
transaction.finish(status: SentrySpanStatus.cancelled)
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
} else {
|
||||
@@ -191,7 +204,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
if name != digestName {
|
||||
// Create new or overwrite the old symbolic link
|
||||
try link(from: digestName, to: name)
|
||||
try link(from: name, to: digestName)
|
||||
} else {
|
||||
// Ensure that images pulled by content digest
|
||||
// are excluded from garbage collection
|
||||
@@ -200,11 +213,11 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
func link(from: RemoteName, to: RemoteName) throws {
|
||||
if FileManager.default.fileExists(atPath: vmURL(to).path) {
|
||||
try FileManager.default.removeItem(at: vmURL(to))
|
||||
if FileManager.default.fileExists(atPath: vmURL(from).path) {
|
||||
try FileManager.default.removeItem(at: vmURL(from))
|
||||
}
|
||||
|
||||
try FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
|
||||
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
||||
|
||||
try gc()
|
||||
}
|
||||
|
||||
@@ -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'
|
||||