Compare commits

..
Author SHA1 Message Date
Fedor Korotkov a65ab5a712 Claude Code Review workflow 2025-07-10 20:23:26 +04:00
Fedor Korotkov 3f7b6d2a61 Claude PR Assistant workflow 2025-07-10 20:23:25 +04:00
123 changed files with 1645 additions and 2171 deletions
-24
View File
@@ -1,24 +0,0 @@
#!/bin/sh
set -eu
ARCH="$1"
SCRATCH_PATH=".build/$ARCH"
OUTPUT_PATH=".build/prebuilt/$ARCH"
swift build \
--build-system swiftbuild \
--scratch-path "$SCRATCH_PATH" \
--arch "$ARCH" \
--configuration release \
--product tart
BIN_PATH=$(swift build \
--build-system swiftbuild \
--scratch-path "$SCRATCH_PATH" \
--arch "$ARCH" \
--configuration release \
--show-bin-path)
mkdir -p "$OUTPUT_PATH"
cp "$BIN_PATH/tart" "$OUTPUT_PATH/tart"
+1 -1
View File
@@ -2,7 +2,7 @@
set -e
export VERSION="${VERSION:-0}"
export VERSION="${CIRRUS_TAG:-0}"
mkdir -p .ci/pkg/
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
+3 -7
View File
@@ -1,11 +1,7 @@
#!/bin/sh
set -e
: "${VERSION:?VERSION must be set}"
TMPFILE=$(mktemp)
perl -pe 's/\$\{VERSION\}/$ENV{VERSION}/g' Sources/tart/CI/CI.swift > "$TMPFILE"
mv "$TMPFILE" Sources/tart/CI/CI.swift
envsubst < Sources/tart/CI/CI.swift > $TMPFILE
mv $TMPFILE Sources/tart/CI/CI.swift
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string ${VERSION}" Resources/Info.plist
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string ${CIRRUS_TAG}" Resources/Info.plist
-24
View File
@@ -1,24 +0,0 @@
#!/bin/sh
set -eu
if [ "${TART_RELEASE_SNAPSHOT:-false}" = "true" ]; then
codesign \
--force \
--deep \
--sign - \
--entitlements Resources/tart-dev.entitlements \
dist/tart_darwin_all/tart.app
else
codesign \
--force \
--verbose \
--sign "Developer ID Application: Cirrus Labs, Inc. (9M2P8L4D89)" \
--timestamp \
--options runtime \
--keychain "$RUNNER_TEMP/build.keychain" \
--entitlements Resources/tart-prod.entitlements \
dist/tart_darwin_all/tart.app
codesign --verify --strict --verbose=2 dist/tart_darwin_all/tart.app
fi
+90 -7
View File
@@ -1,7 +1,7 @@
use_compute_credits: true
task:
name: Test
name: Test on Sequoia
alias: test
persistent_worker:
labels:
@@ -11,8 +11,6 @@ task:
build_script:
- swift build
test_script:
# Add /usr/sbin to PATH, otherwise testDiskutilInfo() fails to locate "diskutil"
- export PATH=$PATH:/usr/sbin
- swift test
integration_test_script:
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
@@ -23,7 +21,6 @@ task:
- source venv/bin/activate
- pip install -r requirements.txt
- pytest --verbose --junit-xml=pytest-junit.xml
- go test -v ./...
pytest_junit_result_artifacts:
path: "integration-tests/pytest-junit.xml"
format: junit
@@ -40,7 +37,7 @@ task:
name: Lint
alias: lint
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:tahoe
image: ghcr.io/cirruslabs/macos-runner:sequoia
lint_script:
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
always:
@@ -57,17 +54,103 @@ task:
name: Build ($BUILD_ARCH)
alias: build
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:tahoe
image: ghcr.io/cirruslabs/macos-runner:sequoia
build_script: swift build --arch $BUILD_ARCH --product tart
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/$BUILD_ARCH-apple-macosx/debug/tart
binary_artifacts:
path: .build/$BUILD_ARCH-apple-macosx/debug/tart
task:
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
name: Release (Dry Run)
depends_on:
- lint
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sequoia
env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
setup_script:
- cd $HOME
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
- security create-keychain -p password101 build.keychain
- security default-keychain -s build.keychain
- security unlock-keychain -p password101 build.keychain
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
- 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
- brew install mitchellh/gon/gon
- brew install --cask goreleaser/tap/goreleaser-pro
info_script:
- security find-identity -v
- xcodebuild -version
- swift -version
goreleaser_script: goreleaser release --skip=publish --snapshot --clean
always:
dist_artifacts:
path: "dist/*"
task:
name: Release
only_if: $CIRRUS_TAG != ''
depends_on:
- lint
- test
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-runner:sequoia
env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
SENTRY_ORG: cirrus-labs
SENTRY_PROJECT: persistent-workers
SENTRY_AUTH_TOKEN: ENCRYPTED[!9eaf2875d51b113e2f68598441ff8e6b2e53242e48fcb93633bd75a373fbe2e7caa900d837cc92f0b142b65579731644!]
setup_script:
- cd $HOME
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
- security create-keychain -p password101 build.keychain
- security default-keychain -s build.keychain
- security unlock-keychain -p password101 build.keychain
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
- 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 getsentry/tools/sentry-cli
- brew install mitchellh/gon/gon
- brew install --cask goreleaser/tap/goreleaser-pro
info_script:
- security find-identity -v
- xcodebuild -version
- swift -version
release_script: goreleaser
upload_sentry_debug_files_script:
- cd .build/arm64-apple-macosx/release/
# Generate and upload symbols
- dsymutil tart
- sentry-cli debug-files upload tart.dSYM/
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.dSYM/
# Bundle and upload sources
- sentry-cli debug-files bundle-sources tart.dSYM
- sentry-cli debug-files upload tart.src.zip
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.src.zip
create_sentry_release_script:
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
- sentry-cli releases new $SENTRY_RELEASE
- 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
image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
env:
DEPLOY_TOKEN: ENCRYPTED[!45ed45666558902ed1c2400add734ec063103bec31841847e8c8764802fca229bfa6d85c690e16ad159e047574b48793!]
-5
View File
@@ -4,8 +4,3 @@ root = true
indent_style = space
indent_size = 2
insert_final_newline = true
[integration-tests/**]
indent_style = unset
indent_size = unset
insert_final_newline = unset
-37
View File
@@ -1,37 +0,0 @@
name: Build
on:
workflow_dispatch:
permissions:
contents: read
jobs:
build_cached:
name: Build tart (cached)
runs-on: macos-26
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- name: Build
run: |
export COMPILATION_CACHE_ENABLE_CACHING=YES
export COMPILATION_CACHE_REMOTE_SERVICE_PATH="$HOME/.cirruslabs/omni-cache.sock"
export COMPILATION_CACHE_ENABLE_PLUGIN=YES
export COMPILATION_CACHE_ENABLE_INTEGRATED_QUERIES=YES
export COMPILATION_CACHE_ENABLE_DETACHED_KEY_QUERIES=YES
export SWIFT_ENABLE_COMPILE_CACHE=YES
export SWIFT_ENABLE_EXPLICIT_MODULES=YES
export SWIFT_USE_INTEGRATED_DRIVER=YES
export CLANG_ENABLE_COMPILE_CACHE=YES
export CLANG_ENABLE_MODULES=YES
swift build --build-system swiftbuild --product tart
build_no_cache:
name: Build tart (no cache)
runs-on: macos-26
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- name: Build
run: swift build --build-system swiftbuild --product tart
-37
View File
@@ -1,37 +0,0 @@
name: CI
on:
merge_group:
pull_request:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
jobs:
test:
name: Test
runs-on: macos-26
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: integration-tests/go.mod
cache-dependency-path: integration-tests/go.sum
- name: Build
run: swift build --build-system swiftbuild
- name: Run unit tests
run: |
export PATH="$PATH:/usr/sbin"
swift test --build-system swiftbuild
# The Python suite boots Tart VMs, but hosted ARM macOS runners do not support nested virtualization.
- name: Run OpenTelemetry integration tests
run: |
bin_path="$(swift build --build-system swiftbuild --show-bin-path)"
codesign --sign - --entitlements Resources/tart-dev.entitlements --force "$bin_path/tart"
cd integration-tests
PATH="$bin_path:$PATH" go test -v ./...
+78
View File
@@ -0,0 +1,78 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
# model: "claude-opus-4-20250514"
# Direct prompt for automated review (no @claude mention needed)
direct_prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Be constructive and helpful in your feedback.
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
# use_sticky_comment: true
# Optional: Customize review based on file types
# direct_prompt: |
# Review this PR focusing on:
# - For TypeScript files: Type safety and proper interface usage
# - For API endpoints: Security, input validation, and error handling
# - For React components: Performance, accessibility, and best practices
# - For tests: Coverage, edge cases, and test quality
# Optional: Different prompts for different authors
# direct_prompt: |
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
# Optional: Add specific tools for running tests or linting
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
# Optional: Skip review for certain conditions
# if: |
# !contains(github.event.pull_request.title, '[skip-review]') &&
# !contains(github.event.pull_request.title, '[WIP]')
+64
View File
@@ -0,0 +1,64 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
# model: "claude-opus-4-20250514"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test
-105
View File
@@ -1,105 +0,0 @@
name: Release
on:
push:
tags:
- "*"
workflow_dispatch:
permissions:
contents: read
jobs:
release:
if: github.event_name == 'push' && github.repository == 'openai/tart'
name: Release
runs-on: macos-26
environment: publish
timeout-minutes: 90
permissions:
contents: read
env:
VERSION: ${{ github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Import signing certificate
env:
KEYCHAIN_PASSWORD: temporary-password
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
P12_PASSWORD: password101
run: |
echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/build.keychain"
security set-keychain-settings -lut 21600 "$RUNNER_TEMP/build.keychain"
security default-keychain -s "$RUNNER_TEMP/build.keychain"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/build.keychain"
security import "$RUNNER_TEMP/certificate.p12" \
-k "$RUNNER_TEMP/build.keychain" \
-P "$P12_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/pkgbuild
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s \
-k "$KEYCHAIN_PASSWORD" \
"$RUNNER_TEMP/build.keychain"
security list-keychain -d user -s "$RUNNER_TEMP/build.keychain"
- name: Create release app token for this repo
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
permission-contents: write
- name: Create release app token for homebrew-tools
id: tap-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
owner: openai
repositories: homebrew-tools
permission-contents: write
permission-pull-requests: write
- name: Release
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
with:
distribution: goreleaser-pro
version: "~> v2"
args: release --clean
env:
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }}
snapshot:
if: github.event_name == 'workflow_dispatch'
name: Release (Dry Run)
runs-on: macos-26
timeout-minutes: 90
permissions:
contents: read
env:
TART_RELEASE_SNAPSHOT: "true"
VERSION: snapshot
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Build snapshot
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
with:
distribution: goreleaser-pro
version: "~> v2"
args: release --skip=publish --snapshot --clean
- name: Upload snapshot artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: tart-snapshot
path: dist/*
+20 -36
View File
@@ -5,8 +5,8 @@ project_name: tart
before:
hooks:
- .ci/set-version.sh
- sh .ci/build-release.sh arm64
- sh .ci/build-release.sh x86_64
- swift build --arch arm64 --configuration release --product tart
- swift build --arch x86_64 --configuration release --product tart
builds:
- id: tart
@@ -19,33 +19,26 @@ builds:
- amd64
binary: tart.app/Contents/MacOS/tart
prebuilt:
path: '.build/prebuilt/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}/tart'
path: '.build/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}-apple-macosx/release/tart'
universal_binaries:
- name_template: tart.app/Contents/MacOS/tart
replace: true
hooks:
post:
- mkdir -p dist/tart_darwin_all/tart.app/Contents/Resources
- cp Resources/embedded.provisionprofile dist/tart_darwin_all/tart.app/Contents/
- cp Resources/Info.plist dist/tart_darwin_all/tart.app/Contents/
- cp "Resources/actool/UPW Tart.icns" "Resources/actool/Assets.car" dist/tart_darwin_all/tart.app/Contents/Resources/
- cmd: .ci/sign-release.sh
output: true
post: gon gon.hcl
archives:
- name_template: "{{ .ProjectName }}"
files:
- src: dist/tart_darwin_all/tart.app/Contents/Info.plist
dst: tart.app/Contents/Info.plist
- src: dist/tart_darwin_all/tart.app/Contents/embedded.provisionprofile
dst: tart.app/Contents/embedded.provisionprofile
- src: dist/tart_darwin_all/tart.app/Contents/Resources/UPW Tart.icns
dst: tart.app/Contents/Resources/UPW Tart.icns
- src: dist/tart_darwin_all/tart.app/Contents/Resources/Assets.car
dst: tart.app/Contents/Resources/Assets.car
- src: dist/tart_darwin_all/tart.app/Contents/_CodeSignature/CodeResources
dst: tart.app/Contents/_CodeSignature/CodeResources
- src: Resources/embedded.provisionprofile
dst: tart.app/Contents
strip_parent: true
- src: Resources/Info.plist
dst: tart.app/Contents
strip_parent: true
- src: Resources/AppIcon.png
dst: tart.app/Contents/Resources
strip_parent: true
- LICENSE
release:
@@ -53,14 +46,9 @@ release:
brews:
- name: tart
directory: Formula
repository:
owner: openai
name: homebrew-tools
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
branch: "tart-{{ .Version }}"
pull_request:
enabled: true
owner: cirruslabs
name: homebrew-cli
caveats: |
Tart has been installed. You might want to reduce the default DHCP lease time
from 86,400 to 600 seconds to avoid DHCP shortage when running lots of VMs daily:
@@ -68,19 +56,15 @@ brews:
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.InternetSharing.default.plist bootpd -dict DHCPLeaseTimeSecs -int 600
See https://tart.run/faq/#changing-the-default-dhcp-lease-time for more details.
homepage: https://github.com/openai/tart
license: FSL-1.1-ALv2
homepage: https://github.com/cirruslabs/tart
license: "Fair Source"
description: Run macOS and Linux VMs on Apple Hardware
skip_upload: auto
dependencies:
- "openai/tools/softnet"
- "cirruslabs/cli/softnet"
install: |
libexec.install Dir["*"]
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
generate_completions_from_executable(libexec/"tart.app/Contents/MacOS/tart", "--generate-completion-script")
custom_block: |
on_macos do
depends_on :macos => :ventura
end
def post_install
generate_completions_from_executable(libexec/"tart.app/Contents/MacOS/tart", "--generate-completion-script")
end
depends_on :macos => :ventura
+1 -1
View File
@@ -20,7 +20,7 @@ Table of Contents
```
## How to Create an Issue/Enhancement
1. Go to the [Issue page](https://github.com/openai/tart/issues) of the repository
1. Go to the [Issue page](https://github.com/cirruslabs/tart/issues) of the repository
2. Click on the "New Issue" button
3. Provide a descriptive title and detailed description of the issue or enhancement you're suggesting
4. Submit the issue
+35 -95
View File
@@ -1,105 +1,45 @@
# Functional Source License, Version 1.1, ALv2 Future License
Fair Source License, version 0.9
## Abbreviation
Copyright (C) 2023 Cirrus Labs, Inc.
FSL-1.1-ALv2
Licensor: Cirrus Labs, Inc.
## Notice
Software: Tart
Copyright 2022-2026 OpenAI
Use Limitation: 100 users. User is defined as a single core of a central processing unit (CPU) used by the product.
The Use Limitation does not apply to CPUs installed in devices used by a single individual.
## Terms and Conditions
License Grant. Licensor hereby grants to each recipient of the
Software ("you") a non-exclusive, non-transferable, royalty-free and
fully-paid-up license, under all of the Licensor's copyright and
patent rights, to use, copy, distribute, prepare derivative works of,
publicly perform and display the Software, subject to the Use
Limitation and the conditions set forth below.
### Licensor ("We")
Use Limitation. The license granted above allows use by up to the
number of users per entity set forth above (the "Use Limitation"). For
determining the number of users, "you" includes all affiliates,
meaning legal entities controlling, controlled by, or under common
control with you. If you exceed the Use Limitation, your use is
subject to payment of Licensor's then-current list price for licenses.
The party offering the Software under these Terms and Conditions.
Conditions. Redistribution in source code or other forms must include
a copy of this license document to be provided in a reasonable
manner. Any redistribution of the Software is only allowed subject to
this license.
### The Software
Trademarks. This license does not grant you any right in the
trademarks, service marks, brand names or logos of Licensor.
The "Software" is each version of the software that we make available under
these Terms and Conditions, as indicated by our inclusion of these Terms and
Conditions with the Software.
DISCLAIMER. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OR
CONDITION, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. LICENSORS HEREBY DISCLAIM ALL LIABILITY, WHETHER IN
AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE.
### License Grant
Subject to your compliance with this License Grant and the Patents,
Redistribution and Trademark clauses below, we hereby grant you the right to
use, copy, modify, create derivative works, publicly perform, publicly display
and redistribute the Software for any Permitted Purpose identified below.
### Permitted Purpose
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
means making the Software available to others in a commercial product or
service that:
1. substitutes for the Software;
2. substitutes for any other product or service we offer using the Software
that exists as of the date we make the Software available; or
3. offers the same or substantially similar functionality as the Software.
Permitted Purposes specifically include using the Software:
1. for your internal use and access;
2. for non-commercial education;
3. for non-commercial research; and
4. in connection with professional services that you provide to a licensee
using the Software in accordance with these Terms and Conditions.
### Patents
To the extent your use for a Permitted Purpose would necessarily infringe our
patents, the license grant above includes a license under our patents. If you
make a claim against any party that the Software infringes or contributes to
the infringement of any patent, then your patent license to the Software ends
immediately.
### Redistribution
The Terms and Conditions apply to all copies, modifications and derivatives of
the Software.
If you redistribute any copies, modifications or derivatives of the Software,
you must include a copy of or a link to these Terms and Conditions and not
remove any copyright notices provided in or with the Software.
### Disclaimer
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
### Trademarks
Except for displaying the License Details and identifying us as the origin of
the Software, you have no right under these Terms and Conditions to use our
trademarks, trade names, service marks or product names.
## Grant of Future License
We hereby irrevocably grant you an additional license to use the Software under
the Apache License, Version 2.0 that is effective on the second anniversary of
the date we make the Software available. On or after that date, you may use the
Software under the Apache License, Version 2.0, in which case the following
will apply:
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
Termination. If you violate the terms of this license, your rights
will terminate automatically and will not be reinstated without the
prior written consent of Licensor. Any such termination will not
affect the right of others who may have received copies of the
Software from you.
+2 -2
View File
@@ -7,7 +7,7 @@ Perhaps, the easiest, but not the most comprehensive way to tell what's going on
In the example below, you will run `tart pull` via `time(1)` to gather generalized CPU, I/O and memory usage metrics:
```shell
/usr/bin/time -l tart pull ghcr.io/cirruslabs/macos-tahoe-base:latest
/usr/bin/time -l tart pull ghcr.io/cirruslabs/macos-sequoia-base:latest
```
**Note:** you need to specify a full path to `time(1)` binary, otherwise the shell's built-in `time` command will be invoked, which doesn't have the `-l` command-line argument.
@@ -48,7 +48,7 @@ To use it, make sure that [Xcode](https://developer.apple.com/xcode/resources/)
Once done, you can create a CPU profile of `tart pull`:
```shell
xctrace record --template "CPU Profiler" --target-stdout - --launch -- /opt/homebrew/bin/tart pull ghcr.io/cirruslabs/macos-tahoe-base:latest
xctrace record --template "CPU Profiler" --target-stdout - --launch -- /opt/homebrew/bin/tart pull ghcr.io/cirruslabs/macos-sequoia-base:latest
```
Now that `xctrace(1)` is running, you'll see the `tart pull`-related output first, and once finished, the following line will appear:
+26 -62
View File
@@ -1,5 +1,5 @@
{
"originHash" : "061dfe6cdf4e6dbf32b51c5e7023c4ae69726dcafb42a35b34e5489b0338c17f",
"originHash" : "668bad809d4882f75f097e66a12a6dbc8e61ec998f1800a7e09439c854fadda1",
"pins" : [
{
"identity" : "antlr4",
@@ -13,19 +13,19 @@
{
"identity" : "cirruslabs_tart-guest-agent_apple_swift",
"kind" : "remoteSourceControl",
"location" : "https://buf.build/gen/swift/git/1.33.3-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_apple_swift.git",
"location" : "https://buf.build/gen/swift/git/1.28.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_apple_swift.git",
"state" : {
"revision" : "5c49a653f4b003161077d194bc708b7373628c99",
"version" : "1.33.3-20260114140118-bd09c26a260f.1"
"revision" : "ccfae5de1917cdb0d7c5000008fa5ed0bad032bf",
"version" : "1.28.2-00000000000000-17d7dedafb88.1"
}
},
{
"identity" : "cirruslabs_tart-guest-agent_grpc_swift",
"kind" : "remoteSourceControl",
"location" : "https://buf.build/gen/swift/git/1.27.1-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_grpc_swift.git",
"location" : "https://buf.build/gen/swift/git/1.24.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_grpc_swift.git",
"state" : {
"branch" : "main",
"revision" : "4935078c2fe2508360843596d71a1f844ce639a6"
"branch" : "1.24.2-00000000000000-17d7dedafb88.1",
"revision" : "b8421f137325fe8de737ff5b61238f6f2131b2a8"
}
},
{
@@ -42,35 +42,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/grpc/grpc-swift.git",
"state" : {
"revision" : "8f57f68b9d247fe3759fa9f18e1fe919911e6031",
"version" : "1.27.1"
}
},
{
"identity" : "opentelemetry-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/open-telemetry/opentelemetry-swift",
"state" : {
"branch" : "main",
"revision" : "ed37be9525081509ab62410d38b705c2b3f0d5a4"
}
},
{
"identity" : "opentelemetry-swift-core",
"kind" : "remoteSourceControl",
"location" : "https://github.com/open-telemetry/opentelemetry-swift-core.git",
"state" : {
"revision" : "240c8d5e36c3c7b774ed961325369f0b1f2c965f",
"version" : "2.3.0"
}
},
{
"identity" : "opentracing-objc",
"kind" : "remoteSourceControl",
"location" : "https://github.com/undefinedlabs/opentracing-objc",
"state" : {
"revision" : "18c1a35ca966236cee0c5a714a51a73ff33384c1",
"version" : "0.5.2"
"revision" : "8c5e99d0255c373e0330730d191a3423c57373fb",
"version" : "1.24.2"
}
},
{
@@ -82,6 +55,15 @@
"version" : "0.1.0"
}
},
{
"identity" : "sentry-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa",
"state" : {
"revision" : "65b3d2a7608685e8d4a37c68fa2c64f28d0b537e",
"version" : "8.51.1"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
@@ -105,8 +87,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
"version" : "1.2.0"
}
},
{
@@ -141,17 +123,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181",
"version" : "1.9.1"
}
},
{
"identity" : "swift-metrics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-metrics.git",
"state" : {
"revision" : "0743a9364382629da3bf5677b46a2c4b1ce5d2a6",
"version" : "2.7.1"
"revision" : "9cb486020ebf03bfa5b5df985387a14a98744537",
"version" : "1.6.1"
}
},
{
@@ -159,8 +132,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "233f61bc2cfbb22d0edeb2594da27a20d2ce514e",
"version" : "2.93.0"
"revision" : "34d486b01cd891297ac615e40d5999536a1e138d",
"version" : "2.83.0"
}
},
{
@@ -213,8 +186,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
"version" : "1.33.3"
"revision" : "ebc7251dd5b37f627c93698e4374084d98409633",
"version" : "1.28.2"
}
},
{
@@ -288,15 +261,6 @@
"branch" : "master",
"revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
}
},
{
"identity" : "thrift-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/undefinedlabs/Thrift-Swift",
"state" : {
"revision" : "18ff09e6b30e589ed38f90a1af23e193b8ecef8e",
"version" : "1.1.2"
}
}
],
"version" : 3
+4 -9
View File
@@ -17,17 +17,15 @@ let package = Package(
.package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"),
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"),
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.51.1"),
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"),
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"),
.package(url: "https://github.com/groue/Semaphore", from: "0.0.8"),
.package(url: "https://github.com/fumoboy007/swift-retry", from: "0.2.3"),
.package(url: "https://github.com/jozefizso/swift-xattr", from: "3.0.0"),
.package(url: "https://github.com/grpc/grpc-swift.git", .upToNextMajor(from: "1.27.0")),
.package(url: "https://buf.build/gen/swift/git/1.27.1-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_grpc_swift.git", branch: "main"),
.package(url: "https://github.com/open-telemetry/opentelemetry-swift", branch: "main"),
.package(url: "https://github.com/open-telemetry/opentelemetry-swift-core", from: "2.3.0"),
.package(url: "https://github.com/grpc/grpc-swift.git", .upToNextMajor(from: "1.24.2")),
.package(url: "https://buf.build/gen/swift/git/1.24.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_grpc_swift.git", revision: "1.24.2-00000000000000-17d7dedafb88.1"),
],
targets: [
.executableTarget(name: "tart", dependencies: [
@@ -37,6 +35,7 @@ let package = Package(
.product(name: "SwiftDate", package: "SwiftDate"),
.product(name: "Antlr4Static", package: "Antlr4"),
.product(name: "Atomics", package: "swift-atomics"),
.product(name: "Sentry", package: "sentry-cocoa"),
.product(name: "TextTable", package: "TextTable"),
.product(name: "Sysctl", package: "swift-sysctl"),
.product(name: "SwiftRadix", package: "SwiftRadix"),
@@ -45,10 +44,6 @@ let package = Package(
.product(name: "XAttr", package: "swift-xattr"),
.product(name: "GRPC", package: "grpc-swift"),
.product(name: "Cirruslabs_TartGuestAgent_Grpc_Swift", package: "cirruslabs_tart-guest-agent_grpc_swift"),
.product(name: "OpenTelemetryApi", package: "opentelemetry-swift-core"),
.product(name: "OpenTelemetrySdk", package: "opentelemetry-swift-core"),
.product(name: "OpenTelemetryProtocolExporterHTTP", package: "opentelemetry-swift"),
.product(name: "ResourceExtension", package: "opentelemetry-swift"),
], exclude: [
"OCI/Reference/Makefile",
"OCI/Reference/Reference.g4",
+29 -14
View File
@@ -1,4 +1,4 @@
<img src="https://github.com/openai/tart/raw/main/Resources/TartSocial.png"/>
<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 (VMs) on Apple Silicon.
Built by CI engineers for your automation needs. Here are some highlights of Tart:
@@ -8,52 +8,67 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
* Use Tart Packer Plugin to automate VM creation.
* Easily integrates with any CI system.
Tart powers [Cirrus Runners](https://cirrus-runners.app/)
service — a drop-in replacement for the standard GitHub-hosted runners, offering 2-3 times better performance for a fraction of the price.
<p align="center">
<a href="https://cirrus-runners.app/?utm_source=github&utm_medium=referral" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
</a>
</p>
Many companies are using Tart in their internal setups. Here are just a few of them:
<p align="center">
<a href="https://atlassian.com/" target=_blank>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Atlassian.png" height="65"/>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Atlassian.png" height="65"/>
</a>
<a href="https://www.figma.com/" target=_blank>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Figma.png" height="65"/>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Figma.png" height="65"/>
</a>
<a href="https://mullvad.net/" target=_blank>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
</a>
<a href="https://krisp.ai/" target=_blank>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
</a>
<a href="https://testingbot.com/" target=_blank>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
</a>
<a href="https://symflower.com/" target=_blank>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
</a>
<a href="https://transloadit.com/" target=_blank>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
</a>
<a href="https://cirrus-ci.org/" target=_blank>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
</a>
<a href="https://www.pitsdatarecovery.net/" target=_blank>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
</a>
<a href="https://expo.dev/" target=_blank>
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Expo.png" height="65"/>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Expo.png" height="65"/>
</a>
</p>
**Note:** If your company or project is using Tart please consider [sharing with the community](https://github.com/openai/tart/discussions/857).
**Note:** If your company or project is using Tart please consider [sharing with the community](https://github.com/cirruslabs/tart/discussions/857).
<p align="center">
<a href="https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws?utm_source=github&utm_medium=referral" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/AWSMarkeplaceLogo.png" height="90"/>
</a>
</p>
## Usage
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
```bash
brew install openai/tools/tart
brew install cirruslabs/cli/tart
tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest tahoe-base
tart run tahoe-base
```
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/openai/tart/discussions)
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
for remaining questions.
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

+7 -9
View File
@@ -7,17 +7,15 @@
<key>CFBundleDisplayName</key>
<string>Tart</string>
<key>CFBundleIdentifier</key>
<string>com.github.cirruslabs.tart</string>
<string>org.cirruslabs.tart</string>
<key>CFBundleExecutable</key>
<string>tart</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>CFBundleIconFile</key>
<string>UPW Tart</string>
<key>CFBundleIconName</key>
<string>UPW Tart</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon.png</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

-140
View File
@@ -1,140 +0,0 @@
{
"fill" : "automatic",
"groups" : [
{
"blend-mode" : "normal",
"blur-material" : 0.5,
"layers" : [
{
"hidden" : false,
"image-name-specializations" : [
{
"value" : "4.4-–-layer.png"
},
{
"idiom" : "square",
"value" : "UPW Tart L4.png"
}
],
"name" : "UPW Tart L4"
}
],
"opacity" : 1,
"shadow" : {
"kind" : "neutral",
"opacity" : 1
},
"specular" : true,
"translucency" : {
"enabled" : true,
"value" : 0.25
}
},
{
"layers" : [
{
"image-name-specializations" : [
{
"value" : "3.3-–-layer.png"
},
{
"idiom" : "square",
"value" : "UPW Tart L3.png"
}
],
"name" : "UPW Tart L3",
"position-specializations" : [
{
"idiom" : "square",
"value" : {
"scale" : 1,
"translation-in-points" : [
0,
0
]
}
}
]
}
],
"shadow" : {
"kind" : "none",
"opacity" : 1
},
"specular" : false,
"translucency" : {
"enabled" : true,
"value" : 0.25
}
},
{
"blur-material" : null,
"layers" : [
{
"image-name-specializations" : [
{
"value" : "2.2-–-layer.png"
},
{
"idiom" : "square",
"value" : "UPW Tart L2.png"
}
],
"name" : "UPW Tart L2"
}
],
"position-specializations" : [
{
"idiom" : "square",
"value" : {
"scale" : 1,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "none",
"opacity" : 1
},
"specular" : true,
"translucency" : {
"enabled" : true,
"value" : 0.25
}
},
{
"layers" : [
{
"image-name-specializations" : [
{
"value" : "1.1-–-layer.png"
},
{
"idiom" : "square",
"value" : "UPW Tart L1.png"
}
],
"name" : "UPW Tart L1"
}
],
"shadow" : {
"kind" : "layer-color",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : true,
"value" : 0.25
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
Binary file not shown.
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIconFile</key>
<string>UPW Tart</string>
<key>CFBundleIconName</key>
<string>UPW Tart</string>
</dict>
</plist>
Binary file not shown.
+1 -1
View File
@@ -1,5 +1,5 @@
struct CI {
private static let rawVersion = "${VERSION}"
private static let rawVersion = "${CIRRUS_TAG}"
static var version: String {
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
+4 -9
View File
@@ -31,9 +31,6 @@ struct Clone: AsyncParsableCommand {
@Flag(help: .hidden)
var deduplicate: Bool = false
@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
var pruneLimit: UInt = 100
func validate() throws {
if newName.contains("/") {
throw ValidationError("<new-name> should be a local name")
@@ -45,8 +42,8 @@ struct Clone: AsyncParsableCommand {
}
func run() async throws {
let ociStorage = try VMStorageOCI()
let localStorage = try 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
@@ -79,10 +76,8 @@ struct Clone: AsyncParsableCommand {
//
// So, once we clone the VM let's try to claim the rest of space for the VM to run without errors.
let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
// Avoid reclaiming an excessive amount of disk space.
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
if reclaimBytes > 0 {
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM)
if unallocatedBytes > 0 {
try Prune.reclaimIfNeeded(UInt64(unallocatedBytes), sourceVM)
}
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
+26 -71
View File
@@ -1,5 +1,6 @@
import ArgumentParser
import Foundation
import NIOPosix
import GRPC
import Cirruslabs_TartGuestAgent_Grpc_Swift
@@ -40,12 +41,19 @@ struct Exec: AsyncParsableCommand {
throw RuntimeError.VMNotRunning(name)
}
// Change the current working directory to a VM's base directory
// to work around Unix domain socket 104 byte limitation [1]
//
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
if let baseURL = vmDir.controlSocketURL.baseURL {
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
// Create a gRPC channel connected to the VM's control socket
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer {
try! group.syncShutdownGracefully()
}
let channel = try GRPCChannelPool.with(
target: .unixDomainSocket(vmDir.controlSocketURL.path()),
transportSecurity: .plaintext,
eventLoopGroup: group,
)
defer {
try! channel.close().wait()
}
// Switch controlling terminal into raw mode when remote pseudo-terminal is requested
@@ -63,10 +71,7 @@ struct Exec: AsyncParsableCommand {
// Execute a command in a running VM
do {
let controlSocketPath = vmDir.controlSocketURL.relativePath
try await withGuestAgentChannel(unixDomainSocketPath: controlSocketPath) { channel in
try await execute(channel)
}
try await execute(channel)
} catch let error as GRPCConnectionPoolError {
throw RuntimeError.Generic("Failed to connect to the VM using its control socket: \(error.localizedDescription), is the Tart Guest Agent running?")
}
@@ -82,13 +87,11 @@ struct Exec: AsyncParsableCommand {
$0.args = Array(command.dropFirst(1))
$0.interactive = interactive
$0.tty = tty
if tty {
$0.terminalSize = .with {
let (width, height) = try! Term.GetSize()
$0.terminalSize = .with {
let (width, height) = try! Term.GetSize()
$0.cols = UInt32(width)
$0.rows = UInt32(height)
}
$0.cols = UInt32(width)
$0.rows = UInt32(height)
}
})
})
@@ -97,66 +100,28 @@ struct Exec: AsyncParsableCommand {
try await withThrowingTaskGroup { group in
// Stream host's standard input if interactive mode is enabled
if interactive {
let stdinStream = AsyncThrowingStream<Data, Error> { continuation in
let stdinStream = AsyncStream<Data> { continuation in
let handle = FileHandle.standardInput
if isRegularFile(handle.fileDescriptor) {
// Standard input can be a regular file when input redirection (<) is used,
// in which case the handle won't receive any new readability events, so we
// just read the file normally here in chunks and consider done with it
//
// Ideally this is best handled by using non-blocking I/O, but Swift's
// standard library only offers inefficient bytes[1] property and SwiftNIO's
// NIOFileSystem doesn't seem to support opening raw file descriptors.
//
// [1]: https://developer.apple.com/documentation/foundation/filehandle/bytes
while true {
do {
let data = try handle.read(upToCount: 64 * 1024)
if let data = data {
continuation.yield(data)
} else {
continuation.finish()
break
}
} catch (let error) {
continuation.finish(throwing: error)
break
}
}
} else {
handle.readabilityHandler = { handle in
let data = handle.availableData
handle.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
// EOF: unregister the handler, otherwise the fd stays permanently
// "readable" and Foundation re-invokes us in a tight loop, burning
// 100% of a core for the rest of the command's lifetime
handle.readabilityHandler = nil
continuation.yield(data)
continuation.finish()
} else {
continuation.yield(data)
}
if data.isEmpty {
continuation.finish()
}
}
}
group.addTask {
for try await stdinData in stdinStream {
for await stdinData in stdinStream {
try await execCall.requestStream.send(.with {
$0.type = .standardInput(.with {
$0.data = stdinData
})
})
}
// Signal EOF as we're done reading standard input
try await execCall.requestStream.send(.with {
$0.type = .standardInput(.with {
$0.data = Data()
})
})
}
}
@@ -213,13 +178,3 @@ struct Exec: AsyncParsableCommand {
}
}
}
private func isRegularFile(_ fileDescriptor: Int32) -> Bool {
var stat = stat()
if fstat(fileDescriptor, &stat) != 0 {
return false
}
return (stat.st_mode & S_IFMT) == S_IFREG
}
+2 -9
View File
@@ -2,6 +2,7 @@ import ArgumentParser
import Foundation
import Network
import SystemConfiguration
import Sentry
enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
case dhcp, arp, agent
@@ -68,15 +69,7 @@ struct IP: AsyncParsableCommand {
throw RuntimeError.Generic("Cannot perform IP resolution via Tart Guest Agent when control socket URL is not set")
}
// Change the current working directory to a VM's base directory
// to work around Unix domain socket 104 byte limitation [1]
//
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
if let baseURL = controlSocketURL.baseURL {
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
}
if let ip = try await AgentResolver.ResolveIP(controlSocketURL.relativePath) {
if let ip = try await AgentResolver.ResolveIP(controlSocketURL) {
return ip
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ struct Import: AsyncParsableCommand {
}
func run() async throws {
let localStorage = try VMStorageLocal()
let localStorage = VMStorageLocal()
// Create a temporary VM directory to which we will load the export file
let tmpVMDir = try VMDirectory.temporary()
+3 -31
View File
@@ -7,7 +7,7 @@ fileprivate struct VMInfo: Encodable {
let Name: String
let Disk: Int
let Size: Int
let Accessed: String
let SizeOnDisk: Int
let Running: Bool
let State: String
}
@@ -39,29 +39,13 @@ struct List: AsyncParsableCommand {
if source == nil || source == "local" {
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
try VMInfo(
Source: "local",
Name: name,
Disk: vmDir.sizeGB(),
Size: vmDir.allocatedSizeGB(),
Accessed: formatAccessDate(try vmDir.accessDate()),
Running: vmDir.running(),
State: vmDir.state().rawValue
)
try VMInfo(Source: "local", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
})
}
if source == nil || source == "oci" {
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
try VMInfo(
Source: "OCI",
Name: name,
Disk: vmDir.sizeGB(),
Size: vmDir.allocatedSizeGB(),
Accessed: formatAccessDate(try vmDir.accessDate()),
Running: vmDir.running(),
State: vmDir.state().rawValue
)
try VMInfo(Source: "OCI", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
})
}
@@ -77,16 +61,4 @@ struct List: AsyncParsableCommand {
private func sortedInfos(_ infos: [VMInfo]) -> [VMInfo] {
infos.sorted(by: { left, right in left.Name < right.Name })
}
private func formatAccessDate(_ accessDate: Date) -> String {
switch format {
case .text:
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: accessDate, relativeTo: Date())
case .json:
let formatter = ISO8601DateFormatter()
return formatter.string(from: accessDate)
}
}
}
-2
View File
@@ -64,8 +64,6 @@ struct Login: AsyncParsableCommand {
}
fileprivate class DictionaryCredentialsProvider: CredentialsProvider {
let userFriendlyName = "static dictionary credentials provider"
var credentials: Dictionary<String, (String, String)>
init(_ credentials: Dictionary<String, (String, String)>) {
+22 -23
View File
@@ -1,6 +1,6 @@
import ArgumentParser
import Dispatch
import OpenTelemetryApi
import Sentry
import SwiftUI
import SwiftDate
@@ -53,9 +53,9 @@ struct Prune: AsyncParsableCommand {
switch entries {
case "caches":
prunableStorages = [try VMStorageOCI(), try IPSWCache()]
prunableStorages = [VMStorageOCI(), try IPSWCache()]
case "vms":
prunableStorages = [try VMStorageLocal()]
prunableStorages = [VMStorageLocal()]
default:
throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"")
}
@@ -109,10 +109,9 @@ struct Prune: AsyncParsableCommand {
return
}
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "prune.required-bytes",
value: .int(Int(requiredBytes))
)
SentrySDK.configureScope { scope in
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
}
// Figure out how much disk space is available
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [
@@ -124,14 +123,18 @@ struct Prune: AsyncParsableCommand {
UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
)
OpenTelemetry.instance.contextProvider.activeSpan?.setAttributes([
"prune.volume-available-capacity-bytes": .int(Int(attrs.volumeAvailableCapacity!)),
"prune.volume-available-capacity-for-important-usage-bytes": .int(Int(attrs.volumeAvailableCapacityForImportantUsage!)),
"prune.volume-available-capacity-calculated": .int(Int(volumeAvailableCapacityCalculated)),
])
SentrySDK.configureScope { scope in
scope.setContext(value: [
"volumeAvailableCapacity": attrs.volumeAvailableCapacity!,
"volumeAvailableCapacityForImportantUsage": attrs.volumeAvailableCapacityForImportantUsage!,
"volumeAvailableCapacityCalculated": volumeAvailableCapacityCalculated
], key: "Prune")
}
if volumeAvailableCapacityCalculated <= 0 {
OpenTelemetry.instance.contextProvider.activeSpan?.addEvent(name: "Zero volume capacity reported")
SentrySDK.capture(message: "Zero volume capacity reported") { scope in
scope.setLevel(.warning)
}
return
}
@@ -146,10 +149,10 @@ struct Prune: AsyncParsableCommand {
}
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
let span = OTel.shared.tracer.spanBuilder(spanName: "prune").startSpan()
defer { span.end() }
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
defer { transaction.finish() }
let prunableStorages: [PrunableStorage] = [try VMStorageOCI(), try IPSWCache()]
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() }
@@ -174,17 +177,13 @@ struct Prune: AsyncParsableCommand {
continue
}
let allocatedSizeBytes = try prunable.allocatedSizeBytes()
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path)
OpenTelemetry.instance.contextProvider.activeSpan?
.addEvent(name: "Pruned \(allocatedSizeBytes) bytes for \(prunable.url.path)")
cacheReclaimedBytes += allocatedSizeBytes
cacheReclaimedBytes += try prunable.allocatedSizeBytes()
try prunable.delete()
}
OpenTelemetry.instance.contextProvider.activeSpan?
.addEvent(name: "Reclaimed \(cacheReclaimedBytes) bytes")
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ struct Pull: AsyncParsableCommand {
func run() async throws {
// Be more liberal when accepting local image as argument,
// see https://github.com/cirruslabs/tart/issues/36
if try VMStorageLocal().exists(remoteName) {
if VMStorageLocal().exists(remoteName) {
print("\"\(remoteName)\" is a local image, nothing to pull here!")
return
+5 -1
View File
@@ -31,12 +31,15 @@ struct Push: AsyncParsableCommand {
discussion: "Can be specified multiple times to attach multiple labels."))
var labels: [String] = []
@Option(help: .hidden)
var diskFormat: String = "v2"
@Flag(help: ArgumentHelp("cache pushed images locally",
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
var populateCache: Bool = false
func run() async throws {
let ociStorage = try VMStorageOCI()
let ociStorage = VMStorageOCI()
let localVMDir = try VMStorageHelper.open(localName)
let lock = try localVMDir.lock()
if try !lock.trylock() {
@@ -82,6 +85,7 @@ struct Push: AsyncParsableCommand {
registry: registry,
references: references,
chunkSizeMb: chunkSize,
diskFormat: diskFormat,
concurrency: concurrency,
labels: parseLabels()
)
+1 -1
View File
@@ -17,7 +17,7 @@ struct Rename: AsyncParsableCommand {
}
func run() async throws {
let localStorage = try VMStorageLocal()
let localStorage = VMStorageLocal()
if !localStorage.exists(name) {
throw ValidationError("failed to rename a non-existent local VM: \(name)")
+20 -186
View File
@@ -4,7 +4,7 @@ import Darwin
import Dispatch
import SwiftUI
import Virtualization
import OpenTelemetryApi
import Sentry
import System
var vm: VM?
@@ -134,12 +134,11 @@ struct Run: AsyncParsableCommand {
Learn how to create a disk image using Disk Utility here: https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
To work with block devices, the easiest way is to modify their permissions to be accessible to the current user:
To work with block devices, the easiest way is to modify their permissions (e.g. by using "sudo chown $USER /dev/diskX") or to run the Tart binary as root, which affects locating Tart VMs.
sudo chown $USER /dev/diskX
tart run macos --disk=/dev/diskX
To work around this pass TART_HOME explicitly:
Warning: after running the chown command above, all software running under the current user will be able to access /dev/diskX. If that violates your threat model, we recommend avoiding mounting block devices altogether.
sudo TART_HOME="$HOME/.tart" tart run sequoia --disk=/dev/disk0
""", valueName: "path[:options]"), completion: .file())
var disk: [String] = []
@@ -202,28 +201,15 @@ struct Run: AsyncParsableCommand {
"""))
var netSoftnet: Bool = false
@Option(help: ArgumentHelp("Comma-separated list of CIDRs to allow the traffic to when using Softnet isolation (e.g. --net-softnet-allow=192.168.0.0/24)", discussion: """
@Option(help: ArgumentHelp("Comma-separated list of CIDRs to allow the traffic to when using Softnet isolation\n(e.g. --net-softnet-allow=192.168.0.0/24)", discussion: """
This option allows you bypass the private IPv4 address space restrictions imposed by --net-softnet.
For example, you can allow the VM to communicate with the local network with e.g. --net-softnet-allow=10.0.0.0/16 or with --net-softnet-allow=0.0.0.0/0 to completely disable the destination based restrictions, including VMs bridge isolation.
When used with --net-softnet-block, the longest prefix match always wins. In case the same prefix is both allowed and blocked, blocking takes precedence.
For example, you can allow the VM to communicate with the local network with e.g. --net-softnet-allow=10.0.0.0/16 or to completely disable the destination based restrictions with --net-softnet-allow=0.0.0.0/0.
Implies --net-softnet.
""", valueName: "comma-separated CIDRs"))
var netSoftnetAllow: String?
@Option(help: ArgumentHelp("Comma-separated list of CIDRs to block the traffic to when using Softnet isolation (e.g. --net-softnet-block=66.66.0.0/16)", discussion: """
This option allows you to tighten the IPv4 address space restrictions imposed by --net-softnet even further.
For example --net-softnet-block=0.0.0.0/0 may be used to establish a default deny policy that is further relaxed with --net-softnet-allow.
When used with --net-softnet-allow, the longest prefix match always wins. In case the same prefix is both allowed and blocked, blocking takes precedence.
Implies --net-softnet.
""", valueName: "comma-separated CIDRs"))
var netSoftnetBlock: String?
@Option(help: ArgumentHelp("Comma-separated list of TCP ports to expose (e.g. --net-softnet-expose 2222:22,8080:80)", discussion: """
Options are comma-separated and are as follows:
@@ -279,41 +265,13 @@ struct Run: AsyncParsableCommand {
#endif
var noTrackpad: Bool = false
@Flag(help: ArgumentHelp("Disable the pointer"))
var noPointer: Bool = false
@Flag(help: ArgumentHelp("Disable the keyboard"))
var noKeyboard: Bool = false
#if arch(arm64) && compiler(>=6.4)
@Option(help: ArgumentHelp("Provision a macOS guest on first boot using the guest provisioning API", discussion: """
Takes a comma-separated list of key=value pairs that configure the initial setup of a macOS guest
Requires the host to be running macOS 27 (or newer) and only takes effect on the first boot after
creation of a macOS 27 (or newer) guest VM.
Supported keys (matching VZMacGuestProvisioningOptions):
* fullName=<NAME> — the person's full name to configure
* username=<USERNAME> — the username for logging into the guest
* password=<PASSWORD> — the password to configure for the guest
* logsInAutomatically=true|false — whether to automatically log the person in at startup
* enablesRemoteLogin=true|false — whether to enable Remote Login (SSH) in the guest
""", valueName: "key=value,..."))
var provisioningOpts: String?
#endif
mutating func validate() throws {
if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
}
// Automatically enable --net-softnet when any of its related options are specified
if netSoftnetAllow != nil || netSoftnetBlock != nil || netSoftnetExpose != nil {
if netSoftnetAllow != nil || netSoftnetExpose != nil {
netSoftnet = true
}
@@ -343,7 +301,7 @@ struct Run: AsyncParsableCommand {
}
}
let localStorage = try VMStorageLocal()
let localStorage = VMStorageLocal()
let vmDir = try localStorage.open(name)
if try vmDir.state() == .Suspended {
suspendable = true
@@ -358,15 +316,8 @@ struct Run: AsyncParsableCommand {
if noTrackpad {
throw ValidationError("--no-trackpad cannot be used with --suspendable")
}
if noKeyboard {
throw ValidationError("--no-keyboard cannot be used with --suspendable")
}
if noPointer {
throw ValidationError("--no-pointer cannot be used with --suspendable")
}
}
if noTrackpad {
let config = try VMConfig.init(fromURL: vmDir.configURL)
if config.os != .darwin {
@@ -374,19 +325,6 @@ struct Run: AsyncParsableCommand {
}
}
#if arch(arm64) && compiler(>=6.4)
if provisioningOpts != nil {
if #unavailable(macOS 27) {
throw ValidationError("--provisioning-opts requires the host to be running macOS 27 (or newer)")
}
let config = try VMConfig.init(fromURL: vmDir.configURL)
if config.os != .darwin {
throw ValidationError("--provisioning-opts can only be used with macOS VMs")
}
}
#endif
for disk in disk {
if disk.hasSuffix("-amd64.iso") {
throw ValidationError("Seems you have a disk targeting x86 architecture (hence amd64 in the name). Please use an 'arm64' version of the disk.")
@@ -395,8 +333,8 @@ struct Run: AsyncParsableCommand {
}
@MainActor
func runOnMainThread() throws {
let localStorage = try VMStorageLocal()
func run() async throws {
let localStorage = VMStorageLocal()
let vmDir = try localStorage.open(name)
// Validate disk format support
@@ -443,11 +381,6 @@ struct Run: AsyncParsableCommand {
// Parse root disk options
let diskOptions = DiskOptions(rootDiskOpts)
// Parse guest provisioning options
#if arch(arm64) && compiler(>=6.4)
let provisioning = try provisioningOpts.map { try GuestProvisioningOptions($0) }
#endif
vm = try VM(
vmDir: vmDir,
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
@@ -460,9 +393,7 @@ struct Run: AsyncParsableCommand {
clipboard: !noClipboard,
sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw),
caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw),
noTrackpad: noTrackpad,
noPointer: noPointer,
noKeyboard: noKeyboard
noTrackpad: noTrackpad
)
let vncImpl: VNC? = try {
@@ -513,11 +444,7 @@ struct Run: AsyncParsableCommand {
#endif
do {
#if arch(arm64) && compiler(>=6.4)
try await vm!.start(recovery: recovery, resume: resume, provisioning: provisioning)
#else
try await vm!.start(recovery: recovery, resume: resume)
#endif
try await vm!.start(recovery: recovery, resume: resume)
} catch let error as VZError {
if error.code == .virtualMachineLimitExceeded {
var hint = ""
@@ -558,7 +485,7 @@ struct Run: AsyncParsableCommand {
}
if #available(macOS 14, *) {
ErrorReportingTask("Failed to run control socket") {
Task {
try await ControlSocket(vmDir.controlSocketURL).run()
}
}
@@ -569,15 +496,14 @@ struct Run: AsyncParsableCommand {
try vncImpl.stop()
}
OTel.shared.flush()
Foundation.exit(0)
} catch {
// Capture the error into OpenTelemetry
OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error)
// Capture the error into Sentry
SentrySDK.capture(error: error)
SentrySDK.flush(timeout: 2.seconds.timeInterval)
fputs("\(error)\n", stderr)
OTel.shared.flush()
Foundation.exit(1)
}
}
@@ -611,14 +537,12 @@ struct Run: AsyncParsableCommand {
} else {
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
OTel.shared.flush()
Foundation.exit(1)
}
#endif
} catch (let e) {
print(RuntimeError.SuspendFailed(e.localizedDescription))
OTel.shared.flush()
Foundation.exit(1)
}
}
@@ -630,7 +554,7 @@ struct Run: AsyncParsableCommand {
signal(SIGUSR2, SIG_IGN)
let sigusr2Src = DispatchSource.makeSignalSource(signal: SIGUSR2)
sigusr2Src.setEventHandler {
ErrorReportingTask("Failed to request guest OS to stop") {
Task {
print("Requesting guest OS to stop...")
try vm!.virtualMachine.requestStop()
}
@@ -670,10 +594,6 @@ struct Run: AsyncParsableCommand {
softnetExtraArguments += ["--allow", netSoftnetAllow]
}
if let netSoftnetBlock = netSoftnetBlock {
softnetExtraArguments += ["--block", netSoftnetBlock]
}
if let netSoftnetExpose = netSoftnetExpose {
softnetExtraArguments += ["--expose", netSoftnetExpose]
}
@@ -802,11 +722,6 @@ struct Run: AsyncParsableCommand {
}
}
// "tart run" drives an AppKit/SwiftUI run loop and therefore must own the main
// thread at the top level, so it opts out of Root's asynchronous command path.
// See Root.main() for the rationale.
extension Run: MainThreadCommand {}
struct MainApp: App {
static var suspendable: Bool = false
static var capturesSystemKeys: Bool = false
@@ -847,13 +762,13 @@ struct MainApp: App {
CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) }
CommandMenu("Control") {
Button("Start") {
ErrorReportingTask("Failed to start VM") { try await vm!.virtualMachine.start() }
Task { try await vm!.virtualMachine.start() }
}
Button("Stop") {
ErrorReportingTask("Failed to stop VM") { try await vm!.virtualMachine.stop() }
Task { try await vm!.virtualMachine.stop() }
}
Button("Request Stop") {
ErrorReportingTask("Failed to request VM stop") { try vm!.virtualMachine.requestStop() }
Task { try vm!.virtualMachine.requestStop() }
}
if #available(macOS 14, *) {
if (MainApp.suspendable) {
@@ -868,12 +783,6 @@ struct MainApp: App {
}
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
func applicationDidFinishLaunching(_ : Notification) {
let nsApp = NSApplication.shared
nsApp.setActivationPolicy(.regular)
nsApp.activate(ignoringOtherApps: true)
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
if (kill(getpid(), MainApp.suspendable ? SIGUSR1 : SIGINT) == 0) {
return .terminateLater
@@ -1075,81 +984,6 @@ struct DiskOptions {
}
}
struct GuestProvisioningOptions {
var fullName: String?
var username: String?
var password: String?
var logsInAutomatically: Bool?
var enablesRemoteLogin: Bool?
init(_ parseFrom: String) throws {
for pair in parseFrom.split(separator: ",") {
let keyValue = pair.split(separator: "=", maxSplits: 1)
guard keyValue.count == 2 else {
throw RuntimeError.VMConfigurationError("invalid provisioning option \"\(pair)\", expected key=value")
}
let key = String(keyValue[0])
let value = String(keyValue[1])
switch key {
case "fullName":
self.fullName = value
case "username":
self.username = value
case "password":
self.password = value
case "logsInAutomatically":
self.logsInAutomatically = try Self.parseBool(key, value)
case "enablesRemoteLogin":
self.enablesRemoteLogin = try Self.parseBool(key, value)
default:
throw RuntimeError.VMConfigurationError("unsupported provisioning option \"\(key)\"")
}
}
}
private static func parseBool(_ key: String, _ value: String) throws -> Bool {
switch value {
case "true":
return true
case "false":
return false
default:
throw RuntimeError.VMConfigurationError("invalid value \"\(value)\" for provisioning option \"\(key)\", expected \"true\" or \"false\"")
}
}
}
#if arch(arm64) && compiler(>=6.4)
@available(macOS 27, *)
extension GuestProvisioningOptions {
func toVZMacGuestProvisioningOptions() throws -> VZMacGuestProvisioningOptions {
let options = VZMacGuestProvisioningOptions()
if let fullName = fullName {
options.fullName = fullName
}
if let username = username {
options.username = username
}
if let password = password {
options.password = password
}
if let logsInAutomatically = logsInAutomatically {
options.logsInAutomatically = logsInAutomatically
}
if let enablesRemoteLogin = enablesRemoteLogin {
options.enablesRemoteLogin = enablesRemoteLogin
}
try options.validate()
return options
}
}
#endif
struct DirectoryShare {
let name: String?
let path: URL
+2 -15
View File
@@ -14,7 +14,7 @@ struct Set: AsyncParsableCommand {
@Option(help: "VM memory size in megabytes")
var memory: UInt64?
@Option(help: "VM display resolution in a format of WIDTHxHEIGHT[pt|px]. For example, 1200x800, 1200x800pt or 1920x1080px. Units are treated as hints and default to \"pt\" (points) for macOS VMs and \"px\" (pixels) for Linux VMs when not specified.")
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
var display: VMDisplayConfig?
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window"))
@@ -56,7 +56,6 @@ struct Set: AsyncParsableCommand {
if (display.height > 0) {
vmConfig.display.height = display.height
}
vmConfig.display.unit = display.unit
}
vmConfig.displayRefit = displayRefit
@@ -89,24 +88,12 @@ struct Set: AsyncParsableCommand {
extension VMDisplayConfig: ExpressibleByArgument {
public init(argument: String) {
var argument = argument
var unit: Unit? = nil
if argument.hasSuffix(Unit.pixel.rawValue) {
argument = String(argument.dropLast(Unit.pixel.rawValue.count))
unit = Unit.pixel
} else if argument.hasSuffix(Unit.point.rawValue) {
argument = String(argument.dropLast(Unit.point.rawValue.count))
unit = Unit.point
}
let parts = argument.components(separatedBy: "x").map {
Int($0) ?? 0
}
self = VMDisplayConfig(
width: parts[safe: 0] ?? 0,
height: parts[safe: 1] ?? 0,
unit: unit,
height: parts[safe: 1] ?? 0
)
}
}
+1 -22
View File
@@ -9,8 +9,7 @@ struct Config {
var tartHomeDir: URL
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
tartHomeDir = URL(fileURLWithPath: customTartHome, isDirectory: true)
try Self.validateTartHome(url: tartHomeDir)
tartHomeDir = URL(fileURLWithPath: customTartHome)
} else {
tartHomeDir = FileManager.default
.homeDirectoryForCurrentUser
@@ -50,24 +49,4 @@ struct Config {
static func jsonDecoder() -> JSONDecoder {
JSONDecoder()
}
private static func validateTartHome(url: URL) throws {
let urlComponents = url.pathComponents
let descendingURLs = urlComponents.indices.map { i in
URL(fileURLWithPath: urlComponents[0...i].joined(separator: "/"))
}
for descendingURL in descendingURLs {
if FileManager.default.fileExists(atPath: descendingURL.path) {
continue
}
do {
try FileManager.default.createDirectory(at: descendingURL, withIntermediateDirectories: false)
} catch {
throw RuntimeError.Generic("TART_HOME is invalid: \(descendingURL.path) does not exist, yet we can't create it: \(error.localizedDescription)")
}
}
}
}
+1 -9
View File
@@ -21,16 +21,8 @@ class ControlSocket {
// if any, otherwise we may get the "address already in use" error
try? FileManager.default.removeItem(atPath: controlSocketURL.path())
// Change the current working directory to a VM's base directory
// to work around Unix domain socket 104 byte limitation [1]
//
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
if let baseURL = controlSocketURL.baseURL {
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
}
let serverChannel = try await ServerBootstrap(group: eventLoopGroup)
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
.bind(unixDomainSocketPath: controlSocketURL.path()) { childChannel in
childChannel.eventLoop.makeCompletedFuture {
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
wrappingChannelSynchronously: childChannel
@@ -5,7 +5,6 @@ enum CredentialsProviderError: Error {
}
protocol CredentialsProvider {
var userFriendlyName: String { get }
func retrieve(host: String) throws -> (String, String)?
func store(host: String, user: String, password: String) throws
}
@@ -1,8 +1,6 @@
import Foundation
class DockerConfigCredentialsProvider: CredentialsProvider {
let userFriendlyName = "Docker configuration credentials provider"
func retrieve(host: String) throws -> (String, String)? {
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
@@ -1,8 +1,6 @@
import Foundation
class EnvironmentCredentialsProvider: CredentialsProvider {
let userFriendlyName = "environment variable credentials provider"
func retrieve(host: String) throws -> (String, String)? {
if let tartRegistryHostname = ProcessInfo.processInfo.environment["TART_REGISTRY_HOSTNAME"],
tartRegistryHostname != host {
@@ -1,8 +1,6 @@
import Foundation
class KeychainCredentialsProvider: CredentialsProvider {
let userFriendlyName = "Keychain credentials provider"
func retrieve(host: String) throws -> (String, String)? {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
@@ -6,8 +6,6 @@ enum StdinCredentialsError: Error {
}
class StdinCredentials {
let userFriendlyName = "standard input credentials provider"
static func retrieve() throws -> (String, String) {
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
@@ -15,7 +13,7 @@ class StdinCredentials {
return (user, password)
}
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 8192, isSensitive: Bool) throws -> String {
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 1024, isSensitive: Bool) throws -> String {
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
throw StdinCredentialsError.CredentialRequired(which: name)
+1 -1
View File
@@ -21,7 +21,7 @@ enum DiskImageFormat: String, CaseIterable, Codable {
case .raw:
return true
case .asif:
if #available(macOS 26, *) {
if #available(macOS 15, *) {
return true
} else {
return false
-108
View File
@@ -1,108 +0,0 @@
import Foundation
struct ImageInfo: Codable {
let sizeInfo: SizeInfo?
let size: UInt64?
enum CodingKeys: String, CodingKey {
case sizeInfo = "Size Info"
case size = "Size"
}
func totalBytes() throws -> Int {
if let totalBytes = self.sizeInfo?.totalBytes {
return Int(totalBytes)
}
if let size = self.size {
return Int(size)
}
throw RuntimeError.Generic("Could not find size information in disk image info")
}
}
struct SizeInfo: Codable {
let totalBytes: UInt64?
enum CodingKeys: String, CodingKey {
case totalBytes = "Total Bytes"
}
}
struct Diskutil {
static func imageCreate(diskURL: URL, sizeGB: UInt16) throws {
do {
_ = try run([
"image", "create", "blank",
"--format", "ASIF",
"--size", "\(sizeGB)G",
"--volumeName", "Tart",
diskURL.path
])
} catch {
throw RuntimeError.FailedToCreateDisk("Failed to create ASIF disk image: \(error)")
}
}
static func imageInfo(_ diskURL: URL) throws -> ImageInfo {
do {
let (stdoutData, _) = try run([
"image", "info", "--plist",
diskURL.path
])
do {
return try PropertyListDecoder().decode(ImageInfo.self, from: stdoutData)
} catch {
throw RuntimeError.Generic("Failed to parse \"diskutil image info --plist\" output: \(error)")
}
}
}
private static func run(_ arguments: [String]) throws -> (Data, Data) {
guard let diskutilURL = resolveBinaryPath("diskutil") else {
throw RuntimeError.Generic("\"diskutil\" binary is not found in PATH")
}
let process = Process()
process.executableURL = diskutilURL
process.arguments = arguments
let stdoutPipe = Pipe()
process.standardOutput = stdoutPipe
let stderrPipe = Pipe()
process.standardError = stderrPipe
do {
try process.run()
} catch {
throw RuntimeError.Generic("\"\(arguments.joined(separator: " "))\" failed: \(error)")
}
process.waitUntilExit()
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
if process.terminationStatus != 0 {
let stdoutString = String(data: stdoutData, encoding: .utf8) ?? ""
let stderrString = String(data: stderrData, encoding: .utf8) ?? ""
throw RuntimeError.Generic("\"\(arguments.joined(separator: " "))\" failed with exit code \(process.terminationStatus): \(firstNonEmptyLine(stderrString, stdoutString))")
}
return (stdoutData, stderrData)
}
private static func firstNonEmptyLine(_ outputs: String...) -> String {
for output in outputs {
for line in output.split(separator: "\n", omittingEmptySubsequences: false) {
if !line.isEmpty {
return String(line)
}
}
}
return ""
}
}
-28
View File
@@ -1,28 +0,0 @@
import GRPC
import NIOPosix
/// Connects to a guest agent's gRPC endpoint over a VM's control socket, runs
/// `body` with the resulting channel, and closes the channel afterwards on both
/// the success and error paths.
///
/// The connection uses the process-wide singleton event loop group, which must
/// not be shut down, so there is no group lifecycle to manage here.
func withGuestAgentChannel<T>(
unixDomainSocketPath socketPath: String,
_ body: (GRPCChannel) async throws -> T
) async throws -> T {
let channel = try GRPCChannelPool.with(
target: .unixDomainSocket(socketPath),
transportSecurity: .plaintext,
eventLoopGroup: .singletonMultiThreadedEventLoopGroup,
)
do {
let result = try await body(channel)
try await channel.close().get()
return result
} catch {
try? await channel.close().get()
throw error
}
}
+2 -12
View File
@@ -4,28 +4,18 @@ public class ProgressObserver: NSObject {
@objc var progressToObserve: Progress
var observation: NSKeyValueObservation?
var lastTimeUpdated = Date.now
private var lastRenderedLine: String?
public init(_ progress: Progress) {
progressToObserve = progress
}
func log(_ renderer: Logger) {
let initialLine = ProgressObserver.lineToRender(progressToObserve)
renderer.appendNewLine(initialLine)
lastRenderedLine = initialLine
renderer.appendNewLine(ProgressObserver.lineToRender(progressToObserve))
observation = observe(\.progressToObserve.fractionCompleted) { progress, _ in
let currentTime = Date.now
if self.progressToObserve.isFinished || currentTime.timeIntervalSince(self.lastTimeUpdated) >= 1.0 {
self.lastTimeUpdated = currentTime
let line = ProgressObserver.lineToRender(self.progressToObserve)
// Skip identical renders so non-interactive logs only see new percent values.
if line == self.lastRenderedLine {
return
}
self.lastRenderedLine = line
renderer.updateLastLine(line)
renderer.updateLastLine(ProgressObserver.lineToRender(self.progressToObserve))
}
}
}
@@ -1,28 +1,42 @@
import Foundation
import Network
import NIOPosix
import GRPC
import Cirruslabs_TartGuestAgent_Apple_Swift
import Cirruslabs_TartGuestAgent_Grpc_Swift
class AgentResolver {
static func ResolveIP(_ controlSocketPath: String) async throws -> IPv4Address? {
static func ResolveIP(_ controlSocketURL: URL) async throws -> IPv4Address? {
do {
return try await resolveIP(controlSocketPath)
} catch is GRPCConnectionPoolError {
return try await resolveIP(controlSocketURL)
} catch let error as GRPCConnectionPoolError {
return nil
}
}
private static func resolveIP(_ controlSocketPath: String) async throws -> IPv4Address? {
try await withGuestAgentChannel(unixDomainSocketPath: controlSocketPath) { channel in
// Invoke ResolveIP() gRPC method
let callOptions = CallOptions(timeLimit: .timeout(.seconds(1)))
let agentAsyncClient = AgentAsyncClient(channel: channel)
let resolveIPCall = agentAsyncClient.makeResolveIpCall(ResolveIPRequest(), callOptions: callOptions)
let response = try await resolveIPCall.response
return IPv4Address(response.ip)
private static func resolveIP(_ controlSocketURL: URL) async throws -> IPv4Address? {
// Create a gRPC channel connected to the VM's control socket
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer {
try! group.syncShutdownGracefully()
}
let channel = try GRPCChannelPool.with(
target: .unixDomainSocket(controlSocketURL.path()),
transportSecurity: .plaintext,
eventLoopGroup: group,
)
defer {
try! channel.close().wait()
}
// Invoke ResolveIP() gRPC method
let callOptions = CallOptions(timeLimit: .timeout(.seconds(1)))
let agentAsyncClient = AgentAsyncClient(channel: channel)
let resolveIPCall = agentAsyncClient.makeResolveIpCall(ResolveIPRequest(), callOptions: callOptions)
let response = try await resolveIPCall.response
return IPv4Address(response.ip)
}
}
+75
View File
@@ -0,0 +1,75 @@
import Foundation
import Compression
class DiskV1: Disk {
private static let bufferSizeBytes = 4 * 1024 * 1024
private static let layerLimitBytes = 500 * 1000 * 1000
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] {
var pushedLayers: [OCIManifestLayer] = []
// Open the disk file
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
var mappedDiskReadOffset = 0
// Compress the disk file as a single stream
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
// Determine the size of the next chunk
let bytesRead = min(length, mappedDisk.count - mappedDiskReadOffset)
// Read the next uncompressed chunk
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
// Advance the offset
mappedDiskReadOffset += bytesRead
// Provide the uncompressed chunk to the compressing filter
return data
}
// Cut the compressed stream into layers, each equal exactly ``Self.layerLimitBytes`` bytes,
// except for the last one, which may be smaller
while let compressedData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
pushedLayers.append(OCIManifestLayer(
mediaType: diskV1MediaType,
size: compressedData.count,
digest: layerDigest
))
// Update progress using an absolute value
progress.completedUnitCount = Int64(mappedDiskReadOffset)
}
return pushedLayers
}
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws {
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
throw OCIError.FailedToCreateVmFile
}
// Open the disk file
let disk = try FileHandle(forWritingTo: diskURL)
defer { try! disk.close() }
// Decompress the layers onto the disk in a single stream
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
if let data = data {
try disk.write(contentsOf: data)
}
}
for diskLayer in diskLayers {
try await registry.pullBlob(diskLayer.digest) { data in
try filter.write(data)
// Update the progress
progress.completedUnitCount += Int64(data.count)
}
}
try filter.finalize()
}
}
+1
View File
@@ -6,6 +6,7 @@ let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
// Layer media types
let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
let diskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2"
let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
+2 -6
View File
@@ -429,12 +429,8 @@ class Registry {
}
for provider in credentialsProviders {
do {
if let (user, password) = try provider.retrieve(host: host) {
return (user, password)
}
} catch (let e) {
print("Failed to retrieve credentials using \(provider.userFriendlyName), authentication may fail: \(e)")
if let (user, password) = try provider.retrieve(host: host) {
return (user, password)
}
}
return nil
-64
View File
@@ -1,64 +0,0 @@
import Foundation
import OpenTelemetryApi
import OpenTelemetrySdk
import OpenTelemetryProtocolExporterHttp
import ResourceExtension
class OTel {
let tracerProvider: TracerProviderSdk?
let tracer: Tracer
static let shared = OTel()
init() {
tracerProvider = Self.initializeTracing()
tracer = OpenTelemetry.instance.tracerProvider.get(instrumentationName: "tart", instrumentationVersion: CI.version)
}
static func initializeTracing() -> TracerProviderSdk? {
guard let _ = ProcessInfo.processInfo.environment["TRACEPARENT"] else {
return nil
}
var resource = DefaultResources().get()
resource.merge(other: Resource(attributes: [
SemanticConventions.Service.name.rawValue: .string("tart"),
SemanticConventions.Service.version.rawValue: .string(CI.version)
]))
let spanExporter: SpanExporter
if let endpointRaw = ProcessInfo.processInfo.environment["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"],
let endpoint = URL(string: endpointRaw) {
spanExporter = OtlpHttpTraceExporter(endpoint: endpoint)
} else {
spanExporter = OtlpHttpTraceExporter()
}
let spanProcessor = SimpleSpanProcessor(spanExporter: spanExporter)
let tracerProvider = TracerProviderBuilder()
.add(spanProcessor: spanProcessor)
.with(resource: resource)
.build()
OpenTelemetry.registerTracerProvider(tracerProvider: tracerProvider)
return tracerProvider
}
func flush() {
OpenTelemetry.instance.contextProvider.activeSpan?.end()
guard let tracerProvider else {
// No tracing was initialized, so just ending a span is enough
return
}
tracerProvider.forceFlush()
// Work around OpenTelemtry not flushing traces after explicitly asking it to do so
//
// [1]: https://github.com/open-telemetry/opentelemetry-swift/issues/685
// [2]: https://github.com/open-telemetry/opentelemetry-swift/issues/555
Thread.sleep(forTimeInterval: .fromMilliseconds(100))
}
}
+1 -1
View File
@@ -82,7 +82,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZMacGraphicsDeviceConfiguration()
if (vmConfig.display.unit ?? .point) == .point, let hostMainScreen = NSScreen.main {
if let hostMainScreen = NSScreen.main {
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
result.displays = [
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
+66 -134
View File
@@ -1,9 +1,7 @@
import ArgumentParser
import Darwin
import Foundation
import OpenTelemetryApi
import OpenTelemetrySdk
import OpenTelemetryProtocolExporterHttp
import Sentry
@main
struct Root: AsyncParsableCommand {
@@ -32,160 +30,101 @@ struct Root: AsyncParsableCommand {
FQN.self,
])
// Note: main() is intentionally synchronous. Swift's asynchronous main() entry
// point implicitly starts an executor that owns the main thread — and since
// Swift 6.4 that executor is no longer backed by the Dispatch main queue — so
// running an AppKit/SwiftUI run loop nested inside it leaves the main run loop
// unable to drain Tasks or DispatchQueue.main, and a VM started via "tart run"
// never boots. Keeping main() synchronous lets a command that needs the main
// run loop own it at the top level, exactly like a plain SwiftUI app.
public static func main() {
public static func main() async throws {
// Add commands that are only available on specific macOS versions
if #available(macOS 14, *) {
configuration.subcommands.append(Suspend.self)
}
// Ensure the default SIGINT handler is disabled, otherwise there's a race
// between two handlers. We handle cancellation by Ctrl+C ourselves below.
signal(SIGINT, SIG_IGN)
// Ensure the default SIGINT handled is disabled,
// otherwise there's a race between two handlers
signal(SIGINT, SIG_IGN);
// Handle cancellation by Ctrl+C ourselves
let task = withUnsafeCurrentTask { $0 }!
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSrc.setEventHandler {
task.cancel()
}
sigintSrc.activate()
// Set line-buffered output for stdout
setlinebuf(stdout)
// Parse the command up-front, synchronously, so we can decide who gets to own
// the main thread before any concurrency is involved.
//
// ParsableCommand isn't Sendable, but we only ever hand it to the single task
// spawned below and never touch it again afterwards, so transferring it into
// that task is safe.
nonisolated(unsafe) let command: ParsableCommand
do {
command = try parseAsRoot()
} catch {
exit(withError: error)
}
// Parse command
var command = try parseAsRoot()
if let mainThreadCommand = command as? MainThreadCommand {
// This command drives a run loop on the main thread, so run it right here,
// letting it own the main thread at the top level.
MainActor.assumeIsolated {
runOnMainThread(mainThreadCommand)
// 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)
]
}
}
} else {
// Every other command is asynchronous and doesn't touch the main thread, so
// drive it from a detached task and let the Dispatch main queue keep the
// process alive until the command exits.
let task = Task.detached {
await runInBackground(command)
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
SentrySDK.configureScope { scope in
scope.setExtra(value: ProcessInfo.processInfo.arguments, key: "Command-line arguments")
}
// Handle cancellation by Ctrl+C ourselves
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSrc.setEventHandler {
task.cancel()
// 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)
}
}
}
sigintSrc.activate()
dispatchMain()
}
}
// Run garbage-collection before each command (shouldn't take too long)
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
do {
try Config().gc()
} catch {
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
}
}
@MainActor
private static func runOnMainThread(_ command: MainThreadCommand) {
let span = startCommandSpan(for: command)
runGarbageCollection(for: command)
do {
// Enters the run loop and only returns once the command exits via
// Foundation.exit(), so the lines below are a best-effort fallback.
try command.runOnMainThread()
} catch {
handleError(error, span: span)
}
span.end()
OTel.shared.flush()
Foundation.exit(0)
}
private static func runInBackground(_ command: ParsableCommand) async {
let span = startCommandSpan(for: command)
runGarbageCollection(for: command)
do {
// Run command
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
var command = command
try command.run()
}
} catch {
handleError(error, span: span)
}
span.end()
OTel.shared.flush()
Foundation.exit(0)
}
// Create a root span for the command we're about to run.
private static func startCommandSpan(for command: ParsableCommand) -> Span {
let span = OTel.shared.tracer.spanBuilder(spanName: type(of: command)._commandName).startSpan()
OpenTelemetry.instance.contextProvider.setActiveSpan(span)
// Enrich root command span with command's arguments
let commandLineArguments = ProcessInfo.processInfo.arguments.map { argument in
AttributeValue.string(argument)
}
span.setAttribute(key: "Command-line arguments", value: .array(AttributeArray(values: commandLineArguments)))
// Enrich root command span with Cirrus CI-specific tags
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
for (key, value) in tags.split(separator: ",").compactMap(splitEnvironmentVariable) {
span.setAttribute(key: key, value: .string(value))
// Not an error, just a custom exit code from "tart exec"
if let execCustomExitCodeError = error as? ExecCustomExitCodeError {
Foundation.exit(execCustomExitCodeError.exitCode)
}
}
return span
}
// Capture the error into Sentry
SentrySDK.capture(error: error)
SentrySDK.flush(timeout: 2.seconds.timeInterval)
// Run garbage-collection before each command (shouldn't take too long).
private static func runGarbageCollection(for command: ParsableCommand) {
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()) {
do {
try Config().gc()
} catch {
fputs("Failed to perform garbage collection: \(error)\n", stderr)
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
if let errorWithExitCode = error as? HasExitCode {
fputs("\(error)\n", stderr)
Foundation.exit(errorWithExitCode.exitCode)
}
// Handle any other exception, including ArgumentParser's ones
exit(withError: error)
}
}
private static func handleError(_ error: Error, span: Span) -> Never {
// Not an error, just a custom exit code from "tart exec"
if let execCustomExitCodeError = error as? ExecCustomExitCodeError {
span.end()
OTel.shared.flush()
Foundation.exit(execCustomExitCodeError.exitCode)
}
// Capture the error into OpenTelemetry
OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error)
span.end()
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
if let errorWithExitCode = error as? HasExitCode {
fputs("\(error)\n", stderr)
OTel.shared.flush()
Foundation.exit(errorWithExitCode.exitCode)
}
// Handle any other exception, including ArgumentParser's ones
OTel.shared.flush()
exit(withError: error)
}
private static func splitEnvironmentVariable(_ tag: String.SubSequence) -> (String, String)? {
private static func parseCirrusSentryTag(_ tag: String.SubSequence) -> (String, String)? {
let splits = tag.split(separator: "=", maxSplits: 1)
if splits.count != 2 {
return nil
@@ -194,10 +133,3 @@ struct Root: AsyncParsableCommand {
return (String(splits[0]), String(splits[1]))
}
}
// A command that drives an AppKit/SwiftUI run loop and therefore has to own the
// main thread at the top level, rather than running inside Swift's asynchronous
// main() executor. See Root.main() for the rationale.
protocol MainThreadCommand: ParsableCommand {
@MainActor func runOnMainThread() throws
}
-18
View File
@@ -1,23 +1,5 @@
import Foundation
// A fire-and-forget task that reports any thrown error to stderr. An unstructured
// Task spawned from a synchronous context (a signal handler, a SwiftUI action) has
// no parent to propagate its error to, so we report it here instead of dropping it.
struct ErrorReportingTask {
let task: Task<Void, Never>
@discardableResult
init(_ context: String, operation: @escaping @Sendable () async throws -> Void) {
task = Task {
do {
try await operation()
} catch {
fputs("\(context): \(error)\n", stderr)
}
}
}
}
extension Collection {
subscript (safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
+9 -28
View File
@@ -51,9 +51,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
clipboard: Bool = true,
sync: VZDiskImageSynchronizationMode = .full,
caching: VZDiskImageCachingMode? = nil,
noTrackpad: Bool = false,
noPointer: Bool = false,
noKeyboard: Bool = false
noTrackpad: Bool = false
) throws {
name = vmDir.name
config = try VMConfig.init(fromURL: vmDir.configURL)
@@ -75,9 +73,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
clipboard: clipboard,
sync: sync,
caching: caching,
noTrackpad: noTrackpad,
noPointer: noPointer,
noKeyboard: noKeyboard
noTrackpad: noTrackpad
)
virtualMachine = VZVirtualMachine(configuration: configuration)
@@ -244,13 +240,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
return try VM(vmDir: vmDir)
}
func start(recovery: Bool, resume shouldResume: Bool, provisioning: GuestProvisioningOptions? = nil) async throws {
func start(recovery: Bool, resume shouldResume: Bool) async throws {
try network.run(sema)
if shouldResume {
try await resume()
} else {
try await start(recovery, provisioning: provisioning)
try await start(recovery)
}
}
@@ -286,15 +282,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
@MainActor
private func start(_ recovery: Bool, provisioning: GuestProvisioningOptions? = nil) async throws {
private func start(_ recovery: Bool) async throws {
#if arch(arm64)
let startOptions = VZMacOSVirtualMachineStartOptions()
startOptions.startUpFromMacOSRecovery = recovery
#if compiler(>=6.4)
if let provisioning = provisioning, #available(macOS 27, *) {
try startOptions.setGuestProvisioning(provisioning.toVZMacGuestProvisioningOptions())
}
#endif
try await virtualMachine.start(options: startOptions)
#else
try await virtualMachine.start()
@@ -325,9 +316,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
clipboard: Bool = true,
sync: VZDiskImageSynchronizationMode = .full,
caching: VZDiskImageCachingMode? = nil,
noTrackpad: Bool = false,
noPointer: Bool = false,
noKeyboard: Bool = false
noTrackpad: Bool = false
) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration()
@@ -367,16 +356,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
} else {
if noKeyboard {
configuration.keyboards = []
} else {
configuration.keyboards = vmConfig.platform.keyboards()
}
if noPointer {
configuration.pointingDevices = []
} else if noTrackpad {
configuration.keyboards = vmConfig.platform.keyboards()
if noTrackpad {
configuration.pointingDevices = vmConfig.platform.pointingDevicesSimplified()
} else {
configuration.pointingDevices = vmConfig.platform.pointingDevices()
@@ -404,7 +385,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
// Storage
let attachment = try VZDiskImageStorageDeviceAttachment(
var attachment = try VZDiskImageStorageDeviceAttachment(
url: diskURL,
readOnly: false,
// When not specified, use "cached" caching mode for Linux VMs to prevent file-system corruption[1]
+2 -12
View File
@@ -32,24 +32,14 @@ enum CodingKeys: String, CodingKey {
case hardwareModel
}
struct VMDisplayConfig: Codable, Equatable {
enum Unit: String, Codable {
case point = "pt"
case pixel = "px"
}
struct VMDisplayConfig: Codable {
var width: Int = 1024
var height: Int = 768
var unit: Unit?
}
extension VMDisplayConfig: CustomStringConvertible {
var description: String {
if let unit {
"\(width)x\(height)\(unit.rawValue)"
} else {
"\(width)x\(height)"
}
"\(width)x\(height)"
}
}
+24 -18
View File
@@ -1,8 +1,6 @@
import Compression
import Foundation
import OpenTelemetryApi
let legacyDiskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
import Sentry
enum OCIError: Error {
case ShouldBeExactlyOneLayer
@@ -31,20 +29,21 @@ extension VMDirectory {
try configFile.close()
// Pull VM's disk layers and decompress them into a disk file
if manifest.layers.contains(where: { $0.mediaType == legacyDiskV1MediaType }) {
throw RuntimeError.Generic("Pulling OCI images with legacy disk media type \(legacyDiskV1MediaType) is no longer supported, please re-push the image using a current Tart version")
}
let diskImplType: Disk.Type
let layers: [OCIManifestLayer]
let layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
if layers.isEmpty {
if manifest.layers.contains(where: { $0.mediaType == diskV1MediaType }) {
diskImplType = DiskV1.self
layers = manifest.layers.filter { $0.mediaType == diskV1MediaType }
} else if manifest.layers.contains(where: { $0.mediaType == diskV2MediaType }) {
diskImplType = DiskV2.self
layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
} else {
throw OCIError.ShouldBeAtLeastOneLayer
}
let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +)
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "compressed_disk_size_bytes",
value: .int(Int(diskCompressedSize))
)
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte)
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
@@ -53,10 +52,10 @@ extension VMDirectory {
ProgressObserver(progress).log(defaultLogger)
do {
try await DiskV2.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
concurrency: concurrency, progress: progress,
localLayerCache: localLayerCache,
deduplicate: deduplicate)
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
concurrency: concurrency, progress: progress,
localLayerCache: localLayerCache,
deduplicate: deduplicate)
} catch let error where error is FilterError {
throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)")
}
@@ -88,7 +87,7 @@ extension VMDirectory {
try manifest.toJSON().write(to: manifestURL)
}
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, concurrency: UInt, labels: [String: String] = [:]) async throws -> RemoteName {
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String, concurrency: UInt, labels: [String: String] = [:]) async throws -> RemoteName {
var layers = Array<OCIManifestLayer>()
// Read VM's config and push it as blob
@@ -109,7 +108,14 @@ extension VMDirectory {
let progress = Progress(totalUnitCount: diskSize)
ProgressObserver(progress).log(defaultLogger)
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
switch diskFormat {
case "v1":
layers.append(contentsOf: try await DiskV1.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
case "v2":
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
default:
throw RuntimeError.OCIUnsupportedDiskFormat(diskFormat)
}
// Read VM's NVRAM and push it as blob
defaultLogger.appendNewLine("pushing NVRAM...")
+110 -33
View File
@@ -2,6 +2,25 @@ import Foundation
import Virtualization
import CryptoKit
// MARK: - Disk Image Info Structures
struct DiskImageInfo: Codable {
let sizeInfo: SizeInfo?
let size: UInt64?
enum CodingKeys: String, CodingKey {
case sizeInfo = "Size Info"
case size = "Size"
}
}
struct SizeInfo: Codable {
let totalBytes: UInt64?
enum CodingKeys: String, CodingKey {
case totalBytes = "Total Bytes"
}
}
struct VMDirectory: Prunable {
enum State: String {
case Running = "running"
@@ -27,7 +46,7 @@ struct VMDirectory: Prunable {
baseURL.appendingPathComponent("manifest.json")
}
var controlSocketURL: URL {
URL(fileURLWithPath: "control.sock", relativeTo: baseURL)
baseURL.appendingPathComponent("control.sock")
}
var explicitlyPulledMark: URL {
@@ -182,28 +201,69 @@ struct VMDirectory: Prunable {
}
private func resizeASIFDisk(_ sizeGB: UInt16) throws {
guard let diskutilURL = resolveBinaryPath("diskutil") else {
throw RuntimeError.FailedToResizeDisk("diskutil not found in PATH")
}
// First, get current disk image info to check current size
let infoProcess = Process()
infoProcess.executableURL = diskutilURL
infoProcess.arguments = ["image", "info", "--plist", diskURL.path]
let infoPipe = Pipe()
infoProcess.standardOutput = infoPipe
infoProcess.standardError = infoPipe
do {
let diskImageInfo = try Diskutil.imageInfo(diskURL)
try infoProcess.run()
infoProcess.waitUntilExit()
let currentSizeBytes = try diskImageInfo.totalBytes()
let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000
let infoData = infoPipe.fileHandleForReading.readDataToEndOfFile()
if desiredSizeBytes < currentSizeBytes {
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentSizeBytes))
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredSizeBytes))
throw RuntimeError.InvalidDiskSize("New disk size of \(desiredLengthHuman) should be larger " +
"than the current disk size of \(currentLengthHuman)")
} else if desiredSizeBytes > currentSizeBytes {
// Resize the ASIF disk image using diskutil
try performASIFResize(sizeGB)
} else {
if infoProcess.terminationStatus != 0 {
let output = String(data: infoData, encoding: .utf8) ?? "Unknown error"
throw RuntimeError.FailedToResizeDisk("Failed to get ASIF disk info: \(output)")
}
// Parse the plist using PropertyListDecoder
do {
let diskImageInfo = try PropertyListDecoder().decode(DiskImageInfo.self, from: infoData)
// Extract current size from the decoded structure
var currentSizeBytes: UInt64?
// Try to get size from Size Info -> Total Bytes first
if let totalBytes = diskImageInfo.sizeInfo?.totalBytes {
currentSizeBytes = totalBytes
} else if let size = diskImageInfo.size {
// Fallback to top-level Size field
currentSizeBytes = size
}
guard let currentSizeBytes = currentSizeBytes else {
throw RuntimeError.FailedToResizeDisk("Could not find size information in disk image info")
}
let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000
if desiredSizeBytes < currentSizeBytes {
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentSizeBytes))
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredSizeBytes))
throw RuntimeError.InvalidDiskSize("new disk size of \(desiredLengthHuman) should be larger " +
"than the current disk size of \(currentLengthHuman)")
} else if desiredSizeBytes > currentSizeBytes {
// Resize the ASIF disk image using diskutil
try performASIFResize(sizeGB)
}
// If sizes are equal, no action needed
} catch let error as RuntimeError {
throw error
} catch {
let outputString = String(data: infoData, encoding: .utf8) ?? "Unable to decode output"
throw RuntimeError.FailedToResizeDisk("Failed to parse disk image info: \(error). Output: \(outputString)")
}
} catch let error as RuntimeError {
throw error
} catch {
throw RuntimeError.FailedToResizeDisk("\(error)")
throw RuntimeError.FailedToResizeDisk("Failed to get disk image info: \(error)")
}
}
@@ -244,7 +304,7 @@ struct VMDirectory: Prunable {
case .raw:
try createRawDisk(sizeGB: sizeGB)
case .asif:
try Diskutil.imageCreate(diskURL: diskURL, sizeGB: sizeGB)
try createASIFDisk(sizeGB: sizeGB)
}
}
@@ -258,6 +318,38 @@ struct VMDirectory: Prunable {
try diskFileHandle.close()
}
private func createASIFDisk(sizeGB: UInt16) throws {
guard let diskutilURL = resolveBinaryPath("diskutil") else {
throw RuntimeError.FailedToCreateDisk("diskutil not found in PATH")
}
let process = Process()
process.executableURL = diskutilURL
process.arguments = [
"image", "create", "blank",
"--format", "ASIF",
"--size", "\(sizeGB)G",
"--volumeName", "Tart",
diskURL.path
]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
if process.terminationStatus != 0 {
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? "Unknown error"
throw RuntimeError.FailedToCreateDisk("Failed to create ASIF disk image: \(output)")
}
} catch {
throw RuntimeError.FailedToCreateDisk("Failed to execute diskutil: \(error)")
}
}
func delete() throws {
let lock = try lock()
@@ -299,21 +391,6 @@ struct VMDirectory: Prunable {
try sizeBytes() / 1000 / 1000 / 1000
}
func diskSizeBytes() throws -> Int {
let vmConfig = try VMConfig(fromURL: configURL)
return switch vmConfig.diskFormat {
case .raw:
try sizeBytes()
case .asif:
try Diskutil.imageInfo(diskURL).totalBytes()
}
}
func diskSizeGB() throws -> Int {
try diskSizeBytes() / 1000 / 1000 / 1000
}
func markExplicitlyPulled() {
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
}
+14
View File
@@ -74,6 +74,7 @@ enum RuntimeError : Error {
case ImportFailed(_ message: String)
case SoftnetFailed(_ message: String)
case OCIStorageError(_ message: String)
case OCIUnsupportedDiskFormat(_ format: String)
case SuspendFailed(_ message: String)
case PullFailed(_ message: String)
case VirtualMachineLimitExceeded(_ hint: String)
@@ -138,6 +139,8 @@ extension RuntimeError : CustomStringConvertible {
return "Softnet failed: \(message)"
case .OCIStorageError(let message):
return "OCI storage error: \(message)"
case .OCIUnsupportedDiskFormat(let format):
return "OCI disk format \(format) is not supported by this version of Tart"
case .SuspendFailed(let message):
return "Failed to suspend the VM: \(message)"
case .PullFailed(let message):
@@ -166,3 +169,14 @@ extension RuntimeError : HasExitCode {
}
}
}
// 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,
]
}
}
+1 -5
View File
@@ -1,11 +1,7 @@
import Foundation
class VMStorageLocal: PrunableStorage {
let baseURL: URL
init() throws {
baseURL = try Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
}
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
private func vmURL(_ name: String) -> URL {
baseURL.appendingPathComponent(name, isDirectory: true)
+11 -17
View File
@@ -1,13 +1,9 @@
import Foundation
import OpenTelemetryApi
import Sentry
import Retry
class VMStorageOCI: PrunableStorage {
let baseURL: URL
init() throws {
baseURL = try Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
}
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
private func vmURL(_ name: RemoteName) -> URL {
baseURL.appendingRemoteName(name)
@@ -145,10 +141,9 @@ class VMStorageOCI: PrunableStorage {
}
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "oci.image-name",
value: .string(name.description)
)
SentrySDK.configureScope { scope in
scope.setContext(value: ["imageName": name.description], key: "OCI")
}
defaultLogger.appendNewLine("pulling manifest...")
@@ -182,9 +177,7 @@ class VMStorageOCI: PrunableStorage {
}
if !exists(digestName) {
let span = OTel.shared.tracer.spanBuilder(spanName: "pull").setActive(true).startSpan()
defer { span.end() }
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
// Open an existing VM directory corresponding to this name, if any,
@@ -197,10 +190,9 @@ class VMStorageOCI: PrunableStorage {
// Try to reclaim some cache space if we know the VM size in advance
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
key: "oci.image-uncompressed-disk-size-bytes",
value: .int(Int(uncompressedDiskSize))
)
SentrySDK.configureScope { scope in
scope.setContext(value: ["imageUncompressedDiskSize": uncompressedDiskSize], key: "OCI")
}
let otherVMFilesSize: UInt64 = 128 * 1024 * 1024
@@ -233,7 +225,9 @@ class VMStorageOCI: PrunableStorage {
return .throw
}
try move(digestName, from: tmpVMDir)
transaction.finish()
}, onCancel: {
transaction.finish(status: SentrySpanStatus.cancelled)
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
} else {
+6 -2
View File
@@ -2,9 +2,13 @@ import XCTest
@testable import tart
final class DiskImageFormatTests: XCTestCase {
func testRawFormatIsAlwaysSupported() throws {
XCTAssertTrue(DiskImageFormat.raw.isSupported)
}
func testASIFFormatSupport() throws {
// ASIF should be supported on macOS 26+
if #available(macOS 26, *) {
// ASIF should be supported on macOS 15+
if #available(macOS 15, *) {
XCTAssertTrue(DiskImageFormat.asif.isSupported)
} else {
XCTAssertFalse(DiskImageFormat.asif.isSupported)
-21
View File
@@ -1,21 +0,0 @@
import XCTest
@testable import tart
final class DiskutilTests: XCTestCase {
func testDiskutilInfo() throws {
// Create a temporary directory
let tempDirURL = FileManager.default.temporaryDirectory.appendingPathComponent("tart-diskutil-tests-\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: tempDirURL, withIntermediateDirectories: true)
addTeardownBlock {
try? FileManager.default.removeItem(at: tempDirURL)
}
// Create a 123 GB ASIF disk
let diskURL = tempDirURL.appendingPathComponent("disk.asif")
try Diskutil.imageCreate(diskURL: diskURL, sizeGB: 123)
// Retrieve its information and ensure that it does indeed take 123 GB
let info = try Diskutil.imageInfo(diskURL)
XCTAssertEqual(123 * 1000 * 1000 * 1000, try info.totalBytes())
}
}
+23 -2
View File
@@ -14,7 +14,7 @@ final class LayerizerTests: XCTestCase {
do {
registryRunner = try await RegistryRunner()
} catch {
throw XCTSkip("Registry is unavailable: \(error)")
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
}
}
@@ -24,9 +24,30 @@ final class LayerizerTests: XCTestCase {
registryRunner = nil
}
func testDiskV1() async throws {
// Original disk file to be pushed to the registry
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
addTeardownBlock {
try FileManager.default.removeItem(at: originalDiskFileURL)
}
// Disk file to be pulled from the registry
// and compared against the original disk file
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
print("pushing disk...")
let diskLayers = try await DiskV1.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, concurrency: 4, progress: Progress())
print("pulling disk...")
try await DiskV1.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
print("comparing disks...")
try XCTAssertEqual(Digest.hash(originalDiskFileURL), Digest.hash(pulledDiskFileURL))
}
func testDiskV2() async throws {
// Original disk file to be pushed to the registry
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 1 * 1024 * 1024 * 1024)
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
addTeardownBlock {
try FileManager.default.removeItem(at: originalDiskFileURL)
}
+1 -1
View File
@@ -10,7 +10,7 @@ final class RegistryTests: XCTestCase {
do {
registryRunner = try await RegistryRunner()
} catch {
throw XCTSkip("Registry is unavailable: \(error)")
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ class RegistryRunner {
let stdoutPipe = Pipe()
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/docker")
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
proc.arguments = arguments
proc.standardOutput = stdoutPipe
try proc.run()
@@ -31,7 +31,7 @@ class RegistryRunner {
init() async throws {
// Start container
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "127.0.0.1::5000", "registry:2")
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "127.0.0.1:0:5000", "registry:2")
.trimmingCharacters(in: CharacterSet.newlines)
containerID = container
-18
View File
@@ -1,18 +0,0 @@
import XCTest
@testable import tart
final class VMConfigTests: XCTestCase {
func testVMDisplayConfig() throws {
// Defaults units (points)
var vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678")
XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: nil), vmDisplayConfig)
// Explicit units (points)
vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678pt")
XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: .point), vmDisplayConfig)
// Explicit units (pixels)
vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678px")
XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: .pixel), vmDisplayConfig)
}
}
-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/env bash
# Set shell options to enable fail-fast behavior
#
# * -e: fail the script when an error occurs or command fails
# * -u: fail the script when attempting to reference unset parameters
# * -o pipefail: by default an exit status of a pipeline is that of its
# last command, this fails the pipe early if an error in
# any of its commands occurs
#
set -euo pipefail
OUTPUT_PATH="Resources/actool"
PLIST_PATH="$OUTPUT_PATH/Info.plist"
rm -rf "${OUTPUT_PATH}"
mkdir -p "${OUTPUT_PATH}"
actool "Resources/UPW Tart.icon" \
--compile "${OUTPUT_PATH}" \
--output-format human-readable-text \
--notices \
--warnings \
--errors \
--app-icon "UPW Tart" \
--output-partial-info-plist $PLIST_PATH \
--include-all-app-icons \
--target-device mac \
--minimum-deployment-target 13.0 \
--platform macosx
+1 -1
View File
@@ -13,7 +13,7 @@ brew install go
Finally, run the following command from this (`benchmark/`) directory:
```shell
go run cmd/main.go fio --image ghcr.io/cirruslabs/macos-tahoe-base:latest --prepare 'sudo purge && sync'
go run cmd/main.go fio --image ghcr.io/cirruslabs/macos-sequoia-base:latest --prepare 'sudo purge && sync'
```
You can also enable the debugging output to diagnose issues:
+1 -2
View File
@@ -1,7 +1,6 @@
module github.com/cirruslabs/tart/benchmark
go 1.23.0
go 1.22.1
toolchain go1.24.1
require (
+1 -1
View File
@@ -23,7 +23,7 @@ func NewCommand() *cobra.Command {
}
cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging")
cmd.Flags().StringVar(&image, "image", "ghcr.io/cirruslabs/macos-tahoe-xcode:latest", "image to use for testing")
cmd.Flags().StringVar(&image, "image", "ghcr.io/cirruslabs/macos-sequoia-xcode:latest", "image to use for testing")
cmd.Flags().StringVar(&prepare, "prepare", "", "command to run before running each benchmark")
return cmd
-1
View File
@@ -11,4 +11,3 @@
"MD045": false # OK not to have a description for an image
"MD046": false # Code block style [Expected: fenced; Actual: indented]
"MD059": false # It's OK to have "here" links
"MD051": false # MkDocs generates "#-no-pki" anchors, but markdownlint expects "#--no-pki" anchors
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

@@ -11,12 +11,6 @@ categories:
# Changing Tart License
!!! note "Current license"
This post describes a historical license change announced on February 11, 2023.
As of June 5, 2026, Tart is maintained by OpenAI and licensed under
[FSL-1.1-ALv2](https://github.com/openai/tart/blob/main/LICENSE).
The usage limits, paid tiers, pricing, support commitments, and contact details described below no longer apply.
**TLDR:** We are transitioning Tart's licensing from AGPL-3.0 to [Fair Source 100](https://fair.io/). This change will
permit unlimited installations on personal computers, but organizations that exceed a certain number of server
installations utilizing 100 CPU cores will be required to obtain a paid license.
@@ -26,7 +20,7 @@ installations utilizing 100 CPU cores will be required to obtain a paid license.
Exactly a year ago on February 11th 2022 we started working on Tart – a tiny CLI to run macOS virtual machines on Apple Silicon.
Three months later we successfully started using Tart in our own production system and decided to share Tart with everyone.
<img src="https://github.com/openai/tart/raw/main/Resources/TartSocial.png"/>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
The goal was to establish a community of users and contributors to transform Tart from a small CLI to a robust tool
for various scenarios. **Unfortunately, we were not successful in attracting a significant number of contributors.**
@@ -66,10 +60,10 @@ device without a physical display connected. For example, a Mac Mini with a HDMI
but a Mac Mini on a desk with a connected physical display is considered a personal computer. **Usage on personal computers
and before reaching the 100 CPU cores limit is royalty-free and does not have the viral properties of AGPL.**
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a Gold Tier License,
which costs \$12,000 per year. Upon reaching a limit of 500 CPU cores, a Platinum Tier License
(\$36,000 per year) will be required, and for organizations that exceed 3000 CPU cores, a custom Diamond Tier License
(\$12 per core per year) will be necessary. **All paid license tiers will include priority feature development and SLAs on support with urgent issues.**
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Tier License](/licensing#license-tiers),
which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](/licensing#license-tiers)
(\$3000 per month) will be required, and for organizations that exceed 3000 CPU cores, a custom [Diamond Tier License](/licensing#license-tiers)
(\$1 per core per month) will be necessary. **All paid license tiers will include priority feature development and SLAs on support with urgent issues.**
## Have we considered alternatives?
@@ -80,5 +74,6 @@ this approach is not addressing concerns related to the viral nature of AGPL for
we concluded that transitioning to a source-available model with a mandatory paid licensing is fair, as the licensing fees
are relatively insignificant for companies that reach a significant level of usage.
If you have any questions or concerns, please feel free to reach out to [licensing@cirruslabs.org](mailto:licensing@cirruslabs.org).
If the new licensing model is not suitable for your organization, you are welcome to continue using the AGPL version of Tart,
but please ensure it is not used in a non-AGPL environment.
+4 -4
View File
@@ -78,7 +78,7 @@ in “worker” mode on macOS hosts. Orchard controller is using extremely fast
## Conclusion
Please give [Orchard](https://github.com/openai/orchard) a try! To run it locally in development mode on any Apple Silicon device
Please give [Orchard](https://github.com/cirruslabs/orchard) a try! To run it locally in development mode on any Apple Silicon device
please run the following command:
```bash
@@ -86,9 +86,9 @@ brew install cirruslabs/cli/orchard
orchard dev
```
This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/openai/orchard#creating-virtual-machines)
This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/cirruslabs/orchard#creating-virtual-machines)
on how to create your first virtual machine and access it.
In a [separate blog post](2023-04-28-orchard-ssh-over-grpc.md)
In a [separate blog post](/blog/2023/04/28/ssh-over-grpc-or-how-orchard-simplifies-accessing-vms-in-private-networks/)
we’ll cover how Orchard implements seamless SSH access over a gRPC connection. Stay tuned and please don’t hesitate to
[open an issue](https://github.com/openai/orchard/issues)!
[reach out](https://github.com/cirruslabs/orchard/discussions/landing)!
@@ -11,7 +11,7 @@ categories:
# SSH over gRPC or how Orchard simplifies accessing VMs in private networks
We started developing [Orchard](https://github.com/openai/orchard), an orchestrator for [Tart](https://tart.run/), with the requirement that it should allow users to access virtual machines running on worker nodes in private networks that users might not have access to.
We started developing [Orchard](https://github.com/cirruslabs/orchard), an orchestrator for [Tart](https://tart.run/), with the requirement that it should allow users to access virtual machines running on worker nodes in private networks that users might not have access to.
At the same time, we wanted to enable users to access VMs on these remote workers just as easily as they’d access network services on their local Tart VMs.
@@ -64,7 +64,7 @@ We’ve also initially considered using [Yamux](https://github.com/hashicorp/yam
First of all, we’ve made the new port-forwarding functionality available for integrations via the Orchard’s REST API:
![OpenAPI documentation for Orchard's port-forwarding endpoint](../../assets/images/orchard-port-forwarding-api.png)
![OpenAPI documentation for Orchard's port-forwarding endpoint](/assets/images/orchard-port-forwarding-api.png)
All you need is to use a WebSocket client when accessing this endpoint to make it work.
@@ -102,14 +102,14 @@ Overall, the technology described in this article somewhat resembles what [we pr
We really hope this feature will be useful for many, just as the Cirrus Terminal, and that it will remove the pain of scaling Tart beyond a single machine.
You can give [Orchard](https://github.com/openai/orchard) a try by running it locally in development mode on any Apple Silicon device:
You can give [Orchard](https://github.com/cirruslabs/orchard) a try by running it locally in development mode on any Apple Silicon device:
```bash
brew install cirruslabs/cli/orchard
orchard dev
```
This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/openai/orchard#creating-virtual-machines)
This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/cirruslabs/orchard#creating-virtual-machines)
on how to create your first virtual machine and access it.
Stay tuned and don’t hesitate to send us your feedback either [on GitHub](https://github.com/openai/orchard) or [Twitter](https://twitter.com/cirrus_labs)!
Stay tuned and don’t hesitate to send us your feedback either [on GitHub](https://github.com/cirruslabs/orchard) or [Twitter](https://twitter.com/cirrus_labs)!
+14 -10
View File
@@ -19,27 +19,31 @@ Today we'd like to share some news and updates around the Tart ecosystem since t
In the last 7 months Tart community almost tripled and growth is continuing to accelerate. Tart just crossed 25,000 installations,
dozens of companies that we know of are using Tart in their daily workflows. If your company is not in the list please consider
[joining](https://github.com/openai/tart/blob/main/Resources/Users/HowToAddYourself.md)!
[joining](https://github.com/cirruslabs/tart/blob/main/Resources/Users/HowToAddYourself.md)!
<div class="grid cards" markdown>
- ![](https://github.com/openai/tart/raw/main/Resources/Users/Krisp.png){ height="65" }
- ![](https://github.com/openai/tart/raw/main/Resources/Users/Mullvad.png){ height="65" }
- ![](https://github.com/openai/tart/raw/main/Resources/Users/ahrefs.png){ height="65" }
- ![](https://github.com/openai/tart/raw/main/Resources/Users/Suran.png){ height="65" }
- ![](https://github.com/openai/tart/raw/main/Resources/Users/Symflower.png){ height="65" }
- ![](https://github.com/openai/tart/raw/main/Resources/Users/Transloadit.png){ height="65" }
- ![](https://github.com/openai/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png){ height="65" }
- ![](https://github.com/openai/tart/raw/main/Resources/Users/Uphold.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png){ height="65" }
- ![](https://github.com/cirruslabs/tart/raw/main/Resources/Users/Uphold.png){ height="65" }
</div>
We are also very pleased by how the community responded to [the license change](2023-02-11-changing-tart-license.md).
We now have a number of companies running Tart at scale under the new license. Revenue from the licensing allowed us to
allocate time to continue improving Tart which brings us to the section below.
## Recent updates and what's changing in Tart 2.0.0
In the last 7 months we've had 12 feature releases that brought a lot of features requested by the community. Here are just
a few of them to highlight:
-[Custom GitLab Runner Executor](../../integrations/gitlab-runner.md).
-[Custom GitLab Runner Executor](/integrations/gitlab-runner/).
-[Cluster Management via Orchard](2023-04-25-orchard-ga.md).
-Numerous compatibility improvements for all kinds of OCI-registries.
-Sonoma Support (see details [below](#macos-sonoma-updates)).
+71
View File
@@ -0,0 +1,71 @@
---
draft: false
date: 2023-10-06
search:
exclude: true
authors:
- fkorotkov
categories:
- announcement
---
# Tart is now available on AWS Marketplace
Announcing [official AMIs for EC2 Mac Instances](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws)
with preconfigured Tart installation that is optimized to work within AWS infrastructure.
EC2 Mac Instances is a gem of engineering powered by AWS Nitro devices. Just imagine there is a physical Mac Mini with
a plugged in Nitro device that can push the physical power button!
![EC2 M2 Pro](/blog/images/ec2-mac2-m2pro.png)
This clever synergy between Apple Hardware and Nitro System allows seamless integration with VPC networking and booting macOS from an EBS volume.
In this blog post we’ll see how a virtualization solution like Tart can compliment and elevate experience with EC2 Mac Instances.
<!-- more -->
Let’s start from the basics, what EC2 Mac Instances allow to do compared to physical Mac Minis seating in offices of
many companies around the world?
First and foremost, EC2 Mac Instances sit inside AWS data centers and can leverage all the goodies of VPC networking
within your company's existing infrastructure. No need to connect your Macs in the office through a VPN and deal
with networking and security.
Additionally, EC2 Mac Instances are booting from EBS volumes which means it is possible to always have reproducible instances
and apply all the best practices of Infrastructure-as-Code. Managing a fleet of physical Macs is a pain and it's very hard
to make them configured in a reproducible and stable way. With booting from identical EBS volumes your team is always sure
about the identical initial state of the fleet.
## Compromises of EC2 Mac Instances
The flexibility of EBS volumes for macOS comes with some compromises that virtualization solutions like Tart can help with.
The initial boot from an EBS volume takes some time and not instant. macOS itself is pretty heavy and a Nitro device needs
to download tens of gigabytes that macOS requires in order to boot. This means that **resetting a EC2 Mac Instance to a clean state
is not instant and usually takes a couple of minutes** when you can’t utilize the precious resources for your workloads.
It is much easier to tailor such EBS volumes with tools like Packer but there is still a **friction to test newly created EBS volumes**
since one needs to start and run a EC2 Mac Instance and it’s not possible to test things locally. Similarly it is even harder
to test beta versions of macOS that require manual interaction with a running instance.
## Solution
Tart can help with all the compromises! Tart virtual machines (VMs) have nearly native performance thanks to utilizing
native `Virtualization.Framework` that was developed along the first Apple Silicon chip. **Tart VMs can be copied/disposed
instantly and booting a fresh Tart VM takes only several seconds**. It is also possible to run two different Tart VMs in parallel
that can have completely different versions of macOS and packages. For example, it is possible to have the latest stable macOS
with the release version of Xcode along with the next version of macOS with the latest beta of Xcode.
Creation of Tart VMs can be automated with [a Packer plugin](https://github.com/cirruslabs/packer-plugin-tart) the same way as
creation of EC2 AMIs with one caveat that **Tart Packer Plugin works locally so you can test the same virtual machine locally
as you would run it in the cloud**.
Lightweight nature of Tart VMs with a focus on an easy-to-integrate Tart CLI compliments any macOS automation and helps to reduce
the feedback cycle and improves reproducibility of macOS environments even further.
## Conclusion
We are excited to bring [official AMIs that include Tart installation optimized to work within AWS](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws).
In the coming weeks when macOS Sonoma will become available on AWS we’ll release another update specifically targeting EC2 Mac Instances.
This update will simplify access to local SSDs of Mac Instances that are slightly faster than EBS volumes. Stay tuned and don’t hesitate
to ask any [questions](https://tart.run/licensing/).
@@ -34,7 +34,7 @@ than recently announced Apple Silicon GitHub-manged runners that cost $0.16 per
Now lets take a look at the new Cirrus Runners dashboard of a real customers that run their workflows on Cirrus Runners
and **practically pushing the price performance pretty close to the theoretical minimum**.
![Cirrus Runners Dashboard](../images/runners-price-performance-2.png)
![Cirrus Runners Dashboard](/blog/images/runners-price-performance-2.png)
As you can see above Cirrus Runners Dashboard focuses on 4 core metrics:
@@ -50,7 +50,7 @@ we can see that the downside of such great price performance is that jobs are wa
Here is another example of Cirrus Runners Dashboard for a different customer that has a slightly higher price performance of $0.017 per minute
but at the same time doesn't experience queue time at all. **Note that $0.017 is still 10 times cheaper than GitHub-managed Apple Silicon runners**.
![Cirrus Runners Dashboard](../images/runners-price-performance-3.png)
![Cirrus Runners Dashboard](/blog/images/runners-price-performance-3.png)
## Conclusion
@@ -11,7 +11,7 @@ categories:
# Jumping through the hoops: SSH jump host functionality in Orchard
Almost a year ago, when we started building [Orchard](https://github.com/openai/orchard), an orchestration system for Tart, we quickly realized that most worker machines will be in a private network, and that VMs will be only reachable from the worker machines themselves. Thus, one of our goals became to simplify accessing the compute resources in a cluster through a centralized controller host.
Almost a year ago, when we started building [Orchard](https://github.com/cirruslabs/orchard), an orchestration system for Tart, we quickly realized that most worker machines will be in a private network, and that VMs will be only reachable from the worker machines themselves. Thus, one of our goals became to simplify accessing the compute resources in a cluster through a centralized controller host.
This effort resulted in commands like `orchard port-forward` and `orchard ssh`, which were later improved to support connecting not just to the VMs, but to the worker machines themselves.
@@ -55,6 +55,8 @@ Once running, you can connect to any VM in the cluster using the `ssh -J <servic
## Future plans
First of all, we’d like to thank our paid clients, without which this feature wouldn’t be possible. [Become one now](../../licensing.md) and get the benefit of higher Tart VMs and Orchard workers allowances and making sure that the roadmap for Tart and Orchard is aligned with your company's needs.
In the near future we plan to implement a mechanism similar to `authorized_keys` file that will allow attaching public SSH keys to the Orchard controller’s service accounts, and thus avoid the need to type the passwords.
Stay tuned and don’t hesitate to send us your feedback on [GitHub](https://github.com/openai/orchard) and [Twitter](https://x.com/cirrus_labs)!
Stay tuned and don’t hesitate to send us your feedback on [GitHub](https://github.com/cirruslabs/orchard) and [Twitter](https://x.com/cirrus_labs)!
@@ -11,9 +11,9 @@ categories:
# Bridging the gaps with the Tart Guest Agent
We're introducing a new improvement for the Tart usability experience: a [Tart Guest Agent](https://github.com/openai/tart-guest-agent).
We're introducing a new improvement for the Tart usability experience: a [Tart Guest Agent](https://github.com/cirruslabs/tart-guest-agent).
This agent provides automatic disk resizing, seamless clipboard sharing for macOS guests (a [long-awaited](https://github.com/openai/tart/issues/14) feature), and the ability to run commands, without SSH and networking, using the new `tart exec` command.
This agent provides automatic disk resizing, seamless clipboard sharing for macOS guests (a [long-awaited](https://github.com/cirruslabs/tart/issues/14) feature), and the ability to run commands, without SSH and networking, using the new `tart exec` command.
As of recently, we include this agent in all non-vanilla Cirrus Labs images, so you likely won't need to do anything to benefit from these usability improvements.
@@ -47,7 +47,7 @@ Using gRPC simplifies `tart exec` implementation because of code generation and
Thanks to [gRPC Swift](https://github.com/grpc/grpc-swift), which is built on top of [SwiftNIO](https://github.com/apple/swift-nio), we get [`async/await`](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/) support for free, further simplifying the `tart exec` logic.
As for the Tart Guest Agent, the final result is a Golang binary that [can be customized](https://github.com/openai/tart-guest-agent?tab=readme-ov-file#guest-agent-for-tart-vms) depending on the execution context:
As for the Tart Guest Agent, the final result is a Golang binary that [can be customized](https://github.com/cirruslabs/tart-guest-agent?tab=readme-ov-file#guest-agent-for-tart-vms) depending on the execution context:
* launchd global daemon — runs as a privileged user (`root`), has no clipboard access
* `--resize-disk` — resizes the disk when there's a free space at the end of a disk (assuming that one previously ran `tart set --disk-size`)
@@ -59,10 +59,14 @@ We’ve also introduced `--run-daemon` (which implies `--resize-disk`) and `--ru
## Future plans
First, we'd like to thank our paid clients, without whom this feature wouldn't have been possible.
[Become one now](../../licensing.md) and enjoy higher allowances for Tart VMs and Orchard workers—while helping ensure that our roadmap aligns with your company's needs.
In the near future we plan to implement:
* Linux support — to provide seamless experience for Linux guests too
* a new `tart ip` resolver — to provide a more robust IP retrieval facility for Linux guests, which often struggle to populate the host's ARP table with their network activity
* `tart cp` command — to copy files from/to guest VMs
Stay tuned, and feel free to send us feedback on [GitHub](https://github.com/openai/tart) and [Twitter](https://x.com/cirrus_labs)!
Stay tuned, and feel free to send us feedback on [GitHub](https://github.com/cirruslabs/tart) and [Twitter](https://x.com/cirrus_labs)!
+9 -62
View File
@@ -5,42 +5,6 @@ title: Frequently Asked Questions
description: Advanced configuration and troubleshooting tips for advanced configurations.
---
## Headless machines
Starting from macOS 15 (Sequoia), there's an undocumented requirement from [Virtualization.Framework](https://developer.apple.com/documentation/virtualization) (which Tart uses) to have an unlocked `login.keychain` available at the times when running a VM.
Without an existing and unlocked `login.keychain`, the VM won't start with errors like:
* `SecKeyCreateRandomKey_ios failed`
* `Failed to generate keypair`
* `Interaction is not allowed with the Security Server`
Below you'll find a couple of workarounds for this behavior.
### Log in via GUI at least once
Connect to the headless machine via [Screen Sharing](https://support.apple.com/guide/mac-help/share-the-screen-of-another-mac-mh14066/mac) and log in to a Mac user account. If you haven't done already, you can enable Screen Sharing [via the terminal](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/connect-to-mac-instance.html#mac-instance-vnc).
Logging in graphically will automatically create the `login.keychain`. Afterward, you have two options:
* configure [automatic log in to a Mac user account](https://support.apple.com/en-us/102316)
* this will maintain a running user session (GUI) even after the machine reboots
* moreover, you can still lock the screen (either manually [or automatically](https://support.apple.com/guide/mac-help/change-lock-screen-settings-on-mac-mh11784/mac)), however, the security benefit of this is questionable
* use `security unlock-keychain login.keychain` to unlock the login keychain via the terminal
* this command also supports the `-p` command-line argument, which allows you to supply a password and unlock non-interactively
### Create and unlock the login keychain via the terminal
Compared to the previous approach, this one is fully automated, but might stop working at some point in the future:
```shell
security create-keychain -p '' login.keychain
security unlock-keychain -p '' login.keychain
security login-keychain -s login.keychain
```
Note that this will create a `login.keychain` with an empty password. Consider supplying a different value to `-p` or omitting the `-p` to enter the password interactively.
## Troubleshooting crashes
If you experience a crash or encounter another error while using the tart executable, you can collect debug information to assist with troubleshooting. Run the following command in a separate terminal window to gather logs from the Tart process and the macOS Virtualization subsystem:
@@ -59,7 +23,7 @@ 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`
supports nested virtualization only on M3 or M4 chips running macOS 15 (Sequoia) or later and [only for Linux VMs](https://github.com/openai/tart/issues/1231#issuecomment-4410915463). By default, it is disabled, but can be enabled by passing the `--nested` flag to `tart run`.
supports nested virtualization only on M3 or M4 chips running macOS 15 (Sequoia). By default, it is disabled, but can be enabled by passing the `--nested` flag to `tart run`.
## Connecting to a service running on host
@@ -70,28 +34,13 @@ Then from within a virtual machine you can access the service using the router's
or by running the following command in the Terminal:
```shell
netstat -nr | awk '/default/{print $2; exit}'
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/openai/softnet) (via `tart run --net-softnet <VM NAME>)`, then the network isolation
[Softnet](https://github.com/cirruslabs/softnet) (via `tart run --net-softnet <VM NAME>)`, then the network isolation
is stricter and it's not possible to access the host.
## Avoiding the "Local Network" permission pop-up
Starting from macOS 15 (Sequoia), a GUI "Local Network" permission pop-up might appear when Tart is used by another tool that needs to connect to a VM over a private IPv4 network, for example [Packer](https://developer.hashicorp.com/packer/integrations/cirruslabs/tart/latest/components/builder/tart).
This is not caused by Tart itself, but by the host-side process that needs network access into the guest.
If you need a non-interactive workaround, you can configure the [local network privacy preferences](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy#macOS-considerations) on the host so that all [RFC 1918](https://datatracker.ietf.org/doc/html/rfc1918#section-3) address ranges that Tart VMs commonly use are excluded from the prompt:
```shell
sudo defaults write com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"
sudo defaults write com.apple.network.local-network AllowedWiFiLocalNetworkAddresses -array "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"
```
After applying these settings, reboot the host.
## Changing the default NAT subnet
To change the default network to `192.168.77.1`:
@@ -112,7 +61,7 @@ sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.pli
By default, the built-in macOS DHCP server allocates IP-addresses to the VMs for the duration of 86,400 seconds (one day), which may easily cause DHCP exhaustion if you run more than ~253 VMs per day, or in other words, more than one VM every ~6 minutes.
This issue is worked around automatically [when using Softnet](https://github.com/openai/softnet), however, if you don't use or can't use it, the following command will reduce the lease time from the default 86,400 seconds (one day) to 600 seconds (10 minutes):
This issue is worked around automatically [when using Softnet](http://github.com/cirruslabs/softnet), however, if you don't use or can't use it, the following command will reduce the lease time from the default 86,400 seconds (one day) to 600 seconds (10 minutes):
```shell
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.InternetSharing.default.plist bootpd -dict DHCPLeaseTimeSecs -int 600
@@ -194,17 +143,17 @@ This is because Tart uses [Keychain](https://en.wikipedia.org/wiki/Keychain_(sof
To unlock the Keychain in an SSH session, run the following command, which will ask for your user's password:
```shell
security unlock-keychain login.keychain
security unlock-keychain
```
This command also supports the `-p` command-line argument that allows you to supply a password and unlock non-interactively, which is great for scripts.
This command also supports the `-p` command-line argument that allows you to supply the password and unlock non-interactively, which is great for scripts.
Alternatively, you can pass the credentials via the environment variables, see [Registry Authorization](quick-start.md#registry-authorization) for more details on how to do that.
If that doesn't work for you for some reason, you can pass the credentials via the environment variables, see [Registry Authorization](integrations/vm-management.md#registry-authorization) for more details on how to do that.
## How is Tart different from Anka?
## 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/openai/tart/issues).
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.
@@ -217,8 +166,6 @@ Tart does have an analogue of Anka Controller for managing VMs across a cluster
In case there's not enough space to fit the newly pulled or cloned VM image, Tart will remove the least recently accessed VMs from OCI cache and `.ipsw` files from IPSW cache until enough free space is available.
The `tart clone` command limits this automatic pruning to 100 GB by default to avoid removing too many cached items. You can change this limit with the `--prune-limit` option (in gigabytes).
To disable this functionality, set the `TART_NO_AUTO_PRUNE` environment variable either globally:
```shell
+3 -3
View File
@@ -7,7 +7,7 @@ description: Run pipeline steps in isolated ephemeral Tart Virtual Machines.
It is possible to run [Buildkite](https://buildkite.com/) pipeline steps in isolated ephemeral Tart Virtual Machines with the help of [Tart Buildkite Plugin](https://github.com/cirruslabs/tart-buildkite-plugin):
![](../assets/images/BuildkiteTartPlugin.png)
![](/assets/images/BuildkiteTartPlugin.png)
## Configuration
@@ -18,9 +18,9 @@ steps:
- command: uname -a
plugins:
- cirruslabs/tart#main:
image: ghcr.io/cirruslabs/macos-tahoe-base:latest
image: ghcr.io/cirruslabs/macos-sequoia-base:latest
```
This will run `uname -r` in a macOS Tart VM cloned from `ghcr.io/cirruslabs/macos-tahoe-base:latest`.
This will run `uname -r` in a macOS Tart VM cloned from `ghcr.io/cirruslabs/macos-sequoia-base:latest`.
See plugin's [Configuration section](https://github.com/cirruslabs/tart-buildkite-plugin#configuration) for the full list of available options.
+6 -6
View File
@@ -5,8 +5,8 @@ description: Tool for running isolated tasks reproducibly in any environment wit
# Cirrus CLI
Tart itself is only responsible for managing virtual machines, but Cirrus Labs built Tart support into a tool called
[Cirrus CLI](https://github.com/cirruslabs/cirrus-cli), a command line tool with
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.
@@ -18,7 +18,7 @@ task:
name: hello
macos_instance:
# can be a remote or a local virtual machine
image: ghcr.io/cirruslabs/macos-tahoe-base:latest
image: ghcr.io/cirruslabs/macos-sequoia-base:latest
hello_script:
- echo "Hello from within a Tart VM!"
- echo "Here is my CPU info:"
@@ -33,12 +33,12 @@ brew install cirruslabs/cli/cirrus
cirrus run
```
![](../assets/images/TartCirrusCLI.gif)
![](/assets/images/TartCirrusCLI.gif)
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
**Note:** Cirrus CI only allows [images managed and regularly updated by Cirrus Labs](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
**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
@@ -50,7 +50,7 @@ exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tas
task:
name: Build
macos_instance:
image: ghcr.io/cirruslabs/macos-tahoe-xcode:latest
image: ghcr.io/cirruslabs/macos-sequoia-xcode:latest
build_script: swift build --product tart
binary_artifacts:
path: .build/debug/tart
+1 -1
View File
@@ -42,7 +42,7 @@ Now you can use Tart Images in your `.gitlab-ci.yml`:
```yaml
# You can use any remote Tart Image.
# Tart Executor will pull it from the registry and use it for creating ephemeral VMs.
image: ghcr.io/cirruslabs/macos-tahoe-base:latest
image: ghcr.io/cirruslabs/macos-sequoia-base:latest
test:
tags:
-41
View File
@@ -1,41 +0,0 @@
---
title: Automating VM image building with Packer
description: Use Packer to build custom VM images, configure VMs and work with remote OCI registries.
---
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 a 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-tahoe-base:latest"
vm_name = "my-custom-tahoe"
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 Cirrus Labs](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
+143
View File
@@ -0,0 +1,143 @@
---
title: Managing Virtual Machine
description: Use Packer to build custom VM images, configure VMs and work with remote OCI registries.
---
# Managing Virtual Machine
## Creating from scratch
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regardless 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 sequoia-vanilla
tart run sequoia-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 after VM creation 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 a 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-sequoia-base:latest"
vm_name = "my-custom-sequoia"
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
Tart supports interacting with Open Container Initiative (OCI) registries, but only runs images created and pushed by Tart. This means images created for container engines, like Docker, can't be pulled. Instead, create a custom image as documented above.
For example, let's say you want to push/pull images to an OCI registry hosted at `https://acme.io/`.
### Registry Authorization
First, you need to login to `acme.io` with the `tart login` command:
```bash
tart login acme.io
```
If you login to your registry with OAuth, you may need to create an access token to use as the password.
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 create a VM from a remote image:
```bash
tart clone acme.io/remoteorg/name:latest my-local-vm-name
```
If the specified image is not already present, this invocation calls the `tart pull` implicitly before cloning.
+113
View File
@@ -0,0 +1,113 @@
---
search:
exclude: true
---
<!-- markdownlint-disable -->
# Privacy Policy
In addition to this Privacy Policy, Cirrus Labs also has a [Terms of Service](terms.md).
### The Gist
Cirrus Labs Inc will collect certain non-personally identify information about you as you use our sites. We may use
this data to better understand our users. We can also publish this data, but the data will be about a large group of users,
not individuals.
We will also ask you to provide personal information, but you'll always be able to opt out. If you give us personal
information, we won't do anything evil with it.
We can also use cookies, but you can choose not to store these.
That's the basic idea, but you must read through the entire Privacy Policy below and agree with all the details
before you use any of our sites.
### Reuse
This document is based upon the [Automattic Privacy Policy](https://automattic.com/privacy/) and is licensed under
[Creative Commons Attribution Share-Alike License 2.5](https://creativecommons.org/licenses/by-sa/2.5/). Basically,
this means you can use it verbatim or edited, but you must release new versions under the same license and
you have to credit Automattic somewhere (like this!). Automattic is not connected with and does not sponsor or endorse
Cirrus Labs Inc or its use of the work.
Cirrus Labs Inc ("Cirrus Labs") makes available services include our web sites (https://tart.run/), our blog, our API,
and any other software, sites, and services offered by Cirrus Labs Inc in connection to any of those (taken together, the "Service").
It is Cirrus Labs Inc's policy to respect your privacy regarding any information we may collect while operating our websites.
### Questions
If you have question about this Privacy Policy, please contact us at hello@cirruslabs.org
### Visitors
Like most website operators, Cirrus Labs Inc collects non-personally-identifying information of the sort that web browsers and
servers typically make available, such as the browser type, language preference, referring site, and the date and time of each visitor request.
Cirrus Labs Inc's purpose in collecting non-personally identifying information is to better understand how Cirrus Labs Inc's
visitors use its website. From time to time, Cirrus Labs Inc may release non-personally-identifying information in the aggregate,
e.g., by publishing a report on trends in the usage of its website.
Cirrus Labs Inc also collects potentially personally-identifying information like Internet Protocol (IP) addresses.
Cirrus Labs Inc does not use such information to identify its visitors, however, and does not disclose such information,
other than under the same circumstances that it uses and discloses personally-identifying information, as described below.
We may also collect and use IP addresses to block users who violated our Terms of Service.
### Gathering of Personally-Identifying Information
Certain visitors to Cirrus Labs Inc's websites choose to interact with Cirrus Labs Inc in ways that require
Cirrus Labs Inc to gather personally-identifying information. The amount and type of information that Cirrus Labs Inc gathers
depends on the nature of the interaction. Cirrus Labs Inc collects such information only insofar as is necessary or
appropriate to fulfill the purpose of the visitor's interaction with Cirrus Labs Inc. Cirrus Labs Inc does not disclose
personally-identifying information other than as described below. And visitors can always refuse to supply personally-identifying information,
with the caveat that it may prevent them from engaging in certain Service-related activities.
Additionally, some interactions, such as posting a comment, may ask for optional personal information. For instance,
when posting a comment, may provide a website that will be displayed along with a user's name when the comment is displayed.
Supplying such personal information is completely optional and is only displayed for the benefit and the convenience of the user.
### Aggregated Statistics
Cirrus Labs Inc may collect statistics about the behavior of visitors to the Service. For instance, Cirrus Labs Inc
may monitor the most popular parts of the https://tart.run/. Cirrus Labs Inc may display this information publicly or
provide it to others. However, Cirrus Labs Inc does not disclose personally-identifying information other than as described below.
### Protection of Certain Personally-Identifying Information
Cirrus Labs Inc discloses potentially personally-identifying and personally-identifying information only to those of its employees,
contractors and affiliated organizations that (i) need to know that information in order to process it on Cirrus Labs Inc's behalf
or to provide services available at Cirrus Labs Inc's websites, and (ii) that have agreed not to disclose it to others.
Some of those employees, contractors and affiliated organizations may be located outside of your home country; by using the Service,
you consent to the transfer of such information to them. Cirrus Labs Inc will not rent or sell potentially personally-identifying and
personally-identifying information to anyone. Other than to its employees, contractors and affiliated organizations, as described above,
Cirrus Labs Inc discloses potentially personally-identifying and personally-identifying information only when required to do so by law,
or when Cirrus Labs Inc believes in good faith that disclosure is reasonably necessary to protect the property or rights of Cirrus Labs Inc,
third parties or the public at large. If you are a registered user of the Service and have supplied your email address, Cirrus Labs Inc may
occasionally send you an email to tell you about new features, solicit your feedback, or just keep you up to date with what's going on with
Cirrus Labs Inc and our products. We primarily use our website and blog to communicate this type of information, so we expect to keep
this type of email to a minimum. If you send us a request (for example via a support email or via one of our feedback mechanisms),
we reserve the right to publish it in order to help us clarify or respond to your request or to help us support other users.
Cirrus Labs Inc takes all measures reasonably necessary to protect against the unauthorized access, use, alteration or
destruction of potentially personally-identifying and personally-identifying information.
### Browser Cookies
A cookie is a string of information that a website stores on a visitor's computer, and that the visitor's browser provides
to the Service each time the visitor returns. Cirrus Labs Inc uses cookies to help Cirrus Labs Inc identify and track visitors,
their usage of Cirrus Labs Inc Service, and their Service access preferences. Cirrus Labs Inc visitors who do not wish to have
cookies placed on their computers should set their browsers to refuse cookies before using Cirrus Labs Inc's websites, with
the drawback that certain features of Cirrus Labs Inc's websites may not function properly without the aid of cookies.
### Data Storage
Cirrus Labs Inc uses third party vendors and hosting partners to provide the necessary hardware, software, networking,
storage, and related technology required to run the Service. You understand that although you retain full rights to your data,
it may be stored on third party storage and transmitted through third party networks.
### Privacy Policy Changes
Although most changes are likely to be minor, Cirrus Labs Inc may change its Privacy Policy from time to time,
and in Cirrus Labs Inc's sole discretion. Cirrus Labs Inc encourages visitors to frequently check this page for any changes
to its Privacy Policy. Your continued use of this site after any change in this Privacy Policy will constitute your
acceptance of such change.
This page was last updated on 02/20/2023.

Some files were not shown because too many files have changed in this diff Show More