Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9ed1a98f0 | ||
|
|
057646cf89 | ||
|
|
512c1c3630 | ||
|
|
9e6e59b379 | ||
|
|
32d084e9ed | ||
|
|
0a01a4430c | ||
|
|
d1bfda63fc | ||
|
|
2e63759c1b | ||
|
|
6ada2b955d | ||
|
|
1ea60ef420 | ||
|
|
5ad172e7f0 | ||
|
|
5287b597a1 | ||
|
|
8aa377b71e | ||
|
|
1e52e17c21 | ||
|
|
d39f7c6036 | ||
|
|
abfbb10618 | ||
|
|
094f850046 | ||
|
|
f1305dc083 | ||
|
|
605234b5dd | ||
|
|
be272d8abd | ||
|
|
faa40b6832 | ||
|
|
d45ef38cf7 | ||
|
|
e26b376d51 | ||
|
|
8f8a24ad19 | ||
|
|
29e0606ea3 | ||
|
|
594c6d74cd | ||
|
|
fc159c9992 | ||
|
|
863e3c2925 | ||
|
|
372affb0dc | ||
|
|
37b8219579 | ||
|
|
f1aa591935 | ||
|
|
6189dc23af | ||
|
|
361465748b | ||
|
|
e0147448a8 | ||
|
|
7038c45f8b | ||
|
|
44892c5def | ||
|
|
20dcfc83f2 | ||
|
|
c192de20f5 | ||
|
|
e28d9337a5 | ||
|
|
68ffa6c5e4 | ||
|
|
1b091e9db0 | ||
|
|
902b1a6c9c | ||
|
|
90d9500133 | ||
|
|
b05c731510 | ||
|
|
d762fe6fc1 | ||
|
|
eff964b62a | ||
|
|
590e064e35 | ||
|
|
839c6e7562 | ||
|
|
e3ee2da2fd | ||
|
|
84147f29b5 | ||
|
|
a655edd826 | ||
|
|
df100f1ca2 | ||
|
|
02bf5651e7 | ||
|
|
96c89ad76e | ||
|
|
b78fa6ba1c | ||
|
|
e443cfa9a2 | ||
|
|
e35c13425e | ||
|
|
0debec1266 | ||
|
|
294c5fc5e5 | ||
|
|
99777b6740 | ||
|
|
a2972aa4d9 |
@@ -0,0 +1,24 @@
|
||||
#!/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"
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
set -e
|
||||
|
||||
export VERSION="${CIRRUS_TAG:-0}"
|
||||
export VERSION="${VERSION:-0}"
|
||||
|
||||
mkdir -p .ci/pkg/
|
||||
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#!/bin/sh
|
||||
|
||||
TMPFILE=$(mktemp)
|
||||
envsubst < Sources/tart/CI/CI.swift > $TMPFILE
|
||||
mv $TMPFILE Sources/tart/CI/CI.swift
|
||||
set -e
|
||||
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string ${CIRRUS_TAG}" Resources/Info.plist
|
||||
: "${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
|
||||
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string ${VERSION}" Resources/Info.plist
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
APP_PATH="dist/tart_darwin_all/tart.app"
|
||||
|
||||
if [ "${TART_RELEASE_SNAPSHOT:-false}" = "true" ]; then
|
||||
codesign \
|
||||
--force \
|
||||
--deep \
|
||||
--sign - \
|
||||
--entitlements Resources/tart-dev.entitlements \
|
||||
"$APP_PATH"
|
||||
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 \
|
||||
"$APP_PATH"
|
||||
fi
|
||||
|
||||
codesign --verify --strict --verbose=2 "$APP_PATH"
|
||||
"$APP_PATH/Contents/MacOS/tart" --version
|
||||
|
||||
if [ "${TART_RELEASE_SNAPSHOT:-false}" != "true" ]; then
|
||||
NOTARIZATION_ARCHIVE="$RUNNER_TEMP/tart-notarization.zip"
|
||||
|
||||
ditto -c -k --keepParent "$APP_PATH" "$NOTARIZATION_ARCHIVE"
|
||||
xcrun notarytool submit "$NOTARIZATION_ARCHIVE" \
|
||||
--keychain-profile "notarytool" \
|
||||
--keychain "$RUNNER_TEMP/build.keychain" \
|
||||
--wait \
|
||||
--timeout 20m
|
||||
xcrun stapler staple "$APP_PATH"
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
spctl --assess --type execute --verbose=4 "$APP_PATH"
|
||||
fi
|
||||
@@ -1,7 +1,7 @@
|
||||
use_compute_credits: true
|
||||
|
||||
task:
|
||||
name: Test on Sequoia
|
||||
name: Test
|
||||
alias: test
|
||||
persistent_worker:
|
||||
labels:
|
||||
@@ -11,6 +11,8 @@ 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
|
||||
@@ -21,6 +23,7 @@ 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
|
||||
@@ -37,7 +40,7 @@ task:
|
||||
name: Lint
|
||||
alias: lint
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-runner:sequoia
|
||||
image: ghcr.io/cirruslabs/macos-runner:tahoe
|
||||
lint_script:
|
||||
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
||||
always:
|
||||
@@ -54,103 +57,17 @@ task:
|
||||
name: Build ($BUILD_ARCH)
|
||||
alias: build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-runner:sequoia
|
||||
image: ghcr.io/cirruslabs/macos-runner:tahoe
|
||||
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/cirruslabs/mkdocs-material-insiders:latest
|
||||
image: ghcr.io/squidfunk/mkdocs-material:latest
|
||||
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
|
||||
env:
|
||||
DEPLOY_TOKEN: ENCRYPTED[!45ed45666558902ed1c2400add734ec063103bec31841847e8c8764802fca229bfa6d85c690e16ad159e047574b48793!]
|
||||
|
||||
@@ -4,3 +4,8 @@ root = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
|
||||
[integration-tests/**]
|
||||
indent_style = unset
|
||||
indent_size = unset
|
||||
insert_final_newline = unset
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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
|
||||
@@ -0,0 +1,37 @@
|
||||
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 ./...
|
||||
@@ -0,0 +1,111 @@
|
||||
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:
|
||||
AC_PASSWORD: ${{ secrets.AC_PASSWORD }}
|
||||
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"
|
||||
xcrun notarytool store-credentials "notarytool" \
|
||||
--apple-id "hello@cirruslabs.org" \
|
||||
--team-id "9M2P8L4D89" \
|
||||
--password "$AC_PASSWORD" \
|
||||
--keychain "$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/*
|
||||
@@ -5,8 +5,8 @@ project_name: tart
|
||||
before:
|
||||
hooks:
|
||||
- .ci/set-version.sh
|
||||
- swift build --arch arm64 --configuration release --product tart
|
||||
- swift build --arch x86_64 --configuration release --product tart
|
||||
- sh .ci/build-release.sh arm64
|
||||
- sh .ci/build-release.sh x86_64
|
||||
|
||||
builds:
|
||||
- id: tart
|
||||
@@ -19,26 +19,33 @@ builds:
|
||||
- amd64
|
||||
binary: tart.app/Contents/MacOS/tart
|
||||
prebuilt:
|
||||
path: '.build/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}-apple-macosx/release/tart'
|
||||
path: '.build/prebuilt/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}/tart'
|
||||
|
||||
universal_binaries:
|
||||
- name_template: tart.app/Contents/MacOS/tart
|
||||
replace: true
|
||||
hooks:
|
||||
post: gon gon.hcl
|
||||
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
|
||||
|
||||
archives:
|
||||
- name_template: "{{ .ProjectName }}"
|
||||
files:
|
||||
- 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
|
||||
- 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
|
||||
- LICENSE
|
||||
|
||||
release:
|
||||
@@ -46,9 +53,14 @@ release:
|
||||
|
||||
brews:
|
||||
- name: tart
|
||||
directory: Formula
|
||||
repository:
|
||||
owner: cirruslabs
|
||||
name: homebrew-cli
|
||||
owner: openai
|
||||
name: homebrew-tools
|
||||
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
|
||||
branch: "tart-{{ .Version }}"
|
||||
pull_request:
|
||||
enabled: true
|
||||
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:
|
||||
@@ -56,15 +68,19 @@ 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/cirruslabs/tart
|
||||
license: "Fair Source"
|
||||
homepage: https://github.com/openai/tart
|
||||
license: FSL-1.1-ALv2
|
||||
description: Run macOS and Linux VMs on Apple Hardware
|
||||
skip_upload: auto
|
||||
dependencies:
|
||||
- "cirruslabs/cli/softnet"
|
||||
- "openai/tools/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: |
|
||||
depends_on :macos => :ventura
|
||||
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
|
||||
|
||||
@@ -20,7 +20,7 @@ Table of Contents
|
||||
```
|
||||
## How to Create an Issue/Enhancement
|
||||
|
||||
1. Go to the [Issue page](https://github.com/cirruslabs/tart/issues) of the repository
|
||||
1. Go to the [Issue page](https://github.com/openai/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
|
||||
@@ -29,6 +29,7 @@ Table of Contents
|
||||
|
||||
1. Code should follow camel case
|
||||
2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command:
|
||||
|
||||
```bash
|
||||
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
|
||||
```
|
||||
|
||||
@@ -1,45 +1,105 @@
|
||||
Fair Source License, version 0.9
|
||||
# Functional Source License, Version 1.1, ALv2 Future License
|
||||
|
||||
Copyright (C) 2023 Cirrus Labs, Inc.
|
||||
## Abbreviation
|
||||
|
||||
Licensor: Cirrus Labs, Inc.
|
||||
FSL-1.1-ALv2
|
||||
|
||||
Software: Tart
|
||||
## Notice
|
||||
|
||||
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.
|
||||
Copyright 2022-2026 OpenAI
|
||||
|
||||
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.
|
||||
## Terms and Conditions
|
||||
|
||||
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.
|
||||
### Licensor ("We")
|
||||
|
||||
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 party offering the Software under these Terms and Conditions.
|
||||
|
||||
Trademarks. This license does not grant you any right in the
|
||||
trademarks, service marks, brand names or logos of Licensor.
|
||||
### 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
### 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.
|
||||
|
||||
@@ -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-sequoia-base:latest
|
||||
/usr/bin/time -l tart pull ghcr.io/cirruslabs/macos-tahoe-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-sequoia-base:latest
|
||||
xctrace record --template "CPU Profiler" --target-stdout - --launch -- /opt/homebrew/bin/tart pull ghcr.io/cirruslabs/macos-tahoe-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:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "c5371137580239f6928cf64425e754e77680bf24430b50f02dad5558c23f68b0",
|
||||
"originHash" : "061dfe6cdf4e6dbf32b51c5e7023c4ae69726dcafb42a35b34e5489b0338c17f",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "antlr4",
|
||||
@@ -13,19 +13,19 @@
|
||||
{
|
||||
"identity" : "cirruslabs_tart-guest-agent_apple_swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://buf.build/gen/swift/git/1.28.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_apple_swift.git",
|
||||
"location" : "https://buf.build/gen/swift/git/1.33.3-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_apple_swift.git",
|
||||
"state" : {
|
||||
"revision" : "ccfae5de1917cdb0d7c5000008fa5ed0bad032bf",
|
||||
"version" : "1.28.2-00000000000000-17d7dedafb88.1"
|
||||
"revision" : "5c49a653f4b003161077d194bc708b7373628c99",
|
||||
"version" : "1.33.3-20260114140118-bd09c26a260f.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "cirruslabs_tart-guest-agent_grpc_swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://buf.build/gen/swift/git/1.24.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_grpc_swift.git",
|
||||
"location" : "https://buf.build/gen/swift/git/1.27.1-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_grpc_swift.git",
|
||||
"state" : {
|
||||
"branch" : "1.24.2-00000000000000-17d7dedafb88.1",
|
||||
"revision" : "b8421f137325fe8de737ff5b61238f6f2131b2a8"
|
||||
"branch" : "main",
|
||||
"revision" : "4935078c2fe2508360843596d71a1f844ce639a6"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -42,8 +42,35 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/grpc/grpc-swift.git",
|
||||
"state" : {
|
||||
"revision" : "8c5e99d0255c373e0330730d191a3423c57373fb",
|
||||
"version" : "1.24.2"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -55,15 +82,6 @@
|
||||
"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",
|
||||
@@ -78,8 +96,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
|
||||
"version" : "1.5.0"
|
||||
"revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3",
|
||||
"version" : "1.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -87,8 +105,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
|
||||
"version" : "1.2.0"
|
||||
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -123,8 +141,17 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "9cb486020ebf03bfa5b5df985387a14a98744537",
|
||||
"version" : "1.6.1"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -132,8 +159,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"state" : {
|
||||
"revision" : "34d486b01cd891297ac615e40d5999536a1e138d",
|
||||
"version" : "2.83.0"
|
||||
"revision" : "233f61bc2cfbb22d0edeb2594da27a20d2ce514e",
|
||||
"version" : "2.93.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -186,8 +213,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||
"state" : {
|
||||
"revision" : "ebc7251dd5b37f627c93698e4374084d98409633",
|
||||
"version" : "1.28.2"
|
||||
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
|
||||
"version" : "1.33.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -261,6 +288,15 @@
|
||||
"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
|
||||
|
||||
@@ -10,22 +10,24 @@ let package = Package(
|
||||
.executable(name: "tart", targets: ["tart"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"),
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.6.1"),
|
||||
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
|
||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
|
||||
.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.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"),
|
||||
.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"),
|
||||
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
@@ -35,7 +37,6 @@ 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"),
|
||||
@@ -44,6 +45,10 @@ 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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
|
||||
<img src="https://github.com/openai/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,67 +8,52 @@ 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/cirruslabs/tart/raw/main/Resources/Users/Atlassian.png" height="65"/>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Atlassian.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://www.figma.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Figma.png" height="65"/>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Figma.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://mullvad.net/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://krisp.ai/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://testingbot.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://symflower.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://transloadit.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://cirrus-ci.org/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://www.pitsdatarecovery.net/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://expo.dev/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Expo.png" height="65"/>
|
||||
<img src="https://github.com/openai/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/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>
|
||||
**Note:** If your company or project is using Tart please consider [sharing with the community](https://github.com/openai/tart/discussions/857).
|
||||
|
||||
## 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 cirruslabs/cli/tart
|
||||
tart clone ghcr.io/cirruslabs/macos-sequoia-base:latest sequoia-base
|
||||
tart run sequoia-base
|
||||
brew install openai/tools/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/cirruslabs/tart/discussions)
|
||||
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/openai/tart/discussions)
|
||||
for remaining questions.
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 120 KiB |
@@ -7,15 +7,17 @@
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Tart</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.cirruslabs.tart</string>
|
||||
<string>com.github.cirruslabs.tart</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>tart</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon.png</string>
|
||||
</array>
|
||||
<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>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?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>
|
||||
@@ -1,5 +1,5 @@
|
||||
struct CI {
|
||||
private static let rawVersion = "${CIRRUS_TAG}"
|
||||
private static let rawVersion = "${VERSION}"
|
||||
|
||||
static var version: String {
|
||||
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
|
||||
|
||||
@@ -31,6 +31,9 @@ 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")
|
||||
@@ -42,8 +45,8 @@ struct Clone: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
let ociStorage = VMStorageOCI()
|
||||
let localStorage = VMStorageLocal()
|
||||
let ociStorage = try VMStorageOCI()
|
||||
let localStorage = try 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
|
||||
@@ -76,8 +79,10 @@ 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()
|
||||
if unallocatedBytes > 0 {
|
||||
try Prune.reclaimIfNeeded(UInt64(unallocatedBytes), sourceVM)
|
||||
// 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)
|
||||
}
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import NIOPosix
|
||||
import GRPC
|
||||
import Cirruslabs_TartGuestAgent_Grpc_Swift
|
||||
|
||||
@@ -41,19 +40,12 @@ struct Exec: AsyncParsableCommand {
|
||||
throw RuntimeError.VMNotRunning(name)
|
||||
}
|
||||
|
||||
// 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()
|
||||
// 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())
|
||||
}
|
||||
|
||||
// Switch controlling terminal into raw mode when remote pseudo-terminal is requested
|
||||
@@ -71,7 +63,10 @@ struct Exec: AsyncParsableCommand {
|
||||
|
||||
// Execute a command in a running VM
|
||||
do {
|
||||
try await execute(channel)
|
||||
let controlSocketPath = vmDir.controlSocketURL.relativePath
|
||||
try await withGuestAgentChannel(unixDomainSocketPath: controlSocketPath) { channel in
|
||||
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?")
|
||||
}
|
||||
@@ -87,11 +82,13 @@ struct Exec: AsyncParsableCommand {
|
||||
$0.args = Array(command.dropFirst(1))
|
||||
$0.interactive = interactive
|
||||
$0.tty = tty
|
||||
$0.terminalSize = .with {
|
||||
let (width, height) = try! Term.GetSize()
|
||||
if tty {
|
||||
$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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -100,28 +97,66 @@ struct Exec: AsyncParsableCommand {
|
||||
try await withThrowingTaskGroup { group in
|
||||
// Stream host's standard input if interactive mode is enabled
|
||||
if interactive {
|
||||
let stdinStream = AsyncStream<Data> { continuation in
|
||||
let stdinStream = AsyncThrowingStream<Data, Error> { continuation in
|
||||
let handle = FileHandle.standardInput
|
||||
|
||||
handle.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
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
|
||||
|
||||
continuation.yield(data)
|
||||
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
|
||||
|
||||
if data.isEmpty {
|
||||
continuation.finish()
|
||||
continuation.finish()
|
||||
} else {
|
||||
continuation.yield(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.addTask {
|
||||
for await stdinData in stdinStream {
|
||||
for try 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()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,3 +213,13 @@ 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,7 +2,6 @@ import ArgumentParser
|
||||
import Foundation
|
||||
import Network
|
||||
import SystemConfiguration
|
||||
import Sentry
|
||||
|
||||
enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
||||
case dhcp, arp, agent
|
||||
@@ -69,7 +68,15 @@ struct IP: AsyncParsableCommand {
|
||||
throw RuntimeError.Generic("Cannot perform IP resolution via Tart Guest Agent when control socket URL is not set")
|
||||
}
|
||||
|
||||
if let ip = try await AgentResolver.ResolveIP(controlSocketURL) {
|
||||
// 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) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ struct Import: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
let localStorage = VMStorageLocal()
|
||||
let localStorage = try VMStorageLocal()
|
||||
|
||||
// Create a temporary VM directory to which we will load the export file
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
@@ -7,7 +7,7 @@ fileprivate struct VMInfo: Encodable {
|
||||
let Name: String
|
||||
let Disk: Int
|
||||
let Size: Int
|
||||
let SizeOnDisk: Int
|
||||
let Accessed: String
|
||||
let Running: Bool
|
||||
let State: String
|
||||
}
|
||||
@@ -39,13 +39,29 @@ 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(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||
try VMInfo(
|
||||
Source: "local",
|
||||
Name: name,
|
||||
Disk: vmDir.sizeGB(),
|
||||
Size: vmDir.allocatedSizeGB(),
|
||||
Accessed: formatAccessDate(try vmDir.accessDate()),
|
||||
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(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||
try VMInfo(
|
||||
Source: "OCI",
|
||||
Name: name,
|
||||
Disk: vmDir.sizeGB(),
|
||||
Size: vmDir.allocatedSizeGB(),
|
||||
Accessed: formatAccessDate(try vmDir.accessDate()),
|
||||
Running: vmDir.running(),
|
||||
State: vmDir.state().rawValue
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,4 +77,16 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ 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)>) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import Sentry
|
||||
import OpenTelemetryApi
|
||||
import SwiftUI
|
||||
import SwiftDate
|
||||
|
||||
@@ -53,9 +53,9 @@ struct Prune: AsyncParsableCommand {
|
||||
|
||||
switch entries {
|
||||
case "caches":
|
||||
prunableStorages = [VMStorageOCI(), try IPSWCache()]
|
||||
prunableStorages = [try VMStorageOCI(), try IPSWCache()]
|
||||
case "vms":
|
||||
prunableStorages = [VMStorageLocal()]
|
||||
prunableStorages = [try VMStorageLocal()]
|
||||
default:
|
||||
throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"")
|
||||
}
|
||||
@@ -109,9 +109,10 @@ struct Prune: AsyncParsableCommand {
|
||||
return
|
||||
}
|
||||
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
|
||||
}
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
|
||||
key: "prune.required-bytes",
|
||||
value: .int(Int(requiredBytes))
|
||||
)
|
||||
|
||||
// Figure out how much disk space is available
|
||||
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [
|
||||
@@ -123,18 +124,14 @@ struct Prune: AsyncParsableCommand {
|
||||
UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
|
||||
)
|
||||
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: [
|
||||
"volumeAvailableCapacity": attrs.volumeAvailableCapacity!,
|
||||
"volumeAvailableCapacityForImportantUsage": attrs.volumeAvailableCapacityForImportantUsage!,
|
||||
"volumeAvailableCapacityCalculated": volumeAvailableCapacityCalculated
|
||||
], key: "Prune")
|
||||
}
|
||||
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)),
|
||||
])
|
||||
|
||||
if volumeAvailableCapacityCalculated <= 0 {
|
||||
SentrySDK.capture(message: "Zero volume capacity reported") { scope in
|
||||
scope.setLevel(.warning)
|
||||
}
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.addEvent(name: "Zero volume capacity reported")
|
||||
|
||||
return
|
||||
}
|
||||
@@ -149,10 +146,10 @@ struct Prune: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
||||
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
|
||||
defer { transaction.finish() }
|
||||
let span = OTel.shared.tracer.spanBuilder(spanName: "prune").startSpan()
|
||||
defer { span.end() }
|
||||
|
||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||
let prunableStorages: [PrunableStorage] = [try VMStorageOCI(), try IPSWCache()]
|
||||
let prunables: [Prunable] = try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||
@@ -177,13 +174,17 @@ struct Prune: AsyncParsableCommand {
|
||||
continue
|
||||
}
|
||||
|
||||
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path)
|
||||
let allocatedSizeBytes = try prunable.allocatedSizeBytes()
|
||||
|
||||
cacheReclaimedBytes += try prunable.allocatedSizeBytes()
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?
|
||||
.addEvent(name: "Pruned \(allocatedSizeBytes) bytes for \(prunable.url.path)")
|
||||
|
||||
cacheReclaimedBytes += allocatedSizeBytes
|
||||
|
||||
try prunable.delete()
|
||||
}
|
||||
|
||||
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?
|
||||
.addEvent(name: "Reclaimed \(cacheReclaimedBytes) bytes")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 VMStorageLocal().exists(remoteName) {
|
||||
if try VMStorageLocal().exists(remoteName) {
|
||||
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
||||
|
||||
return
|
||||
|
||||
@@ -31,15 +31,12 @@ 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 = VMStorageOCI()
|
||||
let ociStorage = try VMStorageOCI()
|
||||
let localVMDir = try VMStorageHelper.open(localName)
|
||||
let lock = try localVMDir.lock()
|
||||
if try !lock.trylock() {
|
||||
@@ -85,7 +82,6 @@ struct Push: AsyncParsableCommand {
|
||||
registry: registry,
|
||||
references: references,
|
||||
chunkSizeMb: chunkSize,
|
||||
diskFormat: diskFormat,
|
||||
concurrency: concurrency,
|
||||
labels: parseLabels()
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ struct Rename: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
let localStorage = VMStorageLocal()
|
||||
let localStorage = try VMStorageLocal()
|
||||
|
||||
if !localStorage.exists(name) {
|
||||
throw ValidationError("failed to rename a non-existent local VM: \(name)")
|
||||
|
||||
@@ -4,7 +4,7 @@ import Darwin
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
import Sentry
|
||||
import OpenTelemetryApi
|
||||
import System
|
||||
|
||||
var vm: VM?
|
||||
@@ -116,10 +116,6 @@ struct Run: AsyncParsableCommand {
|
||||
#endif
|
||||
var vncExperimental: Bool = false
|
||||
|
||||
// Note that the valueName here should really be "path[:options]" instead of just "path",
|
||||
// see ArgumentParser issue[1] for more details.
|
||||
//
|
||||
// [1]: https://github.com/apple/swift-argument-parser/issues/761
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional disk attachments with an optional read-only and synchronization options in the form of path[:options] (e.g. --disk="disk.bin", --disk="ubuntu.iso:ro", --disk="/dev/disk0", --disk "ghcr.io/cirruslabs/xcode:16.0:ro" or --disk="nbd://localhost:10809/myDisk:sync=none")
|
||||
""", discussion: """
|
||||
@@ -138,12 +134,13 @@ 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 (e.g. by using "sudo chown $USER /dev/diskX") or to run the Tart binary as root, which affects locating Tart VMs.
|
||||
To work with block devices, the easiest way is to modify their permissions to be accessible to the current user:
|
||||
|
||||
To work around this pass TART_HOME explicitly:
|
||||
sudo chown $USER /dev/diskX
|
||||
tart run macos --disk=/dev/diskX
|
||||
|
||||
sudo TART_HOME="$HOME/.tart" tart run sequoia --disk=/dev/disk0
|
||||
""", valueName: "path"), completion: .file())
|
||||
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.
|
||||
""", valueName: "path[:options]"), completion: .file())
|
||||
var disk: [String] = []
|
||||
|
||||
#if arch(arm64)
|
||||
@@ -162,10 +159,6 @@ struct Run: AsyncParsableCommand {
|
||||
#endif
|
||||
var rosettaTag: String?
|
||||
|
||||
// Note that the valueName here should really be "[name:]path[:options]" instead of just "path",
|
||||
// see ArgumentParser issue[1] for more details.
|
||||
//
|
||||
// [1]: https://github.com/apple/swift-argument-parser/issues/761
|
||||
@Option(help: ArgumentHelp("Additional directory shares with an optional read-only and mount tag options in the form of [name:]path[:options] (e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")", discussion: """
|
||||
Requires host to be macOS 13.0 (Ventura) or newer. macOS guests must be running macOS 13.0 (Ventura) or newer too.
|
||||
|
||||
@@ -178,7 +171,7 @@ struct Run: AsyncParsableCommand {
|
||||
Mount tag can be overridden by appending tag property to the directory share (e.g. --dir=\"~/src/build:tag=build\" or --dir=\"~/src/build:ro,tag=build\"). Then it can be mounted via "mount_virtiofs build ~/build" inside guest macOS and "mount -t virtiofs build ~/build" inside guest Linux.
|
||||
|
||||
In case of passing multiple directories per mount tag it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\". These names will be used as directory names under the mounting point inside guests. For the example above it will be "/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively.
|
||||
""", valueName: "path"), completion: .directory)
|
||||
""", valueName: "[name:]path[:options]"), completion: .directory)
|
||||
var dir: [String] = []
|
||||
|
||||
@Flag(help: ArgumentHelp("Enable nested virtualization if possible"))
|
||||
@@ -209,15 +202,28 @@ struct Run: AsyncParsableCommand {
|
||||
"""))
|
||||
var netSoftnet: Bool = false
|
||||
|
||||
@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: """
|
||||
@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: """
|
||||
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 to completely disable the destination based restrictions with --net-softnet-allow=0.0.0.0/0.
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -273,13 +279,41 @@ 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 || netSoftnetExpose != nil {
|
||||
if netSoftnetAllow != nil || netSoftnetBlock != nil || netSoftnetExpose != nil {
|
||||
netSoftnet = true
|
||||
}
|
||||
|
||||
@@ -309,7 +343,7 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
let localStorage = VMStorageLocal()
|
||||
let localStorage = try VMStorageLocal()
|
||||
let vmDir = try localStorage.open(name)
|
||||
if try vmDir.state() == .Suspended {
|
||||
suspendable = true
|
||||
@@ -324,8 +358,15 @@ 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 {
|
||||
@@ -333,6 +374,19 @@ 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.")
|
||||
@@ -341,8 +395,8 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let localStorage = VMStorageLocal()
|
||||
func runOnMainThread() throws {
|
||||
let localStorage = try VMStorageLocal()
|
||||
let vmDir = try localStorage.open(name)
|
||||
|
||||
// Validate disk format support
|
||||
@@ -389,6 +443,11 @@ 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(),
|
||||
@@ -401,7 +460,9 @@ struct Run: AsyncParsableCommand {
|
||||
clipboard: !noClipboard,
|
||||
sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw),
|
||||
caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw),
|
||||
noTrackpad: noTrackpad
|
||||
noTrackpad: noTrackpad,
|
||||
noPointer: noPointer,
|
||||
noKeyboard: noKeyboard
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
@@ -452,7 +513,11 @@ struct Run: AsyncParsableCommand {
|
||||
#endif
|
||||
|
||||
do {
|
||||
try await vm!.start(recovery: recovery, resume: resume)
|
||||
#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
|
||||
} catch let error as VZError {
|
||||
if error.code == .virtualMachineLimitExceeded {
|
||||
var hint = ""
|
||||
@@ -493,7 +558,7 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
if #available(macOS 14, *) {
|
||||
Task {
|
||||
ErrorReportingTask("Failed to run control socket") {
|
||||
try await ControlSocket(vmDir.controlSocketURL).run()
|
||||
}
|
||||
}
|
||||
@@ -504,14 +569,15 @@ struct Run: AsyncParsableCommand {
|
||||
try vncImpl.stop()
|
||||
}
|
||||
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
// Capture the error into Sentry
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
// Capture the error into OpenTelemetry
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error)
|
||||
|
||||
fputs("\(error)\n", stderr)
|
||||
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -545,12 +611,14 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -562,7 +630,7 @@ struct Run: AsyncParsableCommand {
|
||||
signal(SIGUSR2, SIG_IGN)
|
||||
let sigusr2Src = DispatchSource.makeSignalSource(signal: SIGUSR2)
|
||||
sigusr2Src.setEventHandler {
|
||||
Task {
|
||||
ErrorReportingTask("Failed to request guest OS to stop") {
|
||||
print("Requesting guest OS to stop...")
|
||||
try vm!.virtualMachine.requestStop()
|
||||
}
|
||||
@@ -602,6 +670,10 @@ struct Run: AsyncParsableCommand {
|
||||
softnetExtraArguments += ["--allow", netSoftnetAllow]
|
||||
}
|
||||
|
||||
if let netSoftnetBlock = netSoftnetBlock {
|
||||
softnetExtraArguments += ["--block", netSoftnetBlock]
|
||||
}
|
||||
|
||||
if let netSoftnetExpose = netSoftnetExpose {
|
||||
softnetExtraArguments += ["--expose", netSoftnetExpose]
|
||||
}
|
||||
@@ -730,6 +802,11 @@ 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
|
||||
@@ -770,13 +847,13 @@ struct MainApp: App {
|
||||
CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) }
|
||||
CommandMenu("Control") {
|
||||
Button("Start") {
|
||||
Task { try await vm!.virtualMachine.start() }
|
||||
ErrorReportingTask("Failed to start VM") { try await vm!.virtualMachine.start() }
|
||||
}
|
||||
Button("Stop") {
|
||||
Task { try await vm!.virtualMachine.stop() }
|
||||
ErrorReportingTask("Failed to stop VM") { try await vm!.virtualMachine.stop() }
|
||||
}
|
||||
Button("Request Stop") {
|
||||
Task { try vm!.virtualMachine.requestStop() }
|
||||
ErrorReportingTask("Failed to request VM stop") { try vm!.virtualMachine.requestStop() }
|
||||
}
|
||||
if #available(macOS 14, *) {
|
||||
if (MainApp.suspendable) {
|
||||
@@ -791,6 +868,12 @@ 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
|
||||
@@ -992,6 +1075,81 @@ 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
|
||||
|
||||
@@ -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 <width>x<height>. For example, 1200x800")
|
||||
@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.")
|
||||
var display: VMDisplayConfig?
|
||||
|
||||
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window"))
|
||||
@@ -56,6 +56,7 @@ struct Set: AsyncParsableCommand {
|
||||
if (display.height > 0) {
|
||||
vmConfig.display.height = display.height
|
||||
}
|
||||
vmConfig.display.unit = display.unit
|
||||
}
|
||||
|
||||
vmConfig.displayRefit = displayRefit
|
||||
@@ -88,12 +89,24 @@ 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
|
||||
height: parts[safe: 1] ?? 0,
|
||||
unit: unit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ struct Config {
|
||||
var tartHomeDir: URL
|
||||
|
||||
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
|
||||
tartHomeDir = URL(fileURLWithPath: customTartHome)
|
||||
tartHomeDir = URL(fileURLWithPath: customTartHome, isDirectory: true)
|
||||
try Self.validateTartHome(url: tartHomeDir)
|
||||
} else {
|
||||
tartHomeDir = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
@@ -49,4 +50,24 @@ 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,16 @@ 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.path()) { childChannel in
|
||||
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
|
||||
childChannel.eventLoop.makeCompletedFuture {
|
||||
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
|
||||
wrappingChannelSynchronously: childChannel
|
||||
|
||||
@@ -5,6 +5,7 @@ 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,6 +1,8 @@
|
||||
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,6 +1,8 @@
|
||||
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,6 +1,8 @@
|
||||
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,6 +6,8 @@ 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)
|
||||
@@ -13,7 +15,7 @@ class StdinCredentials {
|
||||
return (user, password)
|
||||
}
|
||||
|
||||
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 1024, isSensitive: Bool) throws -> String {
|
||||
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 8192, 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)
|
||||
|
||||
@@ -21,7 +21,7 @@ enum DiskImageFormat: String, CaseIterable, Codable {
|
||||
case .raw:
|
||||
return true
|
||||
case .asif:
|
||||
if #available(macOS 15, *) {
|
||||
if #available(macOS 26, *) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,28 @@ 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) {
|
||||
renderer.appendNewLine(ProgressObserver.lineToRender(progressToObserve))
|
||||
let initialLine = ProgressObserver.lineToRender(progressToObserve)
|
||||
renderer.appendNewLine(initialLine)
|
||||
lastRenderedLine = initialLine
|
||||
observation = observe(\.progressToObserve.fractionCompleted) { progress, _ in
|
||||
let currentTime = Date.now
|
||||
if self.progressToObserve.isFinished || currentTime.timeIntervalSince(self.lastTimeUpdated) >= 1.0 {
|
||||
self.lastTimeUpdated = currentTime
|
||||
renderer.updateLastLine(ProgressObserver.lineToRender(self.progressToObserve))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,28 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import NIOPosix
|
||||
import GRPC
|
||||
import Cirruslabs_TartGuestAgent_Apple_Swift
|
||||
import Cirruslabs_TartGuestAgent_Grpc_Swift
|
||||
|
||||
class AgentResolver {
|
||||
static func ResolveIP(_ controlSocketURL: URL) async throws -> IPv4Address? {
|
||||
static func ResolveIP(_ controlSocketPath: String) async throws -> IPv4Address? {
|
||||
do {
|
||||
return try await resolveIP(controlSocketURL)
|
||||
} catch let error as GRPCConnectionPoolError {
|
||||
return try await resolveIP(controlSocketPath)
|
||||
} catch is GRPCConnectionPoolError {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ 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"
|
||||
|
||||
|
||||
@@ -429,8 +429,12 @@ class Registry {
|
||||
}
|
||||
|
||||
for provider in credentialsProviders {
|
||||
if let (user, password) = try provider.retrieve(host: host) {
|
||||
return (user, password)
|
||||
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)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
let result = VZMacGraphicsDeviceConfiguration()
|
||||
|
||||
if let hostMainScreen = NSScreen.main {
|
||||
if (vmConfig.display.unit ?? .point) == .point, let hostMainScreen = NSScreen.main {
|
||||
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
||||
result.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import ArgumentParser
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Sentry
|
||||
import OpenTelemetryApi
|
||||
import OpenTelemetrySdk
|
||||
import OpenTelemetryProtocolExporterHttp
|
||||
|
||||
@main
|
||||
struct Root: AsyncParsableCommand {
|
||||
@@ -30,101 +32,160 @@ struct Root: AsyncParsableCommand {
|
||||
FQN.self,
|
||||
])
|
||||
|
||||
public static func main() async throws {
|
||||
// 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() {
|
||||
// Add commands that are only available on specific macOS versions
|
||||
if #available(macOS 14, *) {
|
||||
configuration.subcommands.append(Suspend.self)
|
||||
}
|
||||
|
||||
// Ensure the default SIGINT handled is disabled,
|
||||
// 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()
|
||||
// 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)
|
||||
|
||||
// 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 {
|
||||
// Parse command
|
||||
var command = try parseAsRoot()
|
||||
|
||||
// Initialize Sentry
|
||||
if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
|
||||
SentrySDK.start { options in
|
||||
options.dsn = dsn
|
||||
options.releaseName = CI.release
|
||||
options.tracesSampleRate = Float(
|
||||
ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
|
||||
) as NSNumber?
|
||||
|
||||
// By default only 5XX are captured
|
||||
// Let's capture everything but 401 (unauthorized)
|
||||
options.enableCaptureFailedRequests = true
|
||||
options.failedRequestStatusCodes = [
|
||||
HttpStatusCodeRange(min: 400, max: 400),
|
||||
HttpStatusCodeRange(min: 402, max: 599)
|
||||
]
|
||||
}
|
||||
}
|
||||
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
||||
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setExtra(value: ProcessInfo.processInfo.arguments, key: "Command-line arguments")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Run command
|
||||
if var asyncCommand = command as? AsyncParsableCommand {
|
||||
try await asyncCommand.run()
|
||||
} else {
|
||||
try command.run()
|
||||
}
|
||||
command = try parseAsRoot()
|
||||
} catch {
|
||||
// Not an error, just a custom exit code from "tart exec"
|
||||
if let execCustomExitCodeError = error as? ExecCustomExitCodeError {
|
||||
Foundation.exit(execCustomExitCodeError.exitCode)
|
||||
}
|
||||
|
||||
// Capture the error into Sentry
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
|
||||
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
|
||||
if let errorWithExitCode = error as? HasExitCode {
|
||||
fputs("\(error)\n", stderr)
|
||||
|
||||
Foundation.exit(errorWithExitCode.exitCode)
|
||||
}
|
||||
|
||||
// Handle any other exception, including ArgumentParser's ones
|
||||
exit(withError: error)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
|
||||
// Handle cancellation by Ctrl+C ourselves
|
||||
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||
sigintSrc.setEventHandler {
|
||||
task.cancel()
|
||||
}
|
||||
sigintSrc.activate()
|
||||
|
||||
dispatchMain()
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseCirrusSentryTag(_ tag: String.SubSequence) -> (String, String)? {
|
||||
@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 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
return span
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)? {
|
||||
let splits = tag.split(separator: "=", maxSplits: 1)
|
||||
if splits.count != 2 {
|
||||
return nil
|
||||
@@ -133,3 +194,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ fileprivate func normalizeName(_ name: String) -> String {
|
||||
return name.replacingOccurrences(of: ":", with: "\\:")
|
||||
}
|
||||
|
||||
func completeMachines(_ arguments: [String]) -> [String] {
|
||||
func completeMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
|
||||
let localVMs = (try? VMStorageLocal().list().map { name, _ in
|
||||
normalizeName(name)
|
||||
}) ?? []
|
||||
@@ -15,12 +15,12 @@ func completeMachines(_ arguments: [String]) -> [String] {
|
||||
return (localVMs + ociVMs)
|
||||
}
|
||||
|
||||
func completeLocalMachines(_ arguments: [String]) -> [String] {
|
||||
func completeLocalMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
|
||||
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||
return localVMs.map { name, _ in normalizeName(name) }
|
||||
}
|
||||
|
||||
func completeRunningMachines(_ arguments: [String]) -> [String] {
|
||||
func completeRunningMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
|
||||
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||
return localVMs
|
||||
.filter { _, vmDir in (try? vmDir.state() == .Running) ?? false}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
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
|
||||
|
||||
@@ -51,7 +51,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
clipboard: Bool = true,
|
||||
sync: VZDiskImageSynchronizationMode = .full,
|
||||
caching: VZDiskImageCachingMode? = nil,
|
||||
noTrackpad: Bool = false
|
||||
noTrackpad: Bool = false,
|
||||
noPointer: Bool = false,
|
||||
noKeyboard: Bool = false
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
@@ -73,7 +75,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
clipboard: clipboard,
|
||||
sync: sync,
|
||||
caching: caching,
|
||||
noTrackpad: noTrackpad
|
||||
noTrackpad: noTrackpad,
|
||||
noPointer: noPointer,
|
||||
noKeyboard: noKeyboard
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -240,13 +244,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
return try VM(vmDir: vmDir)
|
||||
}
|
||||
|
||||
func start(recovery: Bool, resume shouldResume: Bool) async throws {
|
||||
func start(recovery: Bool, resume shouldResume: Bool, provisioning: GuestProvisioningOptions? = nil) async throws {
|
||||
try network.run(sema)
|
||||
|
||||
if shouldResume {
|
||||
try await resume()
|
||||
} else {
|
||||
try await start(recovery)
|
||||
try await start(recovery, provisioning: provisioning)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,10 +286,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func start(_ recovery: Bool) async throws {
|
||||
private func start(_ recovery: Bool, provisioning: GuestProvisioningOptions? = nil) 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()
|
||||
@@ -316,7 +325,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
clipboard: Bool = true,
|
||||
sync: VZDiskImageSynchronizationMode = .full,
|
||||
caching: VZDiskImageCachingMode? = nil,
|
||||
noTrackpad: Bool = false
|
||||
noTrackpad: Bool = false,
|
||||
noPointer: Bool = false,
|
||||
noKeyboard: Bool = false
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -356,8 +367,16 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
|
||||
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
|
||||
} else {
|
||||
configuration.keyboards = vmConfig.platform.keyboards()
|
||||
if noTrackpad {
|
||||
|
||||
if noKeyboard {
|
||||
configuration.keyboards = []
|
||||
} else {
|
||||
configuration.keyboards = vmConfig.platform.keyboards()
|
||||
}
|
||||
|
||||
if noPointer {
|
||||
configuration.pointingDevices = []
|
||||
} else if noTrackpad {
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevicesSimplified()
|
||||
} else {
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
@@ -385,7 +404,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
// Storage
|
||||
var attachment = try VZDiskImageStorageDeviceAttachment(
|
||||
let attachment = try VZDiskImageStorageDeviceAttachment(
|
||||
url: diskURL,
|
||||
readOnly: false,
|
||||
// When not specified, use "cached" caching mode for Linux VMs to prevent file-system corruption[1]
|
||||
|
||||
@@ -32,14 +32,24 @@ enum CodingKeys: String, CodingKey {
|
||||
case hardwareModel
|
||||
}
|
||||
|
||||
struct VMDisplayConfig: Codable {
|
||||
struct VMDisplayConfig: Codable, Equatable {
|
||||
enum Unit: String, Codable {
|
||||
case point = "pt"
|
||||
case pixel = "px"
|
||||
}
|
||||
|
||||
var width: Int = 1024
|
||||
var height: Int = 768
|
||||
var unit: Unit?
|
||||
}
|
||||
|
||||
extension VMDisplayConfig: CustomStringConvertible {
|
||||
var description: String {
|
||||
"\(width)x\(height)"
|
||||
if let unit {
|
||||
"\(width)x\(height)\(unit.rawValue)"
|
||||
} else {
|
||||
"\(width)x\(height)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Compression
|
||||
import Foundation
|
||||
import Sentry
|
||||
import OpenTelemetryApi
|
||||
|
||||
let legacyDiskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
||||
|
||||
enum OCIError: Error {
|
||||
case ShouldBeExactlyOneLayer
|
||||
@@ -29,21 +31,20 @@ extension VMDirectory {
|
||||
try configFile.close()
|
||||
|
||||
// Pull VM's disk layers and decompress them into a disk file
|
||||
let diskImplType: Disk.Type
|
||||
let layers: [OCIManifestLayer]
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
let layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
|
||||
if layers.isEmpty {
|
||||
throw OCIError.ShouldBeAtLeastOneLayer
|
||||
}
|
||||
|
||||
let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +)
|
||||
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte)
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
|
||||
key: "compressed_disk_size_bytes",
|
||||
value: .int(Int(diskCompressedSize))
|
||||
)
|
||||
|
||||
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
||||
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
||||
@@ -52,10 +53,10 @@ extension VMDirectory {
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
do {
|
||||
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
|
||||
concurrency: concurrency, progress: progress,
|
||||
localLayerCache: localLayerCache,
|
||||
deduplicate: deduplicate)
|
||||
try await DiskV2.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)")
|
||||
}
|
||||
@@ -87,7 +88,7 @@ extension VMDirectory {
|
||||
try manifest.toJSON().write(to: manifestURL)
|
||||
}
|
||||
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String, concurrency: UInt, labels: [String: String] = [:]) async throws -> RemoteName {
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, concurrency: UInt, labels: [String: String] = [:]) async throws -> RemoteName {
|
||||
var layers = Array<OCIManifestLayer>()
|
||||
|
||||
// Read VM's config and push it as blob
|
||||
@@ -108,14 +109,7 @@ extension VMDirectory {
|
||||
let progress = Progress(totalUnitCount: diskSize)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
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)
|
||||
}
|
||||
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
|
||||
|
||||
// Read VM's NVRAM and push it as blob
|
||||
defaultLogger.appendNewLine("pushing NVRAM...")
|
||||
|
||||
@@ -2,25 +2,6 @@ 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"
|
||||
@@ -46,7 +27,7 @@ struct VMDirectory: Prunable {
|
||||
baseURL.appendingPathComponent("manifest.json")
|
||||
}
|
||||
var controlSocketURL: URL {
|
||||
baseURL.appendingPathComponent("control.sock")
|
||||
URL(fileURLWithPath: "control.sock", relativeTo: baseURL)
|
||||
}
|
||||
|
||||
var explicitlyPulledMark: URL {
|
||||
@@ -201,69 +182,28 @@ 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 {
|
||||
try infoProcess.run()
|
||||
infoProcess.waitUntilExit()
|
||||
let diskImageInfo = try Diskutil.imageInfo(diskURL)
|
||||
|
||||
let infoData = infoPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let currentSizeBytes = try diskImageInfo.totalBytes()
|
||||
let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||
|
||||
if infoProcess.terminationStatus != 0 {
|
||||
let output = String(data: infoData, encoding: .utf8) ?? "Unknown error"
|
||||
throw RuntimeError.FailedToResizeDisk("Failed to get ASIF disk info: \(output)")
|
||||
}
|
||||
if desiredSizeBytes < currentSizeBytes {
|
||||
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentSizeBytes))
|
||||
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredSizeBytes))
|
||||
|
||||
// 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)
|
||||
}
|
||||
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 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("Failed to get disk image info: \(error)")
|
||||
throw RuntimeError.FailedToResizeDisk("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +244,7 @@ struct VMDirectory: Prunable {
|
||||
case .raw:
|
||||
try createRawDisk(sizeGB: sizeGB)
|
||||
case .asif:
|
||||
try createASIFDisk(sizeGB: sizeGB)
|
||||
try Diskutil.imageCreate(diskURL: diskURL, sizeGB: sizeGB)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,38 +258,6 @@ 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()
|
||||
@@ -391,6 +299,21 @@ 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)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ 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)
|
||||
@@ -139,8 +138,6 @@ 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):
|
||||
@@ -169,14 +166,3 @@ 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,7 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
class VMStorageLocal: PrunableStorage {
|
||||
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||
let baseURL: URL
|
||||
|
||||
init() throws {
|
||||
baseURL = try Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||
}
|
||||
|
||||
private func vmURL(_ name: String) -> URL {
|
||||
baseURL.appendingPathComponent(name, isDirectory: true)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import Foundation
|
||||
import Sentry
|
||||
import OpenTelemetryApi
|
||||
import Retry
|
||||
|
||||
class VMStorageOCI: PrunableStorage {
|
||||
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
let baseURL: URL
|
||||
|
||||
init() throws {
|
||||
baseURL = try Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
}
|
||||
|
||||
private func vmURL(_ name: RemoteName) -> URL {
|
||||
baseURL.appendingRemoteName(name)
|
||||
@@ -27,12 +31,12 @@ class VMStorageOCI: PrunableStorage {
|
||||
return digest
|
||||
}
|
||||
|
||||
func open(_ name: RemoteName) throws -> VMDirectory {
|
||||
func open(_ name: RemoteName, _ accessDate: Date = Date()) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
|
||||
try vmDir.validate(userFriendlyName: name.description)
|
||||
|
||||
try vmDir.baseURL.updateAccessDate()
|
||||
try vmDir.baseURL.updateAccessDate(accessDate)
|
||||
|
||||
return vmDir
|
||||
}
|
||||
@@ -141,9 +145,10 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["imageName": name.description], key: "OCI")
|
||||
}
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
|
||||
key: "oci.image-name",
|
||||
value: .string(name.description)
|
||||
)
|
||||
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
@@ -177,18 +182,25 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
if !exists(digestName) {
|
||||
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
||||
let span = OTel.shared.tracer.spanBuilder(spanName: "pull").setActive(true).startSpan()
|
||||
defer { span.end() }
|
||||
|
||||
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
|
||||
|
||||
// Open an existing VM directory corresponding to this name, if any,
|
||||
// marking it as outdated to speed up the garbage collection process
|
||||
_ = try? open(name, Date(timeIntervalSince1970: 0))
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
// Try to reclaim some cache space if we know the VM size in advance
|
||||
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["imageUncompressedDiskSize": uncompressedDiskSize], key: "OCI")
|
||||
}
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
|
||||
key: "oci.image-uncompressed-disk-size-bytes",
|
||||
value: .int(Int(uncompressedDiskSize))
|
||||
)
|
||||
|
||||
let otherVMFilesSize: UInt64 = 128 * 1024 * 1024
|
||||
|
||||
@@ -221,9 +233,7 @@ 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 {
|
||||
|
||||
@@ -2,13 +2,9 @@ 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 15+
|
||||
if #available(macOS 15, *) {
|
||||
// ASIF should be supported on macOS 26+
|
||||
if #available(macOS 26, *) {
|
||||
XCTAssertTrue(DiskImageFormat.asif.isSupported)
|
||||
} else {
|
||||
XCTAssertFalse(DiskImageFormat.asif.isSupported)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ final class LayerizerTests: XCTestCase {
|
||||
do {
|
||||
registryRunner = try await RegistryRunner()
|
||||
} catch {
|
||||
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
||||
throw XCTSkip("Registry is unavailable: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,30 +24,9 @@ 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: 5 * 1024 * 1024 * 1024)
|
||||
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 1 * 1024 * 1024 * 1024)
|
||||
addTeardownBlock {
|
||||
try FileManager.default.removeItem(at: originalDiskFileURL)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ final class RegistryTests: XCTestCase {
|
||||
do {
|
||||
registryRunner = try await RegistryRunner()
|
||||
} catch {
|
||||
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
||||
throw XCTSkip("Registry is unavailable: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class RegistryRunner {
|
||||
let stdoutPipe = Pipe()
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
|
||||
proc.executableURL = URL(fileURLWithPath: "/opt/homebrew/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:0:5000", "registry:2")
|
||||
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "127.0.0.1::5000", "registry:2")
|
||||
.trimmingCharacters(in: CharacterSet.newlines)
|
||||
containerID = container
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/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
|
||||
@@ -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-sequoia-base:latest --prepare 'sudo purge && sync'
|
||||
go run cmd/main.go fio --image ghcr.io/cirruslabs/macos-tahoe-base:latest --prepare 'sudo purge && sync'
|
||||
```
|
||||
|
||||
You can also enable the debugging output to diagnose issues:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module github.com/cirruslabs/tart/benchmark
|
||||
|
||||
go 1.22.1
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
require (
|
||||
|
||||
@@ -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-sequoia-xcode:latest", "image to use for testing")
|
||||
cmd.Flags().StringVar(&image, "image", "ghcr.io/cirruslabs/macos-tahoe-xcode:latest", "image to use for testing")
|
||||
cmd.Flags().StringVar(&prepare, "prepare", "", "command to run before running each benchmark")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
"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
|
||||
|
||||
|
Before Width: | Height: | Size: 2.8 MiB |
@@ -11,6 +11,12 @@ 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.
|
||||
@@ -20,7 +26,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/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
|
||||
<img src="https://github.com/openai/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.**
|
||||
@@ -60,10 +66,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](/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.**
|
||||
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.**
|
||||
|
||||
## Have we considered alternatives?
|
||||
|
||||
@@ -74,6 +80,5 @@ 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.
|
||||
|
||||
@@ -78,7 +78,7 @@ in “worker” mode on macOS hosts. Orchard controller is using extremely fast
|
||||
|
||||
## Conclusion
|
||||
|
||||
Please give [Orchard](https://github.com/cirruslabs/orchard) a try! To run it locally in development mode on any Apple Silicon device
|
||||
Please give [Orchard](https://github.com/openai/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/cirruslabs/orchard#creating-virtual-machines)
|
||||
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)
|
||||
on how to create your first virtual machine and access it.
|
||||
|
||||
In a [separate blog post](/blog/2023/04/28/ssh-over-grpc-or-how-orchard-simplifies-accessing-vms-in-private-networks/)
|
||||
In a [separate blog post](2023-04-28-orchard-ssh-over-grpc.md)
|
||||
we’ll cover how Orchard implements seamless SSH access over a gRPC connection. Stay tuned and please don’t hesitate to
|
||||
[reach out](https://github.com/cirruslabs/orchard/discussions/landing)!
|
||||
[open an issue](https://github.com/openai/orchard/issues)!
|
||||
|
||||
@@ -11,7 +11,7 @@ categories:
|
||||
|
||||
# SSH over gRPC or how Orchard simplifies accessing VMs in private networks
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||

|
||||

|
||||
|
||||
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/cirruslabs/orchard) a try by running it locally in development mode on any Apple Silicon device:
|
||||
You can give [Orchard](https://github.com/openai/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/cirruslabs/orchard#creating-virtual-machines)
|
||||
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)
|
||||
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/cirruslabs/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/openai/orchard) or [Twitter](https://twitter.com/cirrus_labs)!
|
||||
|
||||
@@ -19,31 +19,27 @@ 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/cirruslabs/tart/blob/main/Resources/Users/HowToAddYourself.md)!
|
||||
[joining](https://github.com/openai/tart/blob/main/Resources/Users/HowToAddYourself.md)!
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { 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/).
|
||||
-[Custom GitLab Runner Executor](../../integrations/gitlab-runner.md).
|
||||
-[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)).
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
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!
|
||||
|
||||

|
||||
|
||||
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**.
|
||||
|
||||

|
||||

|
||||
|
||||
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**.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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/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.
|
||||
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.
|
||||
|
||||
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,8 +55,6 @@ 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/cirruslabs/orchard) and [Twitter](https://x.com/cirrus_labs)!
|
||||
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)!
|
||||
|
||||
@@ -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/cirruslabs/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).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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/cirruslabs/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/openai/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,14 +59,10 @@ 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/cirruslabs/tart) and [Twitter](https://x.com/cirrus_labs)!
|
||||
Stay tuned, and feel free to send us feedback on [GitHub](https://github.com/openai/tart) and [Twitter](https://x.com/cirrus_labs)!
|
||||
|
||||
@@ -5,6 +5,42 @@ 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:
|
||||
@@ -23,7 +59,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). 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) 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`.
|
||||
|
||||
## Connecting to a service running on host
|
||||
|
||||
@@ -34,13 +70,28 @@ 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 | grep default | head -n 1 | awk '{print $2}'
|
||||
netstat -nr | awk '/default/{print $2; exit}'
|
||||
```
|
||||
|
||||
Note: that accessing host is only possible with the default NAT network. If you are running your virtual machines with
|
||||
[Softnet](https://github.com/cirruslabs/softnet) (via `tart run --net-softnet <VM NAME>)`, then the network isolation
|
||||
[Softnet](https://github.com/openai/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`:
|
||||
@@ -61,7 +112,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](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):
|
||||
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):
|
||||
|
||||
```shell
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.InternetSharing.default.plist bootpd -dict DHCPLeaseTimeSecs -int 600
|
||||
@@ -143,17 +194,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
|
||||
security unlock-keychain login.keychain
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## How Tart is different from Anka?
|
||||
## How is Tart different from Anka?
|
||||
|
||||
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
|
||||
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
|
||||
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/openai/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.
|
||||
@@ -166,6 +217,8 @@ 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
|
||||
|
||||
@@ -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):
|
||||
|
||||

|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -18,9 +18,9 @@ steps:
|
||||
- command: uname -a
|
||||
plugins:
|
||||
- cirruslabs/tart#main:
|
||||
image: ghcr.io/cirruslabs/macos-sequoia-base:latest
|
||||
image: 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`.
|
||||
This will run `uname -r` in a macOS Tart VM cloned from `ghcr.io/cirruslabs/macos-tahoe-base:latest`.
|
||||
|
||||
See plugin's [Configuration section](https://github.com/cirruslabs/tart-buildkite-plugin#configuration) for the full list of available options.
|
||||
|
||||
@@ -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 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
|
||||
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
|
||||
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-sequoia-base:latest
|
||||
image: ghcr.io/cirruslabs/macos-tahoe-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
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
|
||||
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
|
||||
|
||||
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
||||
**Note:** Cirrus CI only allows [images managed and regularly updated by Cirrus Labs](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-sequoia-xcode:latest
|
||||
image: ghcr.io/cirruslabs/macos-tahoe-xcode:latest
|
||||
build_script: swift build --product tart
|
||||
binary_artifacts:
|
||||
path: .build/debug/tart
|
||||
|
||||
@@ -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-sequoia-base:latest
|
||||
image: ghcr.io/cirruslabs/macos-tahoe-base:latest
|
||||
|
||||
test:
|
||||
tags:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
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).
|
||||
@@ -1,143 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,113 +0,0 @@
|
||||
---
|
||||
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.
|
||||