Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
160b7cd692 | ||
|
|
cbc160a592 | ||
|
|
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 | ||
|
|
3a6c5fb81d | ||
|
|
5793935317 | ||
|
|
8dc8b644b2 | ||
|
|
b625c04131 | ||
|
|
a0c03dcce6 | ||
|
|
8539b8faae | ||
|
|
71159373e5 | ||
|
|
8248f19943 | ||
|
|
1cbc1e2cda | ||
|
|
0187834c34 | ||
|
|
dfbdb5559c | ||
|
|
40ab5c3af4 | ||
|
|
280a31f707 | ||
|
|
8d49404337 | ||
|
|
64a3999a58 | ||
|
|
5c1f5a61c1 | ||
|
|
1310220f05 | ||
|
|
1fe2f1ff88 | ||
|
|
df3de33f1a | ||
|
|
1560e4d312 | ||
|
|
318202fa81 | ||
|
|
cb92a3fa67 | ||
|
|
9c30638079 | ||
|
|
a4edc6af50 | ||
|
|
2890dda847 | ||
|
|
2d55f3b9fa | ||
|
|
d3104c71b9 | ||
|
|
3ddad55372 | ||
|
|
72a81ca84a | ||
|
|
d8945503d6 | ||
|
|
a0fd5435de | ||
|
|
4cf68fc061 | ||
|
|
b626ed415b | ||
|
|
dd7bace92d | ||
|
|
94376ca355 | ||
|
|
60a481857f | ||
|
|
876271dceb | ||
|
|
6dd43abf03 | ||
|
|
04c6df2efb | ||
|
|
5a8b48a392 | ||
|
|
b96ea087f5 | ||
|
|
eaec015edf | ||
|
|
e27da23f4c | ||
|
|
e6a30b07e3 | ||
|
|
2d7615bdf8 | ||
|
|
31ab4218f7 | ||
|
|
32ebc5bdbc | ||
|
|
c825ba4cb1 | ||
|
|
2db3918930 | ||
|
|
4256330f39 | ||
|
|
0794edf15a | ||
|
|
8536c16bcc | ||
|
|
589d489782 | ||
|
|
b1e88e1e51 | ||
|
|
b4de3bee83 | ||
|
|
cd0f238a67 | ||
|
|
02f94720c5 | ||
|
|
c0443060cf | ||
|
|
9c879b3f55 | ||
|
|
f7b38769a9 | ||
|
|
7c1ed4640f |
@@ -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 Sonoma
|
||||
name: Test
|
||||
alias: test
|
||||
persistent_worker:
|
||||
labels:
|
||||
@@ -11,17 +11,19 @@ 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
|
||||
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
|
||||
# Run integration tests
|
||||
- cd integration-tests
|
||||
- HOMEBREW_NO_AUTO_UPDATE=1 brew install virtualenv
|
||||
- virtualenv venv
|
||||
- python3 -m venv --symlinks venv
|
||||
- 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
|
||||
@@ -38,7 +40,7 @@ task:
|
||||
name: Lint
|
||||
alias: lint
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||
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:
|
||||
@@ -55,105 +57,21 @@ task:
|
||||
name: Build ($BUILD_ARCH)
|
||||
alias: build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||
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:sonoma
|
||||
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 goreleaser/tap/goreleaser-pro
|
||||
- brew install mitchellh/gon/gon
|
||||
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:sonoma
|
||||
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 goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
|
||||
- brew install mitchellh/gon/gon
|
||||
info_script:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
- swift -version
|
||||
release_script: goreleaser
|
||||
upload_sentry_debug_files_script:
|
||||
- cd .build/arm64-apple-macosx/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!]
|
||||
deploy_script:
|
||||
deploy_script:
|
||||
- git config --global user.name "Cirrus CI"
|
||||
- git config --global user.name "hello@cirruslabs.org"
|
||||
- git remote set-url origin https://$DEPLOY_TOKEN@github.com/cirruslabs/tart/
|
||||
|
||||
@@ -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: xcode-27
|
||||
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: xcode-27
|
||||
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: xcode-27
|
||||
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: xcode-27
|
||||
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: xcode-27
|
||||
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/*
|
||||
@@ -8,6 +8,9 @@ tart.xcodeproj/
|
||||
# AppCode
|
||||
.idea/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Swift
|
||||
.build/
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
version: 2
|
||||
|
||||
project_name: tart
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- .ci/set-version.sh
|
||||
- swift build --arch x86_64 -c release --product tart
|
||||
- swift build --arch arm64 -c release --product tart
|
||||
- gon gon.hcl
|
||||
- sh .ci/build-release.sh arm64
|
||||
- sh .ci/build-release.sh x86_64
|
||||
|
||||
builds:
|
||||
- id: tart
|
||||
@@ -18,20 +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:
|
||||
- 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 }}-{{ .Arch }}"
|
||||
- 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:
|
||||
@@ -39,19 +53,34 @@ release:
|
||||
|
||||
brews:
|
||||
- name: tart
|
||||
directory: Formula
|
||||
repository:
|
||||
owner: cirruslabs
|
||||
name: homebrew-cli
|
||||
caveats: See the GitHub repository for more information
|
||||
homepage: https://github.com/cirruslabs/tart
|
||||
license: "Fair Source"
|
||||
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:
|
||||
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.InternetSharing.default.plist bootpd -dict DHCPLeaseTimeSecs -int 600
|
||||
|
||||
See https://tart.run/faq/#changing-the-default-dhcp-lease-time for more details.
|
||||
homepage: https://github.com/openai/tart
|
||||
license: FSL-1.1-ALv2
|
||||
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" : "6a15657d8cb1d3e2b447f31aff5b47d6a9655d2262e48ca76476ba525435269b",
|
||||
"originHash" : "061dfe6cdf4e6dbf32b51c5e7023c4ae69726dcafb42a35b34e5489b0338c17f",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "antlr4",
|
||||
@@ -10,6 +10,24 @@
|
||||
"version" : "4.13.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "cirruslabs_tart-guest-agent_apple_swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://buf.build/gen/swift/git/1.33.3-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_apple_swift.git",
|
||||
"state" : {
|
||||
"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.27.1-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_grpc_swift.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "4935078c2fe2508360843596d71a1f844ce639a6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dynamic",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -19,6 +37,42 @@
|
||||
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "grpc-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/grpc/grpc-swift.git",
|
||||
"state" : {
|
||||
"revision" : "8f57f68b9d247fe3759fa9f18e1fe919911e6031",
|
||||
"version" : "1.27.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "opentelemetry-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/open-telemetry/opentelemetry-swift",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "ed37be9525081509ab62410d38b705c2b3f0d5a4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "opentelemetry-swift-core",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/open-telemetry/opentelemetry-swift-core.git",
|
||||
"state" : {
|
||||
"revision" : "240c8d5e36c3c7b774ed961325369f0b1f2c965f",
|
||||
"version" : "2.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "opentracing-objc",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/undefinedlabs/opentracing-objc",
|
||||
"state" : {
|
||||
"revision" : "18c1a35ca966236cee0c5a714a51a73ff33384c1",
|
||||
"version" : "0.5.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "semaphore",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -28,15 +82,6 @@
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sentry-cocoa",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "5575af93efb776414f243e93d6af9f6258dc539a",
|
||||
"version" : "8.36.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -51,17 +96,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
|
||||
"version" : "1.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-async-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "5c8bd186f48c16af0775972700626f0b74588278"
|
||||
"revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3",
|
||||
"version" : "1.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -69,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"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -78,8 +114,26 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "9bf03ff58ce34478e66aaee630e491823326fd06",
|
||||
"version" : "1.1.3"
|
||||
"revision" : "671108c96644956dddcd89dd59c203dcdb36cec7",
|
||||
"version" : "1.1.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-http-structured-headers",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-http-structured-headers.git",
|
||||
"state" : {
|
||||
"revision" : "db6eea3692638a65e2124990155cd220c2915903",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-http-types",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-http-types.git",
|
||||
"state" : {
|
||||
"revision" : "a0a57e949a8903563aba4615869310c0ebf14c03",
|
||||
"version" : "1.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -87,8 +141,62 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"state" : {
|
||||
"revision" : "233f61bc2cfbb22d0edeb2594da27a20d2ce514e",
|
||||
"version" : "2.93.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-extras.git",
|
||||
"state" : {
|
||||
"revision" : "f1f6f772198bee35d99dd145f1513d8581a54f2c",
|
||||
"version" : "1.26.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-http2",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-http2.git",
|
||||
"state" : {
|
||||
"revision" : "4281466512f63d1bd530e33f4aa6993ee7864be0",
|
||||
"version" : "1.36.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-ssl",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-ssl.git",
|
||||
"state" : {
|
||||
"revision" : "4b38f35946d00d8f6176fe58f96d83aba64b36c7",
|
||||
"version" : "2.31.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-transport-services",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-transport-services.git",
|
||||
"state" : {
|
||||
"revision" : "cd1e89816d345d2523b11c55654570acd5cd4c56",
|
||||
"version" : "1.24.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -100,6 +208,15 @@
|
||||
"version" : "1.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-protobuf",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||
"state" : {
|
||||
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
|
||||
"version" : "1.33.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-retry",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -118,6 +235,15 @@
|
||||
"version" : "1.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1",
|
||||
"version" : "1.4.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-xattr",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -162,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,38 +10,45 @@ 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/apple/swift-async-algorithms", branch: "main"),
|
||||
.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.36.0"),
|
||||
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"),
|
||||
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"),
|
||||
.package(url: "https://github.com/groue/Semaphore", from: "0.0.8"),
|
||||
.package(url: "https://github.com/fumoboy007/swift-retry", from: "0.2.3"),
|
||||
.package(url: "https://github.com/jozefizso/swift-xattr", from: "3.0.0"),
|
||||
.package(url: "https://github.com/grpc/grpc-swift.git", .upToNextMajor(from: "1.27.0")),
|
||||
.package(url: "https://buf.build/gen/swift/git/1.27.1-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_grpc_swift.git", branch: "main"),
|
||||
.package(url: "https://github.com/open-telemetry/opentelemetry-swift", branch: "main"),
|
||||
.package(url: "https://github.com/open-telemetry/opentelemetry-swift-core", from: "2.3.0"),
|
||||
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
.product(name: "Algorithms", package: "swift-algorithms"),
|
||||
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "Dynamic", package: "Dynamic"),
|
||||
.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"),
|
||||
.product(name: "Semaphore", package: "Semaphore"),
|
||||
.product(name: "DMRetry", package: "swift-retry"),
|
||||
.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-sonoma-base:latest sonoma-base
|
||||
tart run sonoma-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,19 +7,23 @@
|
||||
<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>LSBackgroundOnly</key>
|
||||
<string>1</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>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Access to OCI registries on the local network</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
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
|
||||
@@ -71,10 +74,16 @@ struct Clone: AsyncParsableCommand {
|
||||
|
||||
try lock.unlock()
|
||||
|
||||
// APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
|
||||
// APFS is doing copy-on-write, so the above cloning operation (just copying files on disk)
|
||||
// is not actually claiming new space until the VM is started and it writes something to disk.
|
||||
// So once we clone the VM let's try to claim a little bit of space for the VM to run.
|
||||
try Prune.reclaimIfNeeded(UInt64(sourceVM.allocatedSizeBytes()), sourceVM)
|
||||
//
|
||||
// So, once we clone the VM let's try to claim the rest of space for the VM to run without errors.
|
||||
let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
|
||||
// Avoid reclaiming an excessive amount of disk space.
|
||||
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
|
||||
if reclaimBytes > 0 {
|
||||
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM)
|
||||
}
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ struct Create: AsyncParsableCommand {
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file or URL (or \"latest\", to fetch the latest supported IPSW automatically)", valueName: "path"))
|
||||
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file or URL (or \"latest\", to fetch the latest supported IPSW automatically)", valueName: "path"), completion: .file())
|
||||
var fromIPSW: String?
|
||||
|
||||
@Flag(help: "create a Linux VM")
|
||||
@@ -19,6 +19,9 @@ struct Create: AsyncParsableCommand {
|
||||
@Option(help: ArgumentHelp("Disk size in GB"))
|
||||
var diskSize: UInt16 = 50
|
||||
|
||||
@Option(help: ArgumentHelp("Disk image format", discussion: "ASIF format provides better performance but requires macOS 26 Tahoe or later"))
|
||||
var diskFormat: DiskImageFormat = .raw
|
||||
|
||||
func validate() throws {
|
||||
if fromIPSW == nil && !linux {
|
||||
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
||||
@@ -28,6 +31,11 @@ struct Create: AsyncParsableCommand {
|
||||
throw ValidationError("Only Linux VMs are supported on Intel!")
|
||||
}
|
||||
#endif
|
||||
|
||||
// Validate disk format support
|
||||
if !diskFormat.isSupported {
|
||||
throw ValidationError("Disk format '\(diskFormat.rawValue)' is not supported on this system.")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
@@ -58,12 +66,12 @@ struct Create: AsyncParsableCommand {
|
||||
ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath)
|
||||
}
|
||||
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize, diskFormat: diskFormat)
|
||||
}
|
||||
#endif
|
||||
|
||||
if linux {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize, diskFormat: diskFormat)
|
||||
}
|
||||
|
||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import GRPC
|
||||
import Cirruslabs_TartGuestAgent_Grpc_Swift
|
||||
|
||||
struct ExecCustomExitCodeError: Error {
|
||||
let exitCode: Int32
|
||||
}
|
||||
|
||||
struct Exec: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Execute a command in a running VM", discussion: """
|
||||
Requires Tart Guest Agent running in a guest VM.
|
||||
|
||||
Note that all non-vanilla Cirrus Labs VM images already have the Tart Guest Agent installed.
|
||||
""")
|
||||
|
||||
@Flag(name: [.customShort("i")], help: "Attach host's standard input to a remote command")
|
||||
var interactive: Bool = false
|
||||
|
||||
@Flag(name: [.customShort("t")], help: "Allocate a remote pseudo-terminal (PTY)")
|
||||
var tty: Bool = false
|
||||
|
||||
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
@Argument(parsing: .captureForPassthrough, help: "Command to execute")
|
||||
var command: [String]
|
||||
|
||||
func run() async throws {
|
||||
// We only have withThrowingDiscardingTaskGroup available starting from macOS 14
|
||||
if #unavailable(macOS 14) {
|
||||
throw RuntimeError.Generic("\"tart exec\" is only available on macOS 14 (Sonoma) or newer")
|
||||
}
|
||||
|
||||
// Open VM's directory
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
|
||||
// Ensure that the VM is running
|
||||
if try !vmDir.running() {
|
||||
throw RuntimeError.VMNotRunning(name)
|
||||
}
|
||||
|
||||
// Change the current working directory to a VM's base directory
|
||||
// to work around Unix domain socket 104 byte limitation [1]
|
||||
//
|
||||
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
|
||||
if let baseURL = vmDir.controlSocketURL.baseURL {
|
||||
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
|
||||
}
|
||||
|
||||
// Switch controlling terminal into raw mode when remote pseudo-terminal is requested
|
||||
var state: State? = nil
|
||||
|
||||
if tty && Term.IsTerminal() {
|
||||
state = try Term.MakeRaw()
|
||||
}
|
||||
defer {
|
||||
// Restore terminal to its initial state
|
||||
if let state {
|
||||
try! Term.Restore(state)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute a command in a running VM
|
||||
do {
|
||||
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?")
|
||||
}
|
||||
}
|
||||
|
||||
private func execute(_ channel: GRPCChannel) async throws {
|
||||
let agentAsyncClient = AgentAsyncClient(channel: channel)
|
||||
let execCall = agentAsyncClient.makeExecCall()
|
||||
|
||||
try await execCall.requestStream.send(.with {
|
||||
$0.type = .command(.with {
|
||||
$0.name = command[0]
|
||||
$0.args = Array(command.dropFirst(1))
|
||||
$0.interactive = interactive
|
||||
$0.tty = tty
|
||||
if tty {
|
||||
$0.terminalSize = .with {
|
||||
let (width, height) = try! Term.GetSize()
|
||||
|
||||
$0.cols = UInt32(width)
|
||||
$0.rows = UInt32(height)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Process command events and optionally send our standard input and/or terminal dimensions
|
||||
try await withThrowingTaskGroup { group in
|
||||
// Stream host's standard input if interactive mode is enabled
|
||||
if interactive {
|
||||
let stdinStream = AsyncThrowingStream<Data, Error> { continuation in
|
||||
let handle = FileHandle.standardInput
|
||||
|
||||
if isRegularFile(handle.fileDescriptor) {
|
||||
// Standard input can be a regular file when input redirection (<) is used,
|
||||
// in which case the handle won't receive any new readability events, so we
|
||||
// just read the file normally here in chunks and consider done with it
|
||||
//
|
||||
// Ideally this is best handled by using non-blocking I/O, but Swift's
|
||||
// standard library only offers inefficient bytes[1] property and SwiftNIO's
|
||||
// NIOFileSystem doesn't seem to support opening raw file descriptors.
|
||||
//
|
||||
// [1]: https://developer.apple.com/documentation/foundation/filehandle/bytes
|
||||
while true {
|
||||
do {
|
||||
let data = try handle.read(upToCount: 64 * 1024)
|
||||
if let data = data {
|
||||
continuation.yield(data)
|
||||
} else {
|
||||
continuation.finish()
|
||||
break
|
||||
}
|
||||
} catch (let error) {
|
||||
continuation.finish(throwing: error)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
handle.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
|
||||
if data.isEmpty {
|
||||
// EOF: unregister the handler, otherwise the fd stays permanently
|
||||
// "readable" and Foundation re-invokes us in a tight loop, burning
|
||||
// 100% of a core for the rest of the command's lifetime
|
||||
handle.readabilityHandler = nil
|
||||
|
||||
continuation.finish()
|
||||
} else {
|
||||
continuation.yield(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.addTask {
|
||||
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()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Stream host's terminal dimensions if pseudo-terminal is requested
|
||||
signal(SIGWINCH, SIG_IGN)
|
||||
let sigwinchSrc = DispatchSource.makeSignalSource(signal: SIGWINCH)
|
||||
sigwinchSrc.activate()
|
||||
|
||||
if tty {
|
||||
let terminalDimensionsStream = AsyncStream { continuation in
|
||||
sigwinchSrc.setEventHandler {
|
||||
continuation.yield(try! Term.GetSize())
|
||||
}
|
||||
}
|
||||
|
||||
group.addTask {
|
||||
for await (width, height) in terminalDimensionsStream {
|
||||
try await execCall.requestStream.send(.with {
|
||||
$0.type = .terminalResize(.with {
|
||||
$0.cols = UInt32(width)
|
||||
$0.rows = UInt32(height)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process command events
|
||||
group.addTask {
|
||||
for try await response in execCall.responseStream {
|
||||
switch response.type {
|
||||
case .standardOutput(let ioChunk):
|
||||
try FileHandle.standardOutput.write(contentsOf: ioChunk.data)
|
||||
case .standardError(let ioChunk):
|
||||
try FileHandle.standardError.write(contentsOf: ioChunk.data)
|
||||
case .exit(let exit):
|
||||
throw ExecCustomExitCodeError(exitCode: exit.code)
|
||||
default:
|
||||
// Unknown event, do nothing
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while !group.isEmpty {
|
||||
do {
|
||||
try await group.next()
|
||||
} catch {
|
||||
group.cancelAll()
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isRegularFile(_ fileDescriptor: Int32) -> Bool {
|
||||
var stat = stat()
|
||||
|
||||
if fstat(fileDescriptor, &stat) != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return (stat.st_mode & S_IFMT) == S_IFREG
|
||||
}
|
||||
@@ -7,7 +7,7 @@ struct Export: AsyncParsableCommand {
|
||||
@Argument(help: "Source VM name.", completion: .custom(completeMachines))
|
||||
var name: String
|
||||
|
||||
@Argument(help: "Path to the destination file.")
|
||||
@Argument(help: "Path to the destination file.", completion: .file())
|
||||
var path: String?
|
||||
|
||||
func run() async throws {
|
||||
|
||||
@@ -6,6 +6,7 @@ fileprivate struct VMInfo: Encodable {
|
||||
let CPU: Int
|
||||
let Memory: UInt64
|
||||
let Disk: Int
|
||||
let DiskFormat: String
|
||||
let Size: String
|
||||
let Display: String
|
||||
let Running: Bool
|
||||
@@ -26,7 +27,7 @@ struct Get: AsyncParsableCommand {
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||
|
||||
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
|
||||
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
|
||||
print(format.renderSingle(info))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,11 @@ import ArgumentParser
|
||||
import Foundation
|
||||
import Network
|
||||
import SystemConfiguration
|
||||
import Sentry
|
||||
|
||||
enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
||||
case dhcp, arp
|
||||
case dhcp, arp, agent
|
||||
|
||||
private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"}
|
||||
private(set) static var allValueStrings: [String] = Self.allCases.map { "\($0)"}
|
||||
}
|
||||
|
||||
struct IP: AsyncParsableCommand {
|
||||
@@ -19,13 +18,11 @@ struct IP: AsyncParsableCommand {
|
||||
@Option(help: "Number of seconds to wait for a potential VM booting")
|
||||
var wait: UInt16 = 0
|
||||
|
||||
@Option(help: ArgumentHelp("Strategy for resolving IP address: dhcp or arp",
|
||||
@Option(help: ArgumentHelp("Strategy for resolving IP address",
|
||||
discussion: """
|
||||
By default, Tart is looking up and parsing DHCP lease file to determine the IP of the VM.\n
|
||||
This method is fast and the most reliable but only returns local IP adresses.\n
|
||||
Alternatively, Tart can call external `arp` executable and parse it's output.\n
|
||||
In case of enabled Bridged Networking this method will return VM's IP address on the network interface used for Bridged Networking.\n
|
||||
Note that `arp` strategy won't work for VMs using `--net-softnet`.
|
||||
By default, Tart is using a "dhcp" resolver which parses the DHCP lease file on host and tries to find an entry containing the VM's MAC address. This method is fast and the most reliable, but only works for VMs are not using the bridged networking.\n
|
||||
Alternatively, Tart has an "arp" resolver which calls an external "arp" executable and parses it's output. This works for VMs using bridged networking and returns their IP, but when they generate enough network activity to populate the host's ARP table. Note that "arp" strategy won't work for VMs using the Softnet networking.\n
|
||||
A third strategy, "agent" works in all cases reliably, but requires Guest agent for Tart VMs (https://github.com/cirruslabs/tart-guest-agent) to be installed inside of a VM.
|
||||
"""))
|
||||
var resolver: IPResolutionStrategy = .dhcp
|
||||
|
||||
@@ -34,14 +31,16 @@ struct IP: AsyncParsableCommand {
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
|
||||
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait) else {
|
||||
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait, controlSocketURL: vmDir.controlSocketURL) else {
|
||||
var message = "no IP address found"
|
||||
|
||||
if try !vmDir.running() {
|
||||
message += ", is your VM running?"
|
||||
}
|
||||
|
||||
if (vmConfig.os == .linux && resolver == .arp) {
|
||||
if (resolver == .agent) {
|
||||
message += " (also make sure that Guest agent for Tart is running inside of a VM)"
|
||||
} else if (vmConfig.os == .linux && resolver == .arp) {
|
||||
message += " (not all Linux distributions are compatible with the ARP resolver)"
|
||||
}
|
||||
|
||||
@@ -51,7 +50,7 @@ struct IP: AsyncParsableCommand {
|
||||
print(ip)
|
||||
}
|
||||
|
||||
static public func resolveIP(_ vmMACAddress: MACAddress, resolutionStrategy: IPResolutionStrategy = .dhcp, secondsToWait: UInt16 = 0) async throws -> IPv4Address? {
|
||||
static public func resolveIP(_ vmMACAddress: MACAddress, resolutionStrategy: IPResolutionStrategy = .dhcp, secondsToWait: UInt16 = 0, controlSocketURL: URL? = nil) async throws -> IPv4Address? {
|
||||
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
||||
|
||||
repeat {
|
||||
@@ -64,6 +63,22 @@ struct IP: AsyncParsableCommand {
|
||||
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
}
|
||||
case .agent:
|
||||
guard let controlSocketURL = controlSocketURL else {
|
||||
throw RuntimeError.Generic("Cannot perform IP resolution via Tart Guest Agent when control socket URL is not set")
|
||||
}
|
||||
|
||||
// Change the current working directory to a VM's base directory
|
||||
// to work around Unix domain socket 104 byte limitation [1]
|
||||
//
|
||||
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
|
||||
if let baseURL = controlSocketURL.baseURL {
|
||||
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
|
||||
}
|
||||
|
||||
if let ip = try await AgentResolver.ResolveIP(controlSocketURL.relativePath) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
// wait a second
|
||||
|
||||
@@ -4,10 +4,10 @@ import Foundation
|
||||
struct Import: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Import VM from a compressed .tvm file")
|
||||
|
||||
@Argument(help: "Path to a file created with \"tart export\".")
|
||||
@Argument(help: "Path to a file created with \"tart export\".", completion: .file())
|
||||
var path: String
|
||||
|
||||
@Argument(help: "Destination VM name.")
|
||||
@Argument(help: "Destination VM name.", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
func validate() throws {
|
||||
@@ -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
|
||||
}
|
||||
@@ -18,7 +18,7 @@ struct List: AsyncParsableCommand {
|
||||
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
|
||||
var source: String?
|
||||
|
||||
@Option(help: "Output format: text or json")
|
||||
@Option(help: "Output format: text or json", completion: .list(["text", "json"]))
|
||||
var format: Format = .text
|
||||
|
||||
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
|
||||
@@ -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,13 +1,13 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import Sentry
|
||||
import OpenTelemetryApi
|
||||
import SwiftUI
|
||||
import SwiftDate
|
||||
|
||||
struct Prune: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs")
|
||||
|
||||
@Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs."))
|
||||
@Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs."), completion: .list(["caches", "vms"]))
|
||||
var entries: String = "caches"
|
||||
|
||||
@Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago",
|
||||
@@ -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
|
||||
@@ -47,8 +47,5 @@ struct Pull: AsyncParsableCommand {
|
||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
|
||||
|
||||
// to explicitly set the image as being accessed so it won't get pruned immediately
|
||||
_ = try VMStorageOCI().open(remoteName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,15 +26,17 @@ struct Push: AsyncParsableCommand {
|
||||
"""))
|
||||
var chunkSize: Int = 0
|
||||
|
||||
@Option(help: .hidden)
|
||||
var diskFormat: String = "v2"
|
||||
|
||||
@Option(name: [.customLong("label")], help: ArgumentHelp("additional metadata to attach to the OCI image configuration in key=value format",
|
||||
discussion: "Can be specified multiple times to attach multiple labels."))
|
||||
var labels: [String] = []
|
||||
|
||||
@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() {
|
||||
@@ -80,8 +82,8 @@ struct Push: AsyncParsableCommand {
|
||||
registry: registry,
|
||||
references: references,
|
||||
chunkSizeMb: chunkSize,
|
||||
diskFormat: diskFormat,
|
||||
concurrency: concurrency
|
||||
concurrency: concurrency,
|
||||
labels: parseLabels()
|
||||
)
|
||||
// Populate the local cache (if requested)
|
||||
if populateCache {
|
||||
@@ -115,6 +117,28 @@ struct Push: AsyncParsableCommand {
|
||||
return RemoteName(host: registry.host!, namespace: registry.namespace,
|
||||
reference: Reference(digest: digest))
|
||||
}
|
||||
|
||||
// Helper method to convert labels array to dictionary
|
||||
func parseLabels() -> [String: String] {
|
||||
var result = [String: String]()
|
||||
|
||||
for label in labels {
|
||||
let parts = label.trimmingCharacters(in: .whitespaces).split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
|
||||
let key = parts.count > 0 ? String(parts[0]) : ""
|
||||
let value = parts.count > 1 ? String(parts[1]) : ""
|
||||
|
||||
// It sometimes makes sense to provide an empty value,
|
||||
// but not an empty key
|
||||
if key.isEmpty {
|
||||
continue
|
||||
}
|
||||
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection where Element == RemoteName {
|
||||
|
||||
@@ -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?
|
||||
@@ -45,6 +45,23 @@ extension VZDiskImageSynchronizationMode {
|
||||
}
|
||||
}
|
||||
|
||||
extension VZDiskImageCachingMode {
|
||||
public init?(_ description: String) throws {
|
||||
switch description {
|
||||
case "automatic":
|
||||
self = .automatic
|
||||
case "cached":
|
||||
self = .cached
|
||||
case "uncached":
|
||||
self = .uncached
|
||||
case "":
|
||||
return nil
|
||||
default:
|
||||
throw RuntimeError.VMConfigurationError("unsupported disk image caching mode: \"\(description)\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Run: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Run a VM")
|
||||
|
||||
@@ -64,7 +81,7 @@ struct Run: AsyncParsableCommand {
|
||||
@Option(help: ArgumentHelp(
|
||||
"Attach an externally created serial console",
|
||||
discussion: "Alternative to `--serial` flag for programmatic integrations."
|
||||
))
|
||||
), completion: .file())
|
||||
var serialPath: String?
|
||||
|
||||
@Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .private))
|
||||
@@ -75,7 +92,7 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Disable clipboard sharing between host and guest.",
|
||||
discussion: "Only works with Linux-based guest operating systems."))
|
||||
discussion: "Clipboard sharing requires spice-vdagent package on Linux and https://github.com/cirruslabs/tart-guest-agent on macOS."))
|
||||
var noClipboard: Bool = false
|
||||
|
||||
#if arch(arm64)
|
||||
@@ -100,7 +117,7 @@ struct Run: AsyncParsableCommand {
|
||||
var vncExperimental: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional disk attachments with an optional read-only and synchronization 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")
|
||||
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: """
|
||||
The disk attachment can be a:
|
||||
|
||||
@@ -117,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 sonoma --disk=/dev/disk0
|
||||
""", valueName: "path[:options]"))
|
||||
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)
|
||||
@@ -141,7 +159,7 @@ struct Run: AsyncParsableCommand {
|
||||
#endif
|
||||
var rosettaTag: String?
|
||||
|
||||
@Option(help: ArgumentHelp("Additional directory shares with an optional read-only and mount tag options (e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")", discussion: """
|
||||
@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.
|
||||
|
||||
Options are comma-separated and are as follows:
|
||||
@@ -153,9 +171,12 @@ 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: "[name:]path[:options]"))
|
||||
""", valueName: "[name:]path[:options]"), completion: .directory)
|
||||
var dir: [String] = []
|
||||
|
||||
@Flag(help: ArgumentHelp("Enable nested virtualization if possible"))
|
||||
var nested: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
|
||||
""", discussion: """
|
||||
@@ -163,17 +184,73 @@ struct Run: AsyncParsableCommand {
|
||||
""", valueName: "interface name"))
|
||||
var netBridged: [String] = []
|
||||
|
||||
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
|
||||
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
|
||||
@Flag(help: ArgumentHelp("Use software networking provided by Softnet instead of the default shared (NAT) networking",
|
||||
discussion: """
|
||||
Softnet provides better network isolation and alleviates DHCP shortage on production systems. Tart invokes Softnet when this option is specified as a sub-process and communicates with it over socketpair(2).
|
||||
|
||||
It is essentially a userspace packet filter which restricts the VM networking and prevents a class of security issues, such as ARP spoofing. By default, the VM will only be able to:
|
||||
|
||||
* send traffic from its own MAC-address
|
||||
* send traffic from the IP-address assigned to it by the DHCP
|
||||
* send traffic to globally routable IPv4 addresses
|
||||
* send traffic to gateway IP of the vmnet bridge (this would normally be \"bridge100\" interface)
|
||||
* receive any incoming traffic
|
||||
|
||||
In addition, Softnet tunes macOS built-in DHCP server to decrease its lease time from the default 86,400 seconds (one day) to 600 seconds (10 minutes). This is especially important when you use Tart to clone and run a lot of ephemeral VMs over a period of one day.
|
||||
|
||||
More on Softnet here: https://github.com/cirruslabs/softnet
|
||||
"""))
|
||||
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)", valueName: "comma-separated CIDRs"))
|
||||
@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 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("Connected Unix stream socket file descriptor to use for the Softnet control channel (e.g. --net-softnet-control-fd=3)", discussion: """
|
||||
This option enables the Softnet control channel on an inherited Unix stream socket. It can be used to dynamically replace Softnet allow and block lists while the VM is running.
|
||||
|
||||
The file descriptor must be greater than 2. Implies --net-softnet.
|
||||
""", valueName: "file descriptor"))
|
||||
var netSoftnetControlFd: Int32?
|
||||
|
||||
@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:
|
||||
|
||||
* EXTERNAL_PORT:INTERNAL_PORT — forward TCP traffic from the EXTERNAL_PORT on a host's egress interface (automatically detected and could be Wi-Fi, Ethernet and a VPN interface) to the INTERNAL_PORT on guest's IP (as reported by "tart ip")
|
||||
|
||||
Note that for the port forwarding to work correctly:
|
||||
|
||||
* the software in guest listening on INTERNAL_PORT should either listen on 0.0.0.0 or on an IP address assigned to that guest
|
||||
* connection to the EXTERNAL_PORT should be performed from the local network that the host is attached to or from the internet, it's not possible to connect to that forwarded port from the host itself
|
||||
|
||||
Another thing to keep in mind is that regular Softnet restrictions will still apply even to port forwarding. So if you're planning to access your VM from local network, and your local network is 192.168.0.0/24, for example, then add --net-softnet-allow=192.168.0.0/24. If you only need port forwarding, to completely disable Softnet restrictions you can use --net-softnet-allow=0.0.0.0/0.
|
||||
|
||||
Implies --net-softnet.
|
||||
""", valueName: "comma-separated port specifications"))
|
||||
var netSoftnetExpose: String?
|
||||
|
||||
@Flag(help: ArgumentHelp("Restrict network access to the host-only network"))
|
||||
var netHost: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Set the root disk options (e.g. --root-disk-opts=\"ro\" or --root-disk-opts=\"sync=none\")",
|
||||
@Option(help: ArgumentHelp("Set the root disk options (e.g. --root-disk-opts=\"ro\" or --root-disk-opts=\"caching=cached,sync=none\")",
|
||||
discussion: """
|
||||
Options are comma-separated and are as follows:
|
||||
|
||||
@@ -184,6 +261,12 @@ struct Run: AsyncParsableCommand {
|
||||
* sync=fsync — enable data synchronization with the permanent storage, but don't ensure that it was actually written (e.g. --root-disk-opts="sync=fsync")
|
||||
|
||||
* sync=full — enable data synchronization with the permanent storage and ensure that it was actually written (e.g. --root-disk-opts="sync=full")
|
||||
|
||||
* caching=automatic — allows the virtualization framework to automatically determine whether to enable data caching
|
||||
|
||||
* caching=cached — enabled data caching
|
||||
|
||||
* caching=uncached — disables data caching
|
||||
""", valueName: "options"))
|
||||
var rootDiskOpts: String = ""
|
||||
|
||||
@@ -198,12 +281,50 @@ struct Run: AsyncParsableCommand {
|
||||
#endif
|
||||
var captureSystemKeys: Bool = false
|
||||
|
||||
#if arch(arm64)
|
||||
@Flag(help: ArgumentHelp("Don't add trackpad as a pointing device on macOS VMs"))
|
||||
#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")
|
||||
}
|
||||
|
||||
// check that not more than one network option is specified
|
||||
// Automatically enable --net-softnet when any of its related options are specified
|
||||
if netSoftnetAllow != nil || netSoftnetBlock != nil || netSoftnetExpose != nil || netSoftnetControlFd != nil {
|
||||
netSoftnet = true
|
||||
}
|
||||
|
||||
// Check that no more than one network option is specified
|
||||
var netFlags = 0
|
||||
if netBridged.count > 0 { netFlags += 1 }
|
||||
if netSoftnet { netFlags += 1 }
|
||||
@@ -221,7 +342,15 @@ struct Run: AsyncParsableCommand {
|
||||
throw ValidationError("--captures-system-keys can only be used with the default VM view")
|
||||
}
|
||||
|
||||
let localStorage = VMStorageLocal()
|
||||
if nested {
|
||||
if #unavailable(macOS 15) {
|
||||
throw ValidationError("Nested virtualization is supported on hosts starting with macOS 15 (Sequoia), and later.")
|
||||
} else if !VZGenericPlatformConfiguration.isNestedVirtualizationSupported {
|
||||
throw ValidationError("Nested virtualization is available for Mac with the M3 chip, and later.")
|
||||
}
|
||||
}
|
||||
|
||||
let localStorage = try VMStorageLocal()
|
||||
let vmDir = try localStorage.open(name)
|
||||
if try vmDir.state() == .Suspended {
|
||||
suspendable = true
|
||||
@@ -232,11 +361,39 @@ struct Run: AsyncParsableCommand {
|
||||
if !(config.platform is PlatformSuspendable) {
|
||||
throw ValidationError("You can only suspend macOS VMs")
|
||||
}
|
||||
if dir.count > 0 {
|
||||
throw ValidationError("Suspending VMs with shared directories is not supported")
|
||||
|
||||
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 {
|
||||
throw ValidationError("--no-trackpad can only be used with macOS VMs")
|
||||
}
|
||||
}
|
||||
|
||||
#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.")
|
||||
@@ -245,10 +402,16 @@ 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
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
if !vmConfig.diskFormat.isSupported {
|
||||
throw ValidationError("Disk format '\(vmConfig.diskFormat.rawValue)' is not supported on this system.")
|
||||
}
|
||||
|
||||
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
try storageLock.lock()
|
||||
// check if there is a running VM with the same MAC address
|
||||
@@ -287,6 +450,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(),
|
||||
@@ -294,9 +462,14 @@ struct Run: AsyncParsableCommand {
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable,
|
||||
nested: nested,
|
||||
audio: !noAudio,
|
||||
clipboard: !noClipboard,
|
||||
sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw)
|
||||
sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw),
|
||||
caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw),
|
||||
noTrackpad: noTrackpad,
|
||||
noPointer: noPointer,
|
||||
noKeyboard: noKeyboard
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
@@ -347,7 +520,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 = ""
|
||||
@@ -387,20 +564,27 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
if #available(macOS 14, *) {
|
||||
ErrorReportingTask("Failed to run control socket") {
|
||||
try await ControlSocket(vmDir.controlSocketURL).run()
|
||||
}
|
||||
}
|
||||
|
||||
try await vm!.run()
|
||||
|
||||
if let vncImpl = vncImpl {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -434,12 +618,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)
|
||||
}
|
||||
}
|
||||
@@ -451,7 +637,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()
|
||||
}
|
||||
@@ -460,8 +646,10 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||
if noGraphics || useVNCWithoutGraphics {
|
||||
// enter the main even loop, without bringing up any UI,
|
||||
// and just wait for the VM to exit.
|
||||
// Enter the main event loop without bringing up any UI,
|
||||
// waiting for the VM to exit.
|
||||
NSApplication.shared.setActivationPolicy(.prohibited)
|
||||
|
||||
NSApplication.shared.run()
|
||||
} else {
|
||||
runUI(suspendable, captureSystemKeys)
|
||||
@@ -489,16 +677,24 @@ struct Run: AsyncParsableCommand {
|
||||
softnetExtraArguments += ["--allow", netSoftnetAllow]
|
||||
}
|
||||
|
||||
if let netSoftnetBlock = netSoftnetBlock {
|
||||
softnetExtraArguments += ["--block", netSoftnetBlock]
|
||||
}
|
||||
|
||||
if let netSoftnetExpose = netSoftnetExpose {
|
||||
softnetExtraArguments += ["--expose", netSoftnetExpose]
|
||||
}
|
||||
|
||||
if netSoftnet {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: softnetExtraArguments)
|
||||
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: softnetExtraArguments, controlFD: netSoftnetControlFd)
|
||||
}
|
||||
|
||||
if netHost {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"] + softnetExtraArguments)
|
||||
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"] + softnetExtraArguments, controlFD: netSoftnetControlFd)
|
||||
}
|
||||
|
||||
if netBridged.count > 0 {
|
||||
@@ -613,11 +809,16 @@ 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
|
||||
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
@@ -653,13 +854,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) {
|
||||
@@ -673,13 +874,8 @@ struct MainApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
// The only way to fully remove Edit menu item.
|
||||
class MinimalMenuAppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||
let indexOfEditMenu = 2
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||
func applicationDidFinishLaunching(_ : Notification) {
|
||||
NSApplication.shared.mainMenu?.removeItem(at: indexOfEditMenu)
|
||||
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
@@ -737,12 +933,12 @@ struct VMView: NSViewRepresentable {
|
||||
|
||||
machineView.capturesSystemKeys = capturesSystemKeys
|
||||
|
||||
// Enable automatic display reconfiguration
|
||||
// for guests that support it
|
||||
// If not specified, enable automatic display
|
||||
// reconfiguration for guests that support it
|
||||
//
|
||||
// This is disabled for Linux because of poor HiDPI
|
||||
// support, which manifests in fonts being too small
|
||||
if #available(macOS 14.0, *), vm.config.os != .linux {
|
||||
if #available(macOS 14.0, *), vm.config.displayRefit ?? (vm.config.os != .linux) {
|
||||
machineView.automaticallyReconfiguresDisplay = true
|
||||
}
|
||||
|
||||
@@ -758,12 +954,12 @@ struct AdditionalDisk {
|
||||
let configuration: VZStorageDeviceConfiguration
|
||||
|
||||
init(parseFrom: String) throws {
|
||||
let (diskPath, readOnly, syncModeRaw) = Self.parseOptions(parseFrom)
|
||||
let (diskPath, readOnly, syncModeRaw, cachingModeRaw) = Self.parseOptions(parseFrom)
|
||||
|
||||
self.configuration = try Self.craft(diskPath, readOnly: readOnly, syncModeRaw: syncModeRaw)
|
||||
self.configuration = try Self.craft(diskPath, readOnly: readOnly, syncModeRaw: syncModeRaw, cachingModeRaw: cachingModeRaw)
|
||||
}
|
||||
|
||||
static func craft(_ diskPath: String, readOnly diskReadOnly: Bool, syncModeRaw: String) throws -> VZStorageDeviceConfiguration {
|
||||
static func craft(_ diskPath: String, readOnly diskReadOnly: Bool, syncModeRaw: String, cachingModeRaw: String) throws -> VZStorageDeviceConfiguration {
|
||||
let diskURL = URL(string: diskPath)
|
||||
|
||||
if (["nbd", "nbds", "nbd+unix", "nbds+unix"].contains(diskURL?.scheme)) {
|
||||
@@ -840,14 +1036,14 @@ struct AdditionalDisk {
|
||||
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(
|
||||
url: diskFileURL,
|
||||
readOnly: diskReadOnly,
|
||||
cachingMode: .automatic,
|
||||
cachingMode: try VZDiskImageCachingMode(cachingModeRaw) ?? .automatic,
|
||||
synchronizationMode: try VZDiskImageSynchronizationMode(syncModeRaw)
|
||||
)
|
||||
|
||||
return VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment)
|
||||
}
|
||||
|
||||
static func parseOptions(_ parseFrom: String) -> (String, Bool, String) {
|
||||
static func parseOptions(_ parseFrom: String) -> (String, Bool, String, String) {
|
||||
var arguments = parseFrom.split(separator: ":")
|
||||
|
||||
let options = DiskOptions(String(arguments.last!))
|
||||
@@ -855,13 +1051,14 @@ struct AdditionalDisk {
|
||||
arguments.removeLast()
|
||||
}
|
||||
|
||||
return (arguments.joined(separator: ":"), options.readOnly, options.syncModeRaw)
|
||||
return (arguments.joined(separator: ":"), options.readOnly, options.syncModeRaw, options.cachingModeRaw)
|
||||
}
|
||||
}
|
||||
|
||||
struct DiskOptions {
|
||||
var readOnly: Bool = false
|
||||
var syncModeRaw: String = ""
|
||||
var cachingModeRaw: String = ""
|
||||
var foundAtLeastOneOption: Bool = false
|
||||
|
||||
init(_ parseFrom: String) {
|
||||
@@ -875,6 +1072,9 @@ struct DiskOptions {
|
||||
case option.hasPrefix("sync="):
|
||||
self.syncModeRaw = String(option.dropFirst("sync=".count))
|
||||
self.foundAtLeastOneOption = true
|
||||
case option.hasPrefix("caching="):
|
||||
self.cachingModeRaw = String(option.dropFirst("caching=".count))
|
||||
self.foundAtLeastOneOption = true
|
||||
default:
|
||||
continue
|
||||
}
|
||||
@@ -882,6 +1082,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
|
||||
@@ -998,7 +1273,7 @@ struct DirectoryShare {
|
||||
process.standardInput = inPipe
|
||||
process.launch()
|
||||
|
||||
inPipe.fileHandleForWriting.write(response!.data)
|
||||
try inPipe.fileHandleForWriting.write(contentsOf: response!.data)
|
||||
try inPipe.fileHandleForWriting.close()
|
||||
process.waitUntilExit()
|
||||
|
||||
|
||||
@@ -14,9 +14,12 @@ 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"))
|
||||
var displayRefit: Bool? = nil
|
||||
|
||||
@Flag(help: ArgumentHelp("Generate a new random MAC address for the VM."))
|
||||
var randomMAC: Bool = false
|
||||
|
||||
@@ -30,15 +33,7 @@ struct Set: AsyncParsableCommand {
|
||||
|
||||
@Option(help: ArgumentHelp("Resize the VMs disk to the specified size in GB (note that the disk size can only be increased to avoid losing data)",
|
||||
discussion: """
|
||||
Disk resizing works on most cloud-ready Linux distributions out-of-the box (e.g. Ubuntu Cloud Images
|
||||
have the \"cloud-initramfs-growroot\" package installed that runs on boot) and on the rest of the
|
||||
distributions by running the \"growpart\" or \"resize2fs\" commands.
|
||||
|
||||
For macOS, however, things are a bit more complicated: you need to remove the recovery partition
|
||||
first and then run various \"diskutil\" commands, see Tart's packer plugin source code for more
|
||||
details[1].
|
||||
|
||||
[1]: https://github.com/cirruslabs/packer-plugin-tart/blob/main/builder/tart/step_disk_resize.go
|
||||
See https://tart.run/faq/#disk-resizing for more details.
|
||||
"""))
|
||||
var diskSize: UInt16?
|
||||
|
||||
@@ -61,15 +56,17 @@ struct Set: AsyncParsableCommand {
|
||||
if (display.height > 0) {
|
||||
vmConfig.display.height = display.height
|
||||
}
|
||||
vmConfig.display.unit = display.unit
|
||||
}
|
||||
|
||||
vmConfig.displayRefit = displayRefit
|
||||
|
||||
if randomMAC {
|
||||
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||
}
|
||||
|
||||
#if arch(arm64)
|
||||
if randomSerial {
|
||||
let oldPlatform = vmConfig.platform as! Darwin
|
||||
if randomSerial, let oldPlatform = vmConfig.platform as? Darwin {
|
||||
vmConfig.platform = Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: oldPlatform.hardwareModel)
|
||||
}
|
||||
#endif
|
||||
@@ -92,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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import os.log
|
||||
import NIO
|
||||
import NIOPosix
|
||||
|
||||
@available(macOS 14, *)
|
||||
class ControlSocket {
|
||||
let controlSocketURL: URL
|
||||
let vmPort: UInt32
|
||||
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
let logger: os.Logger = os.Logger(subsystem: "org.cirruslabs.tart.control-socket", category: "network")
|
||||
|
||||
init(_ controlSocketURL: URL, vmPort: UInt32 = 8080) {
|
||||
self.controlSocketURL = controlSocketURL
|
||||
self.vmPort = vmPort
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
// Remove control socket file from previous "tart run" invocations,
|
||||
// if any, otherwise we may get the "address already in use" error
|
||||
try? FileManager.default.removeItem(atPath: controlSocketURL.path())
|
||||
|
||||
// Change the current working directory to a VM's base directory
|
||||
// to work around Unix domain socket 104 byte limitation [1]
|
||||
//
|
||||
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
|
||||
if let baseURL = controlSocketURL.baseURL {
|
||||
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
|
||||
}
|
||||
|
||||
let serverChannel = try await ServerBootstrap(group: eventLoopGroup)
|
||||
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
|
||||
childChannel.eventLoop.makeCompletedFuture {
|
||||
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
|
||||
wrappingChannelSynchronously: childChannel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try await withThrowingDiscardingTaskGroup { group in
|
||||
try await serverChannel.executeThenClose { serverInbound in
|
||||
for try await clientChannel in serverInbound {
|
||||
group.addTask {
|
||||
try await self.handleClient(clientChannel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleClient(_ clientChannel: NIOAsyncChannel<ByteBuffer, ByteBuffer>) async throws {
|
||||
self.logger.info("received new control socket connection from a client")
|
||||
|
||||
try await clientChannel.executeThenClose { clientInbound, clientOutbound in
|
||||
self.logger.info("dialing to VM on port \(self.vmPort)...")
|
||||
|
||||
do {
|
||||
guard let vmConnection = try await vm?.connect(toPort: self.vmPort) else {
|
||||
throw RuntimeError.VMSocketFailed(self.vmPort, "VM is not running")
|
||||
}
|
||||
|
||||
self.logger.info("running control socket proxy")
|
||||
|
||||
let vmChannel = try await ClientBootstrap(group: eventLoopGroup).withConnectedSocket(vmConnection.fileDescriptor) { childChannel in
|
||||
childChannel.eventLoop.makeCompletedFuture {
|
||||
try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
|
||||
wrappingChannelSynchronously: childChannel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try await vmChannel.executeThenClose { (vmInbound, vmOutbound) in
|
||||
try await withThrowingDiscardingTaskGroup { group in
|
||||
// Proxy data from a client (e.g. "tart exec") to a VM
|
||||
group.addTask {
|
||||
for try await message in clientInbound {
|
||||
try await vmOutbound.write(message)
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy data from a VM to a client (e.g. "tart exec")
|
||||
group.addTask {
|
||||
for try await message in vmInbound {
|
||||
try await clientOutbound.write(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.logger.info("control socket client disconnected")
|
||||
} catch (let error) {
|
||||
self.logger.error("control socket connection failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -36,7 +38,11 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
||||
|
||||
process.launch()
|
||||
|
||||
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
||||
do {
|
||||
try inPipe.fileHandleForWriting.write(contentsOf: "\(host)\n".data(using: .utf8)!)
|
||||
} catch {
|
||||
throw CredentialsProviderError.Failed(message: "Failed to write host to Docker helper!")
|
||||
}
|
||||
inPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
let outputData = try outPipe.fileHandleForReading.readToEnd()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
|
||||
enum DiskImageFormat: String, CaseIterable, Codable {
|
||||
case raw = "raw"
|
||||
case asif = "asif"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .raw:
|
||||
return "RAW"
|
||||
case .asif:
|
||||
return "ASIF (Apple Sparse Image Format)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Check if the format is supported on the current system
|
||||
var isSupported: Bool {
|
||||
switch self {
|
||||
case .raw:
|
||||
return true
|
||||
case .asif:
|
||||
if #available(macOS 26, *) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
extension DiskImageFormat: ExpressibleByArgument {
|
||||
init?(argument: String) {
|
||||
self.init(rawValue: argument.lowercased())
|
||||
}
|
||||
|
||||
static var allValueStrings: [String] {
|
||||
return allCases.map { $0.rawValue }
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,6 @@
|
||||
import Foundation
|
||||
import AsyncAlgorithms
|
||||
|
||||
fileprivate let urlSession = createURLSession()
|
||||
|
||||
class DownloadDelegate: NSObject, URLSessionTaskDelegate {
|
||||
let progress: Progress
|
||||
init(_ progress: Progress) throws {
|
||||
self.progress = progress
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) {
|
||||
self.progress.addChild(task.progress, withPendingUnitCount: self.progress.totalUnitCount)
|
||||
}
|
||||
}
|
||||
|
||||
class Fetcher {
|
||||
static func fetch(_ request: URLRequest, viaFile: Bool = false, progress: Progress? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
let delegate = progress != nil ? try DownloadDelegate(progress!) : nil
|
||||
|
||||
if viaFile {
|
||||
return try await fetchViaFile(request, delegate: delegate)
|
||||
}
|
||||
|
||||
return try await fetchViaMemory(request, delegate: delegate)
|
||||
}
|
||||
|
||||
private static func fetchViaMemory(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request, delegate: delegate)
|
||||
|
||||
Task {
|
||||
await dataCh.send(data)
|
||||
|
||||
dataCh.finish()
|
||||
}
|
||||
|
||||
return (dataCh, response as! HTTPURLResponse)
|
||||
}
|
||||
|
||||
private static func fetchViaFile(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||
|
||||
let (fileURL, response) = try await urlSession.download(for: request, delegate: delegate)
|
||||
|
||||
// Acquire a handle to the downloaded file and then remove it.
|
||||
//
|
||||
// This keeps a working reference to that file, yet we don't
|
||||
// have to deal with the cleanup any more.
|
||||
let mappedFile = try Data(contentsOf: fileURL, options: [.alwaysMapped])
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
|
||||
Task {
|
||||
for chunk in (0 ..< mappedFile.count).chunks(ofCount: 64 * 1024 * 1024) {
|
||||
await dataCh.send(mappedFile.subdata(in: chunk))
|
||||
}
|
||||
|
||||
dataCh.finish()
|
||||
}
|
||||
|
||||
return (dataCh, response as! HTTPURLResponse)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func createURLSession() -> URLSession {
|
||||
fileprivate var urlSession: URLSession = {
|
||||
let config = URLSessionConfiguration.default
|
||||
|
||||
// Harbor expects a CSRF token to be present if the HTTP client
|
||||
@@ -77,4 +14,84 @@ fileprivate func createURLSession() -> URLSession {
|
||||
config.httpShouldSetCookies = false
|
||||
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
class Fetcher {
|
||||
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
|
||||
let task = urlSession.dataTask(with: request)
|
||||
|
||||
let delegate = Delegate()
|
||||
task.delegate = delegate
|
||||
|
||||
let stream = AsyncThrowingStream<Data, Error> { continuation in
|
||||
delegate.streamContinuation = continuation
|
||||
}
|
||||
|
||||
let response = try await withCheckedThrowingContinuation { continuation in
|
||||
delegate.responseContinuation = continuation
|
||||
task.resume()
|
||||
}
|
||||
|
||||
return (stream, response as! HTTPURLResponse)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate class Delegate: NSObject, URLSessionDataDelegate {
|
||||
var responseContinuation: CheckedContinuation<URLResponse, Error>?
|
||||
var streamContinuation: AsyncThrowingStream<Data, Error>.Continuation?
|
||||
|
||||
private var buffer: Data = Data()
|
||||
private let bufferFlushSize = 16 * 1024 * 1024
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
dataTask: URLSessionDataTask,
|
||||
didReceive response: URLResponse,
|
||||
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
||||
) {
|
||||
// Soft-limit for the maximum buffer capacity
|
||||
let capacity = min(response.expectedContentLength, Int64(bufferFlushSize))
|
||||
|
||||
// Pre-initialize buffer as we now know the capacity
|
||||
buffer = Data(capacity: Int(capacity))
|
||||
|
||||
responseContinuation?.resume(returning: response)
|
||||
responseContinuation = nil
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
dataTask: URLSessionDataTask,
|
||||
didReceive data: Data
|
||||
) {
|
||||
buffer.append(data)
|
||||
|
||||
if buffer.count >= bufferFlushSize {
|
||||
streamContinuation?.yield(buffer)
|
||||
buffer.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didCompleteWithError error: Error?
|
||||
) {
|
||||
if let error = error {
|
||||
responseContinuation?.resume(throwing: error)
|
||||
responseContinuation = nil
|
||||
|
||||
streamContinuation?.finish(throwing: error)
|
||||
streamContinuation = nil
|
||||
} else {
|
||||
if !buffer.isEmpty {
|
||||
streamContinuation?.yield(buffer)
|
||||
buffer.removeAll(keepingCapacity: true)
|
||||
}
|
||||
|
||||
streamContinuation?.finish()
|
||||
streamContinuation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import GRPC
|
||||
import Cirruslabs_TartGuestAgent_Apple_Swift
|
||||
import Cirruslabs_TartGuestAgent_Grpc_Swift
|
||||
|
||||
class AgentResolver {
|
||||
static func ResolveIP(_ controlSocketPath: String) async throws -> IPv4Address? {
|
||||
do {
|
||||
return try await resolveIP(controlSocketPath)
|
||||
} catch is GRPCConnectionPoolError {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveIP(_ controlSocketPath: String) async throws -> IPv4Address? {
|
||||
try await withGuestAgentChannel(unixDomainSocketPath: controlSocketPath) { channel in
|
||||
// Invoke ResolveIP() gRPC method
|
||||
let callOptions = CallOptions(timeLimit: .timeout(.seconds(1)))
|
||||
let agentAsyncClient = AgentAsyncClient(channel: channel)
|
||||
let resolveIPCall = agentAsyncClient.makeResolveIpCall(ResolveIPRequest(), callOptions: callOptions)
|
||||
|
||||
let response = try await resolveIPCall.response
|
||||
|
||||
return IPv4Address(response.ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,18 @@ class Softnet: Network {
|
||||
|
||||
let vmFD: Int32
|
||||
|
||||
init(vmMACAddress: String, extraArguments: [String] = []) throws {
|
||||
init(vmMACAddress: String, extraArguments: [String] = [], controlFD: Int32? = nil) throws {
|
||||
var controlFileHandle: FileHandle?
|
||||
|
||||
if let controlFD = controlFD {
|
||||
guard controlFD > STDERR_FILENO else {
|
||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2")
|
||||
}
|
||||
|
||||
controlFileHandle = FileHandle(fileDescriptor: controlFD, closeOnDealloc: true)
|
||||
try Self.validateControlFD(controlFD)
|
||||
}
|
||||
|
||||
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||
|
||||
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
||||
@@ -33,6 +44,44 @@ class Softnet: Network {
|
||||
process.executableURL = try Self.softnetExecutableURL()
|
||||
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress] + extraArguments
|
||||
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
||||
|
||||
if let controlFileHandle = controlFileHandle {
|
||||
process.arguments! += ["--control-fd", String(STDOUT_FILENO)]
|
||||
process.standardOutput = controlFileHandle
|
||||
}
|
||||
}
|
||||
|
||||
static func validateControlFD(_ fd: Int32) throws {
|
||||
guard fd > STDERR_FILENO else {
|
||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2")
|
||||
}
|
||||
|
||||
var socketType: Int32 = 0
|
||||
var socketTypeLength = socklen_t(MemoryLayout<Int32>.size)
|
||||
guard getsockopt(fd, SOL_SOCKET, SO_TYPE, &socketType, &socketTypeLength) == 0 else {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not a socket: \(details)")
|
||||
}
|
||||
|
||||
guard socketType == SOCK_STREAM else {
|
||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
|
||||
}
|
||||
|
||||
var peerAddress = sockaddr_storage()
|
||||
var peerAddressLength = socklen_t(MemoryLayout<sockaddr_storage>.size)
|
||||
let result = withUnsafeMutablePointer(to: &peerAddress) { pointer in
|
||||
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
getpeername(fd, $0, &peerAddressLength)
|
||||
}
|
||||
}
|
||||
guard result == 0 else {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not connected: \(details)")
|
||||
}
|
||||
|
||||
guard peerAddress.ss_family == sa_family_t(AF_UNIX) else {
|
||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
|
||||
}
|
||||
}
|
||||
|
||||
static func softnetExecutableURL() throws -> URL {
|
||||
@@ -46,6 +95,8 @@ class Softnet: Network {
|
||||
}
|
||||
|
||||
func run(_ sema: AsyncSemaphore) throws {
|
||||
defer { try? (process.standardOutput as? FileHandle)?.close() }
|
||||
|
||||
try process.run()
|
||||
|
||||
monitorTask = Task {
|
||||
|
||||
@@ -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 {
|
||||
disk.write(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()
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,21 @@ class DiskV2: Disk {
|
||||
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||
private static let layerLimitBytes = 512 * 1024 * 1024
|
||||
|
||||
// A zero chunk for faster than byte-by-byte comparisons
|
||||
//
|
||||
// Assumes that the other Data(...) is equal in size, but it's fine to get a false-negative
|
||||
// on the last block since it costs only 4 MiB of excess data per 512 MiB layer.
|
||||
//
|
||||
// Some simple benchmarks ("sync && sudo purge" command was used to negate the disk caching effects):
|
||||
// +--------------------------------------+---------------------------------------------------+
|
||||
// | Operation | time(1) result |
|
||||
// +--------------------------------------+---------------------------------------------------+
|
||||
// | Data(...) == zeroChunk | 2.16s user 11.71s system 73% cpu 18.928 total |
|
||||
// | Data(...).contains(where: {$0 != 0}) | 603.68s user 12.97s system 99% cpu 10:22.85 total |
|
||||
// +--------------------------------------+---------------------------------------------------+
|
||||
private static let holeGranularityBytes = 4 * 1024 * 1024
|
||||
private static let zeroChunk = Data(count: holeGranularityBytes)
|
||||
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||
var pushedLayers: [(index: Int, pushedLayer: OCIManifestLayer)] = []
|
||||
|
||||
@@ -29,7 +44,7 @@ class DiskV2: Disk {
|
||||
let compressedData = try (data as NSData).compressed(using: .lz4) as Data
|
||||
let compressedDataDigest = Digest.hash(compressedData)
|
||||
|
||||
try await retry(maxAttempts: 5, backoff: .exponentialWithFullJitter(baseDelay: .seconds(5), maxDelay: .seconds(60))) {
|
||||
try await retry(maxAttempts: 5) {
|
||||
if try await !registry.blobExists(compressedDataDigest) {
|
||||
_ = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb, digest: compressedDataDigest)
|
||||
}
|
||||
@@ -193,11 +208,26 @@ class DiskV2: Disk {
|
||||
diskWritingOffset = try zeroSkippingWrite(disk, rdisk, fsBlockSize, diskWritingOffset, data)
|
||||
}
|
||||
|
||||
try await registry.pullBlob(diskLayer.digest) { data in
|
||||
try filter.write(data)
|
||||
var rangeStart: Int64 = 0
|
||||
|
||||
// Update the progress
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
try await retry(maxAttempts: 5) {
|
||||
try await registry.pullBlob(diskLayer.digest, rangeStart: rangeStart) { data in
|
||||
try filter.write(data)
|
||||
|
||||
// Update the progress
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
|
||||
// Update the current range start
|
||||
rangeStart += Int64(data.count)
|
||||
}
|
||||
} recoverFromFailure: { error in
|
||||
if error is URLError {
|
||||
print("Error pulling disk layer \(index + 1): \"\(error.localizedDescription)\", attempting to re-try...")
|
||||
|
||||
return .retry
|
||||
}
|
||||
|
||||
return .throw
|
||||
}
|
||||
|
||||
try filter.finalize()
|
||||
@@ -215,22 +245,6 @@ class DiskV2: Disk {
|
||||
}
|
||||
|
||||
private static func zeroSkippingWrite(_ disk: FileHandle, _ rdisk: FileHandle?, _ fsBlockSize: UInt64, _ offset: UInt64, _ data: Data) throws -> UInt64 {
|
||||
let holeGranularityBytes = 64 * 1024
|
||||
|
||||
// A zero chunk for faster than byte-by-byte comparisons
|
||||
//
|
||||
// Assumes that the other Data(...) is equal in size, but it's fine to get a false-negative
|
||||
// on the last block since it costs only 64 KiB of excess data per 500 MB layer.
|
||||
//
|
||||
// Some simple benchmarks ("sync && sudo purge" command was used to negate the disk caching effects):
|
||||
// +--------------------------------------+---------------------------------------------------+
|
||||
// | Operation | time(1) result |
|
||||
// +--------------------------------------+---------------------------------------------------+
|
||||
// | Data(...) == zeroChunk | 2.16s user 11.71s system 73% cpu 18.928 total |
|
||||
// | Data(...).contains(where: {$0 != 0}) | 603.68s user 12.97s system 99% cpu 10:22.85 total |
|
||||
// +--------------------------------------+---------------------------------------------------+
|
||||
let zeroChunk = Data(count: holeGranularityBytes)
|
||||
|
||||
var offset = offset
|
||||
|
||||
for chunk in data.chunks(ofCount: holeGranularityBytes) {
|
||||
@@ -254,7 +268,7 @@ class DiskV2: Disk {
|
||||
|
||||
if chunk != actualContentsOnDisk {
|
||||
try disk.seek(toOffset: offset)
|
||||
disk.write(chunk)
|
||||
try disk.write(contentsOf: chunk)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +282,7 @@ class DiskV2: Disk {
|
||||
// is zeroed via truncate(2)
|
||||
if chunk != zeroChunk {
|
||||
try disk.seek(toOffset: offset)
|
||||
disk.write(chunk)
|
||||
try disk.write(contentsOf: chunk)
|
||||
}
|
||||
|
||||
offset += UInt64(chunk.count)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -14,6 +13,9 @@ let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
||||
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
||||
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
|
||||
|
||||
// Manifest labels
|
||||
let diskFormatLabel = "org.cirruslabs.tart.disk.format"
|
||||
|
||||
// Layer annotations
|
||||
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
|
||||
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
|
||||
@@ -66,6 +68,11 @@ struct OCIManifest: Codable, Equatable {
|
||||
struct OCIConfig: Codable {
|
||||
var architecture: Architecture = .arm64
|
||||
var os: OS = .darwin
|
||||
var config: ConfigContainer?
|
||||
|
||||
struct ConfigContainer: Codable {
|
||||
var Labels: [String: String]?
|
||||
}
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Foundation
|
||||
import Algorithms
|
||||
import AsyncAlgorithms
|
||||
|
||||
enum RegistryError: Error {
|
||||
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
|
||||
@@ -21,6 +20,7 @@ enum HTTPCode: Int {
|
||||
case Ok = 200
|
||||
case Created = 201
|
||||
case Accepted = 202
|
||||
case PartialContent = 206
|
||||
case Unauthorized = 401
|
||||
case NotFound = 404
|
||||
}
|
||||
@@ -29,14 +29,26 @@ extension Data {
|
||||
func asText() -> String {
|
||||
String(decoding: self, as: UTF8.self)
|
||||
}
|
||||
|
||||
func asTextPreview(limit: Int = 1000) -> String {
|
||||
guard count > limit else {
|
||||
return asText()
|
||||
}
|
||||
|
||||
return "\(asText().prefix(limit))..."
|
||||
}
|
||||
}
|
||||
|
||||
extension AsyncThrowingChannel<Data, Error> {
|
||||
func asData() async throws -> Data {
|
||||
extension AsyncThrowingStream<Data, Error> {
|
||||
func asData(limitBytes: Int64? = nil) async throws -> Data {
|
||||
var result = Data()
|
||||
|
||||
for try await chunk in self {
|
||||
result += chunk
|
||||
|
||||
if let limitBytes, result.count > limitBytes {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -160,7 +172,7 @@ class Registry {
|
||||
body: manifestJSON)
|
||||
if response.statusCode != HTTPCode.Created.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
|
||||
details: data.asText())
|
||||
details: data.asTextPreview())
|
||||
}
|
||||
|
||||
return Digest.hash(manifestJSON)
|
||||
@@ -171,7 +183,7 @@ class Registry {
|
||||
headers: ["Accept": ociManifestMediaType])
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
|
||||
details: data.asText())
|
||||
details: data.asTextPreview())
|
||||
}
|
||||
|
||||
let manifest = try OCIManifest(fromJSON: data)
|
||||
@@ -197,7 +209,7 @@ class Registry {
|
||||
headers: ["Content-Length": "0"])
|
||||
if postResponse.statusCode != HTTPCode.Accepted.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
|
||||
details: data.asText())
|
||||
details: data.asTextPreview())
|
||||
}
|
||||
|
||||
// Figure out where to upload the blob
|
||||
@@ -218,7 +230,7 @@ class Registry {
|
||||
)
|
||||
if response.statusCode != HTTPCode.Created.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
|
||||
code: response.statusCode, details: data.asText())
|
||||
code: response.statusCode, details: data.asTextPreview())
|
||||
}
|
||||
return digest
|
||||
}
|
||||
@@ -241,7 +253,7 @@ class Registry {
|
||||
// always accept both statuses since AWS ECR is not following specification
|
||||
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
|
||||
code: response.statusCode, details: data.asText())
|
||||
code: response.statusCode, details: data.asTextPreview())
|
||||
}
|
||||
uploadedBytes += chunk.count
|
||||
// Update location for the next chunk
|
||||
@@ -260,14 +272,26 @@ class Registry {
|
||||
case HTTPCode.NotFound.rawValue:
|
||||
return false
|
||||
default:
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "checking blob", code: response.statusCode, details: data.asText())
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "checking blob", code: response.statusCode, details: data.asTextPreview())
|
||||
}
|
||||
}
|
||||
|
||||
public func pullBlob(_ digest: String, handler: (Data) async throws -> Void) async throws {
|
||||
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
let body = try await channel.asData().asText()
|
||||
public func pullBlob(_ digest: String, rangeStart: Int64 = 0, handler: (Data) async throws -> Void) async throws {
|
||||
var expectedStatusCode = HTTPCode.Ok
|
||||
var headers: [String: String] = [:]
|
||||
|
||||
// Send Range header and expect HTTP 206 in return
|
||||
//
|
||||
// However, do not send Range header at all when rangeStart is 0,
|
||||
// because it makes no sense and we might get HTTP 200 in return
|
||||
if rangeStart != 0 {
|
||||
expectedStatusCode = HTTPCode.PartialContent
|
||||
headers["Range"] = "bytes=\(rangeStart)-"
|
||||
}
|
||||
|
||||
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), headers: headers, viaFile: true)
|
||||
if response.statusCode != expectedStatusCode.rawValue {
|
||||
let body = try await channel.asData(limitBytes: 4096).asTextPreview()
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
|
||||
details: body)
|
||||
}
|
||||
@@ -307,7 +331,7 @@ class Registry {
|
||||
body: Data? = nil,
|
||||
doAuth: Bool = true,
|
||||
viaFile: Bool = false
|
||||
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
|
||||
var urlComponents = urlComponents
|
||||
|
||||
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
||||
@@ -330,7 +354,6 @@ class Registry {
|
||||
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile, doAuth: doAuth)
|
||||
|
||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||
_ = try await channel.asData()
|
||||
try await auth(response: response)
|
||||
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile, doAuth: doAuth)
|
||||
}
|
||||
@@ -392,7 +415,7 @@ class Registry {
|
||||
let (data, response) = try await dataRequest(.GET, authenticateURL, headers: headers, doAuth: false)
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.statusCode) "
|
||||
+ "while retrieving an authentication token", details: data.asText())
|
||||
+ "while retrieving an authentication token", details: data.asTextPreview())
|
||||
}
|
||||
|
||||
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
|
||||
@@ -406,14 +429,18 @@ 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
|
||||
}
|
||||
|
||||
private func authAwareRequest(request: URLRequest, viaFile: Bool = false, doAuth: Bool) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
private func authAwareRequest(request: URLRequest, viaFile: Bool = false, doAuth: Bool) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
|
||||
var request = request
|
||||
|
||||
if doAuth {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
throw UnsupportedHostOSError()
|
||||
}
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
@@ -58,7 +58,11 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
VZMacOSBootLoader()
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
|
||||
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
|
||||
if needsNestedVirtualization {
|
||||
throw RuntimeError.VMConfigurationError("macOS virtual machines do not support nested virtualization")
|
||||
}
|
||||
|
||||
let result = VZMacPlatformConfiguration()
|
||||
|
||||
result.machineIdentifier = ecid
|
||||
@@ -78,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)
|
||||
@@ -123,6 +127,11 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] {
|
||||
// Only include the USB pointing device, not the trackpad
|
||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
|
||||
@@ -14,8 +14,12 @@ struct Linux: Platform {
|
||||
return result
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
|
||||
VZGenericPlatformConfiguration()
|
||||
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
|
||||
let config = VZGenericPlatformConfiguration()
|
||||
if #available(macOS 15, *) {
|
||||
config.isNestedVirtualizationEnabled = needsNestedVirtualization
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
@@ -38,4 +42,9 @@ struct Linux: Platform {
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] {
|
||||
// Linux doesn't support trackpad, so just return the regular pointing devices
|
||||
return pointingDevices()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import Virtualization
|
||||
protocol Platform: Codable {
|
||||
func os() -> OS
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||
func platform(nvramURL: URL) throws -> VZPlatformConfiguration
|
||||
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||
func keyboards() -> [VZKeyboardConfiguration]
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration]
|
||||
}
|
||||
|
||||
protocol PlatformSuspendable: Platform {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import ArgumentParser
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Sentry
|
||||
import OpenTelemetryApi
|
||||
import OpenTelemetrySdk
|
||||
import OpenTelemetryProtocolExporterHttp
|
||||
|
||||
@main
|
||||
struct Root: AsyncParsableCommand {
|
||||
@@ -18,6 +20,7 @@ struct Root: AsyncParsableCommand {
|
||||
Login.self,
|
||||
Logout.self,
|
||||
IP.self,
|
||||
Exec.self,
|
||||
Pull.self,
|
||||
Push.self,
|
||||
Import.self,
|
||||
@@ -29,95 +32,160 @@ struct Root: AsyncParsableCommand {
|
||||
FQN.self,
|
||||
])
|
||||
|
||||
public static func main() async throws {
|
||||
// Initialize Sentry
|
||||
if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
|
||||
SentrySDK.start { options in
|
||||
options.dsn = dsn
|
||||
options.releaseName = CI.release
|
||||
options.tracesSampleRate = Float(
|
||||
ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
|
||||
) as NSNumber?
|
||||
|
||||
// By default only 5XX are captured
|
||||
// Let's capture everything but 401 (unauthorized)
|
||||
options.enableCaptureFailedRequests = true
|
||||
options.failedRequestStatusCodes = [
|
||||
HttpStatusCodeRange(min: 400, max: 400),
|
||||
HttpStatusCodeRange(min: 402, max: 599)
|
||||
]
|
||||
}
|
||||
}
|
||||
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 and run command
|
||||
// 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 {
|
||||
var command = try parseAsRoot()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if var asyncCommand = command as? AsyncParsableCommand {
|
||||
try await asyncCommand.run()
|
||||
} else {
|
||||
try command.run()
|
||||
}
|
||||
command = try parseAsRoot()
|
||||
} catch {
|
||||
// Capture the error into Sentry
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
exit(withError: error)
|
||||
}
|
||||
|
||||
// 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)
|
||||
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 any other exception, including ArgumentParser's ones
|
||||
exit(withError: error)
|
||||
// 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
|
||||
@@ -126,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}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import System
|
||||
|
||||
struct State {
|
||||
fileprivate let termios: termios
|
||||
}
|
||||
|
||||
class Term {
|
||||
static func IsTerminal() -> Bool {
|
||||
var termios = termios()
|
||||
|
||||
return tcgetattr(FileHandle.standardInput.fileDescriptor, &termios) != -1
|
||||
}
|
||||
|
||||
static func MakeRaw() throws -> State {
|
||||
var termiosOrig = termios()
|
||||
|
||||
var ret = tcgetattr(FileHandle.standardInput.fileDescriptor, &termiosOrig)
|
||||
if ret == -1 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError.TerminalOperationFailed("failed to retrieve terminal parameters: \(details)")
|
||||
}
|
||||
|
||||
var termiosRaw = termiosOrig
|
||||
cfmakeraw(&termiosRaw)
|
||||
|
||||
ret = tcsetattr(FileHandle.standardInput.fileDescriptor, TCSANOW, &termiosRaw)
|
||||
if ret == -1 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError.TerminalOperationFailed("failed to set terminal parameters: \(details)")
|
||||
}
|
||||
|
||||
return State(termios: termiosOrig)
|
||||
}
|
||||
|
||||
static func Restore(_ state: State) throws {
|
||||
var termios = state.termios
|
||||
|
||||
let ret = tcsetattr(FileHandle.standardInput.fileDescriptor, TCSANOW, &termios)
|
||||
if ret == -1 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError.TerminalOperationFailed("failed to set terminal parameters: \(details)")
|
||||
}
|
||||
}
|
||||
|
||||
static func GetSize() throws -> (width: UInt16, height: UInt16) {
|
||||
var winsize = winsize()
|
||||
|
||||
guard ioctl(STDOUT_FILENO, TIOCGWINSZ, &winsize) != -1 else {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError.TerminalOperationFailed("failed to get terminal size: \(details)")
|
||||
}
|
||||
|
||||
return (width: winsize.ws_col, height: winsize.ws_row)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
import AsyncAlgorithms
|
||||
import Semaphore
|
||||
|
||||
struct UnsupportedRestoreImageError: Error {
|
||||
@@ -47,9 +46,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = [],
|
||||
suspendable: Bool = false,
|
||||
nested: Bool = false,
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true,
|
||||
sync: VZDiskImageSynchronizationMode = .full
|
||||
sync: VZDiskImageSynchronizationMode = .full,
|
||||
caching: VZDiskImageCachingMode? = nil,
|
||||
noTrackpad: Bool = false,
|
||||
noPointer: Bool = false,
|
||||
noKeyboard: Bool = false
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
@@ -66,9 +70,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable,
|
||||
nested: nested,
|
||||
audio: audio,
|
||||
clipboard: clipboard,
|
||||
sync: sync
|
||||
sync: sync,
|
||||
caching: caching,
|
||||
noTrackpad: noTrackpad,
|
||||
noPointer: noPointer,
|
||||
noKeyboard: noKeyboard
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -96,16 +105,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Download the IPSW
|
||||
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
|
||||
|
||||
let downloadProgress = Progress(totalUnitCount: 100)
|
||||
ProgressObserver(downloadProgress).log(defaultLogger)
|
||||
|
||||
let request = URLRequest(url: remoteURL)
|
||||
let (channel, response) = try await Fetcher.fetch(request, viaFile: true, progress: downloadProgress)
|
||||
let (channel, response) = try await Fetcher.fetch(request, viaFile: true)
|
||||
|
||||
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw")
|
||||
defaultLogger.appendNewLine("Computing digest for \(temporaryLocation.path)...")
|
||||
let digestProgress = Progress(totalUnitCount: response.expectedContentLength)
|
||||
ProgressObserver(digestProgress).log(defaultLogger)
|
||||
|
||||
let progress = Progress(totalUnitCount: response.expectedContentLength)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
FileManager.default.createFile(atPath: temporaryLocation.path, contents: nil)
|
||||
let lock = try FileLock(lockURL: temporaryLocation)
|
||||
@@ -115,10 +121,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
let digest = Digest()
|
||||
|
||||
for try await chunk in channel {
|
||||
let chunkAsData = Data(chunk)
|
||||
fileHandle.write(chunkAsData)
|
||||
digest.update(chunkAsData)
|
||||
digestProgress.completedUnitCount += Int64(chunk.count)
|
||||
try fileHandle.write(contentsOf: chunk)
|
||||
digest.update(chunk)
|
||||
progress.completedUnitCount += Int64(chunk.count)
|
||||
}
|
||||
|
||||
try fileHandle.close()
|
||||
@@ -142,6 +147,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
vmDir: VMDirectory,
|
||||
ipswURL: URL,
|
||||
diskSizeGB: UInt16,
|
||||
diskFormat: DiskImageFormat = .raw,
|
||||
network: Network = NetworkShared(),
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
@@ -174,14 +180,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
try vmDir.resizeDisk(diskSizeGB, format: diskFormat)
|
||||
|
||||
name = vmDir.name
|
||||
// Create config
|
||||
config = VMConfig(
|
||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
||||
memorySizeMin: requirements.minimumSupportedMemorySize,
|
||||
diskFormat: diskFormat
|
||||
)
|
||||
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
||||
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
||||
@@ -223,30 +230,43 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
#endif
|
||||
|
||||
@available(macOS 13, *)
|
||||
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
||||
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16, diskFormat: DiskImageFormat = .raw) async throws -> VM {
|
||||
// Create NVRAM
|
||||
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
try vmDir.resizeDisk(diskSizeGB, format: diskFormat)
|
||||
|
||||
// Create config
|
||||
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024)
|
||||
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024, diskFormat: diskFormat)
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func connect(toPort: UInt32) async throws -> VZVirtioSocketConnection {
|
||||
guard let socketDevice = virtualMachine.socketDevices.first else {
|
||||
throw RuntimeError.VMSocketFailed(toPort, ", VM has no socket devices configured")
|
||||
}
|
||||
|
||||
guard let virtioSocketDevice = socketDevice as? VZVirtioSocketDevice else {
|
||||
throw RuntimeError.VMSocketFailed(toPort, ", expected VM's first socket device to have a type of VZVirtioSocketDevice, got \(type(of: socketDevice)) instead")
|
||||
}
|
||||
|
||||
return try await virtioSocketDevice.connect(toPort: toPort)
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
try await sema.waitUnlessCancelled()
|
||||
@@ -266,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()
|
||||
@@ -295,9 +320,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||
serialPorts: [VZSerialPortConfiguration],
|
||||
suspendable: Bool = false,
|
||||
nested: Bool = false,
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true,
|
||||
sync: VZDiskImageSynchronizationMode = .full
|
||||
sync: VZDiskImageSynchronizationMode = .full,
|
||||
caching: VZDiskImageCachingMode? = nil,
|
||||
noTrackpad: Bool = false,
|
||||
noPointer: Bool = false,
|
||||
noKeyboard: Bool = false
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -309,7 +339,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.memorySize = vmConfig.memorySize
|
||||
|
||||
// Platform
|
||||
configuration.platform = try vmConfig.platform.platform(nvramURL: nvramURL)
|
||||
configuration.platform = try vmConfig.platform.platform(nvramURL: nvramURL, needsNestedVirtualization: nested)
|
||||
|
||||
// Display
|
||||
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
||||
@@ -337,8 +367,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
|
||||
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
|
||||
} else {
|
||||
configuration.keyboards = vmConfig.platform.keyboards()
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// Networking
|
||||
@@ -350,20 +392,27 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
// Clipboard sharing via Spice agent
|
||||
if clipboard && vmConfig.os == .linux {
|
||||
if clipboard {
|
||||
let spiceAgentConsoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||
let spiceAgentPort = VZVirtioConsolePortConfiguration()
|
||||
spiceAgentPort.name = VZSpiceAgentPortAttachment.spiceAgentPortName
|
||||
spiceAgentPort.attachment = VZSpiceAgentPortAttachment()
|
||||
let spiceAgentPortAttachment = VZSpiceAgentPortAttachment()
|
||||
spiceAgentPortAttachment.sharesClipboard = true
|
||||
spiceAgentPort.attachment = spiceAgentPortAttachment
|
||||
spiceAgentConsoleDevice.ports[0] = spiceAgentPort
|
||||
configuration.consoleDevices.append(spiceAgentConsoleDevice)
|
||||
}
|
||||
|
||||
// Storage
|
||||
let attachment: VZDiskImageStorageDeviceAttachment = vmConfig.os == .linux ?
|
||||
// Use "cached" caching mode for virtio drive to prevent fs corruption on linux
|
||||
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .cached, synchronizationMode: sync) :
|
||||
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .automatic, synchronizationMode: sync)
|
||||
let attachment = try VZDiskImageStorageDeviceAttachment(
|
||||
url: diskURL,
|
||||
readOnly: false,
|
||||
// When not specified, use "cached" caching mode for Linux VMs to prevent file-system corruption[1]
|
||||
//
|
||||
// [1]: https://github.com/cirruslabs/tart/pull/675
|
||||
cachingMode: caching ?? (vmConfig.os == .linux ? .cached : .automatic),
|
||||
synchronizationMode: sync
|
||||
)
|
||||
|
||||
var devices: [VZStorageDeviceConfiguration] = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]
|
||||
devices.append(contentsOf: additionalStorageDevices)
|
||||
@@ -384,15 +433,16 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
//
|
||||
// A dummy console device useful for implementing
|
||||
// host feature checks in the guest agent software.
|
||||
if !suspendable {
|
||||
let consolePort = VZVirtioConsolePortConfiguration()
|
||||
consolePort.name = "tart-version-\(CI.version)"
|
||||
let consolePort = VZVirtioConsolePortConfiguration()
|
||||
consolePort.name = "tart-version-\(CI.version)"
|
||||
|
||||
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||
consoleDevice.ports[0] = consolePort
|
||||
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||
consoleDevice.ports[0] = consolePort
|
||||
|
||||
configuration.consoleDevices.append(consoleDevice)
|
||||
}
|
||||
configuration.consoleDevices.append(consoleDevice)
|
||||
|
||||
// Socket device
|
||||
configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()]
|
||||
|
||||
try configuration.validate()
|
||||
|
||||
|
||||
@@ -24,20 +24,32 @@ enum CodingKeys: String, CodingKey {
|
||||
case memorySize
|
||||
case macAddress
|
||||
case display
|
||||
case displayRefit
|
||||
case diskFormat
|
||||
|
||||
// macOS-specific keys
|
||||
case ecid
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,12 +64,15 @@ struct VMConfig: Codable {
|
||||
private(set) var memorySize: UInt64
|
||||
var macAddress: VZMACAddress
|
||||
var display: VMDisplayConfig = VMDisplayConfig()
|
||||
var displayRefit: Bool?
|
||||
var diskFormat: DiskImageFormat = .raw
|
||||
|
||||
init(
|
||||
platform: Platform,
|
||||
cpuCountMin: Int,
|
||||
memorySizeMin: UInt64,
|
||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered(),
|
||||
diskFormat: DiskImageFormat = .raw
|
||||
) {
|
||||
self.os = platform.os()
|
||||
self.arch = CurrentArchitecture()
|
||||
@@ -65,6 +80,7 @@ struct VMConfig: Codable {
|
||||
self.macAddress = macAddress
|
||||
self.cpuCountMin = cpuCountMin
|
||||
self.memorySizeMin = memorySizeMin
|
||||
self.diskFormat = diskFormat
|
||||
cpuCount = cpuCountMin
|
||||
memorySize = memorySizeMin
|
||||
}
|
||||
@@ -121,6 +137,9 @@ struct VMConfig: Codable {
|
||||
self.macAddress = macAddress
|
||||
|
||||
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
|
||||
displayRefit = try container.decodeIfPresent(Bool.self, forKey: .displayRefit)
|
||||
let diskFormatString = try container.decodeIfPresent(String.self, forKey: .diskFormat) ?? "raw"
|
||||
diskFormat = DiskImageFormat(rawValue: diskFormatString) ?? .raw
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
@@ -136,6 +155,10 @@ struct VMConfig: Codable {
|
||||
try container.encode(memorySize, forKey: .memorySize)
|
||||
try container.encode(macAddress.string, forKey: .macAddress)
|
||||
try container.encode(display, forKey: .display)
|
||||
if let displayRefit = displayRefit {
|
||||
try container.encode(displayRefit, forKey: .displayRefit)
|
||||
}
|
||||
try container.encode(diskFormat.rawValue, forKey: .diskFormat)
|
||||
}
|
||||
|
||||
mutating func setCPU(cpuCount: Int) throws {
|
||||
|
||||
@@ -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
|
||||
@@ -24,26 +26,25 @@ extension VMDirectory {
|
||||
}
|
||||
let configFile = try FileHandle(forWritingTo: configURL)
|
||||
try await registry.pullBlob(configLayers.first!.digest) { data in
|
||||
configFile.write(data)
|
||||
try configFile.write(contentsOf: data)
|
||||
}
|
||||
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)")
|
||||
}
|
||||
@@ -79,7 +80,7 @@ extension VMDirectory {
|
||||
}
|
||||
let nvram = try FileHandle(forWritingTo: nvramURL)
|
||||
try await registry.pullBlob(nvramLayers.first!.digest) { data in
|
||||
nvram.write(data)
|
||||
try nvram.write(contentsOf: data)
|
||||
}
|
||||
try nvram.close()
|
||||
|
||||
@@ -87,11 +88,15 @@ extension VMDirectory {
|
||||
try manifest.toJSON().write(to: manifestURL)
|
||||
}
|
||||
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String, concurrency: UInt) 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
|
||||
let config = try VMConfig(fromURL: configURL)
|
||||
|
||||
// Add disk format label automatically
|
||||
var labels = labels
|
||||
labels[diskFormatLabel] = config.diskFormat.rawValue
|
||||
let configJSON = try JSONEncoder().encode(config)
|
||||
defaultLogger.appendNewLine("pushing config...")
|
||||
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
||||
@@ -104,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...")
|
||||
@@ -121,7 +119,8 @@ extension VMDirectory {
|
||||
layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest))
|
||||
|
||||
// Craft a stub OCI config for Docker Hub compatibility
|
||||
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
||||
let ociConfigContainer = OCIConfig.ConfigContainer(Labels: labels)
|
||||
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os, config: ociConfigContainer).toJSON()
|
||||
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
|
||||
let manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||
|
||||
@@ -26,6 +26,9 @@ struct VMDirectory: Prunable {
|
||||
var manifestURL: URL {
|
||||
baseURL.appendingPathComponent("manifest.json")
|
||||
}
|
||||
var controlSocketURL: URL {
|
||||
URL(fileURLWithPath: "control.sock", relativeTo: baseURL)
|
||||
}
|
||||
|
||||
var explicitlyPulledMark: URL {
|
||||
baseURL.appendingPathComponent(".explicitly-pulled")
|
||||
@@ -139,14 +142,34 @@ struct VMDirectory: Prunable {
|
||||
try vmConfig.save(toURL: configURL)
|
||||
}
|
||||
|
||||
func resizeDisk(_ sizeGB: UInt16) throws {
|
||||
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
||||
}
|
||||
func resizeDisk(_ sizeGB: UInt16, format: DiskImageFormat = .raw) throws {
|
||||
let diskExists = FileManager.default.fileExists(atPath: diskURL.path)
|
||||
|
||||
if diskExists {
|
||||
// Existing disk - resize it
|
||||
try resizeExistingDisk(sizeGB)
|
||||
} else {
|
||||
// New disk - create it with the specified format
|
||||
try createDisk(sizeGB: sizeGB, format: format)
|
||||
}
|
||||
}
|
||||
|
||||
private func resizeExistingDisk(_ sizeGB: UInt16) throws {
|
||||
// Check if this is an ASIF disk by reading the VM config
|
||||
let vmConfig = try VMConfig(fromURL: configURL)
|
||||
|
||||
if vmConfig.diskFormat == .asif {
|
||||
try resizeASIFDisk(sizeGB)
|
||||
} else {
|
||||
try resizeRawDisk(sizeGB)
|
||||
}
|
||||
}
|
||||
|
||||
private func resizeRawDisk(_ sizeGB: UInt16) throws {
|
||||
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
||||
let currentDiskFileLength = try diskFileHandle.seekToEnd()
|
||||
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||
|
||||
if desiredDiskFileLength < currentDiskFileLength {
|
||||
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength))
|
||||
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength))
|
||||
@@ -158,6 +181,84 @@ struct VMDirectory: Prunable {
|
||||
try diskFileHandle.close()
|
||||
}
|
||||
|
||||
private func resizeASIFDisk(_ sizeGB: UInt16) throws {
|
||||
do {
|
||||
let diskImageInfo = try Diskutil.imageInfo(diskURL)
|
||||
|
||||
let currentSizeBytes = try diskImageInfo.totalBytes()
|
||||
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)
|
||||
} else {
|
||||
// If sizes are equal, no action needed
|
||||
}
|
||||
} catch let error as RuntimeError {
|
||||
throw error
|
||||
} catch {
|
||||
throw RuntimeError.FailedToResizeDisk("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func performASIFResize(_ sizeGB: UInt16) throws {
|
||||
guard let diskutilURL = resolveBinaryPath("diskutil") else {
|
||||
throw RuntimeError.FailedToResizeDisk("diskutil not found in PATH")
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = diskutilURL
|
||||
process.arguments = [
|
||||
"image", "resize",
|
||||
"--size", "\(sizeGB)G",
|
||||
diskURL.path
|
||||
]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
|
||||
if process.terminationStatus != 0 {
|
||||
let output = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
throw RuntimeError.FailedToResizeDisk("Failed to resize ASIF disk image: \(output)")
|
||||
}
|
||||
} catch {
|
||||
throw RuntimeError.FailedToResizeDisk("Failed to execute diskutil resize: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func createDisk(sizeGB: UInt16, format: DiskImageFormat) throws {
|
||||
switch format {
|
||||
case .raw:
|
||||
try createRawDisk(sizeGB: sizeGB)
|
||||
case .asif:
|
||||
try Diskutil.imageCreate(diskURL: diskURL, sizeGB: sizeGB)
|
||||
}
|
||||
}
|
||||
|
||||
private func createRawDisk(sizeGB: UInt16) throws {
|
||||
// Create traditional raw disk image
|
||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
||||
|
||||
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
||||
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||
try diskFileHandle.truncate(atOffset: desiredDiskFileLength)
|
||||
try diskFileHandle.close()
|
||||
}
|
||||
|
||||
|
||||
func delete() throws {
|
||||
let lock = try lock()
|
||||
|
||||
@@ -198,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)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ extension Error {
|
||||
}
|
||||
|
||||
enum RuntimeError : Error {
|
||||
case Generic(_ message: String)
|
||||
case VMConfigurationError(_ message: String)
|
||||
case VMDoesNotExist(name: String)
|
||||
case VMMissingFiles(_ message: String)
|
||||
@@ -59,6 +60,8 @@ enum RuntimeError : Error {
|
||||
case DiskAlreadyInUse(_ message: String)
|
||||
case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
|
||||
case InvalidDiskSize(_ message: String)
|
||||
case FailedToCreateDisk(_ message: String)
|
||||
case FailedToResizeDisk(_ message: String)
|
||||
case FailedToUpdateAccessDate(_ message: String)
|
||||
case PIDLockFailed(_ message: String)
|
||||
case PIDLockMissing(_ message: String)
|
||||
@@ -71,10 +74,11 @@ 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)
|
||||
case VMSocketFailed(_ port: UInt32, _ explanation: String)
|
||||
case TerminalOperationFailed(_ message: String)
|
||||
}
|
||||
|
||||
protocol HasExitCode {
|
||||
@@ -84,6 +88,8 @@ protocol HasExitCode {
|
||||
extension RuntimeError : CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .Generic(let message):
|
||||
return message
|
||||
case .VMConfigurationError(let message):
|
||||
return message
|
||||
case .VMDoesNotExist(let name):
|
||||
@@ -104,6 +110,10 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return "failed to open block device \(path): \(explanation)"
|
||||
case .InvalidDiskSize(let message):
|
||||
return message
|
||||
case .FailedToCreateDisk(let message):
|
||||
return message
|
||||
case .FailedToResizeDisk(let message):
|
||||
return message
|
||||
case .FailedToUpdateAccessDate(let message):
|
||||
return message
|
||||
case .PIDLockFailed(let message):
|
||||
@@ -128,14 +138,16 @@ 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):
|
||||
return message
|
||||
case .VirtualMachineLimitExceeded(let hint):
|
||||
return "The number of VMs exceeds the system limit\(hint)"
|
||||
case .VMSocketFailed(let port, let explanation):
|
||||
return "Failed to establish a VM socket connection to port \(port): \(explanation)"
|
||||
case .TerminalOperationFailed(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,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
|
||||
|
||||
@@ -196,7 +208,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await retry(maxAttempts: 5, backoff: .exponentialWithFullJitter(baseDelay: .seconds(5), maxDelay: .seconds(60))) {
|
||||
try await retry(maxAttempts: 5) {
|
||||
// Choose the best base image which has the most deduplication ratio
|
||||
let localLayerCache = try await chooseLocalLayerCache(name, manifest, registry)
|
||||
|
||||
@@ -212,9 +224,8 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache, deduplicate: deduplicate)
|
||||
} recoverFromFailure: { error in
|
||||
if error is Retryable {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
print("Attempting to re-try...")
|
||||
if error is URLError {
|
||||
print("Error pulling image: \"\(error.localizedDescription)\", attempting to re-try...")
|
||||
|
||||
return .retry
|
||||
}
|
||||
@@ -222,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 {
|
||||
@@ -239,6 +248,9 @@ class VMStorageOCI: PrunableStorage {
|
||||
// are excluded from garbage collection
|
||||
VMDirectory(baseURL: vmURL(name)).markExplicitlyPulled()
|
||||
}
|
||||
|
||||
// to explicitly set the image as being accessed so it won't get pruned immediately
|
||||
_ = try VMStorageOCI().open(name)
|
||||
}
|
||||
|
||||
func linked(from: RemoteName, to: RemoteName) -> Bool {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class DiskImageFormatTests: XCTestCase {
|
||||
func testASIFFormatSupport() throws {
|
||||
// ASIF should be supported on macOS 26+
|
||||
if #available(macOS 26, *) {
|
||||
XCTAssertTrue(DiskImageFormat.asif.isSupported)
|
||||
} else {
|
||||
XCTAssertFalse(DiskImageFormat.asif.isSupported)
|
||||
}
|
||||
}
|
||||
|
||||
func testFormatFromString() throws {
|
||||
XCTAssertEqual(DiskImageFormat(rawValue: "raw"), .raw)
|
||||
XCTAssertEqual(DiskImageFormat(rawValue: "asif"), .asif)
|
||||
XCTAssertNil(DiskImageFormat(rawValue: "invalid"))
|
||||
}
|
||||
|
||||
func testCaseInsensitivity() throws {
|
||||
XCTAssertEqual(DiskImageFormat(argument: "ASIF"), .asif) // case insensitive
|
||||
XCTAssertEqual(DiskImageFormat(argument: "Raw"), .raw) // case insensitive
|
||||
}
|
||||
|
||||
func testAllValueStrings() throws {
|
||||
let allValues = DiskImageFormat.allValueStrings
|
||||
XCTAssertTrue(allValues.contains("raw"))
|
||||
XCTAssertTrue(allValues.contains("asif"))
|
||||
XCTAssertEqual(allValues.count, 2)
|
||||
}
|
||||
|
||||
func testVMConfigDiskFormatSerialization() throws {
|
||||
// Test that VMConfig properly serializes and deserializes disk format
|
||||
let config = VMConfig(
|
||||
platform: Linux(),
|
||||
cpuCountMin: 2,
|
||||
memorySizeMin: 1024 * 1024 * 1024,
|
||||
diskFormat: .asif
|
||||
)
|
||||
|
||||
XCTAssertEqual(config.diskFormat, .asif)
|
||||
|
||||
// Test JSON encoding/decoding
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(config)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let decodedConfig = try decoder.decode(VMConfig.self, from: data)
|
||||
|
||||
XCTAssertEqual(decodedConfig.diskFormat, .asif)
|
||||
}
|
||||
|
||||
func testVMConfigDefaultDiskFormat() throws {
|
||||
// Test that VMConfig defaults to raw format
|
||||
let config = VMConfig(
|
||||
platform: Linux(),
|
||||
cpuCountMin: 2,
|
||||
memorySizeMin: 1024 * 1024 * 1024
|
||||
)
|
||||
|
||||
XCTAssertEqual(config.diskFormat, .raw)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
import Semaphore
|
||||
|
||||
final class SoftnetControlFDTests: XCTestCase {
|
||||
func testConnectedUnixStreamSocketIsAccepted() throws {
|
||||
var fds: [Int32] = [-1, -1]
|
||||
XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0)
|
||||
defer {
|
||||
close(fds[0])
|
||||
close(fds[1])
|
||||
}
|
||||
|
||||
XCTAssertNoThrow(try Softnet.validateControlFD(fds[0]))
|
||||
}
|
||||
|
||||
func testUnixDatagramSocketIsRejected() throws {
|
||||
var fds: [Int32] = [-1, -1]
|
||||
XCTAssertEqual(socketpair(AF_UNIX, SOCK_DGRAM, 0, &fds), 0)
|
||||
defer {
|
||||
close(fds[0])
|
||||
close(fds[1])
|
||||
}
|
||||
|
||||
XCTAssertThrowsError(try Softnet.validateControlFD(fds[0]))
|
||||
}
|
||||
|
||||
func testUnconnectedUnixStreamSocketIsRejected() throws {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
XCTAssertGreaterThan(fd, STDERR_FILENO)
|
||||
defer { close(fd) }
|
||||
|
||||
XCTAssertThrowsError(try Softnet.validateControlFD(fd))
|
||||
}
|
||||
|
||||
func testPipeIsRejected() throws {
|
||||
var fds: [Int32] = [-1, -1]
|
||||
XCTAssertEqual(pipe(&fds), 0)
|
||||
defer {
|
||||
close(fds[0])
|
||||
close(fds[1])
|
||||
}
|
||||
|
||||
XCTAssertThrowsError(try Softnet.validateControlFD(fds[0]))
|
||||
}
|
||||
|
||||
func testStandardDescriptorsAreRejected() throws {
|
||||
XCTAssertThrowsError(try Softnet.validateControlFD(STDIN_FILENO))
|
||||
XCTAssertThrowsError(try Softnet.validateControlFD(STDOUT_FILENO))
|
||||
XCTAssertThrowsError(try Softnet.validateControlFD(STDERR_FILENO))
|
||||
}
|
||||
|
||||
func testStandardDescriptorsRemainOpenWhenInitializationFails() throws {
|
||||
for fd in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
|
||||
let flags = fcntl(fd, F_GETFD)
|
||||
XCTAssertNotEqual(flags, -1)
|
||||
|
||||
XCTAssertThrowsError(try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fd))
|
||||
XCTAssertEqual(fcntl(fd, F_GETFD), flags)
|
||||
}
|
||||
}
|
||||
|
||||
func testControlChannelIsPassedToSoftnetAndVMFDRemainsDatagram() async throws {
|
||||
let temporaryDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: false)
|
||||
defer { try? FileManager.default.removeItem(at: temporaryDirectory) }
|
||||
|
||||
let executable = temporaryDirectory.appendingPathComponent("softnet")
|
||||
let script = """
|
||||
#!/usr/bin/env python3
|
||||
import socket
|
||||
import sys
|
||||
|
||||
assert sys.argv[1:] == ["--vm-fd", "0", "--vm-mac-address", "02:00:00:00:00:01", "--control-fd", "1"]
|
||||
vm = socket.socket(fileno=0)
|
||||
control = socket.socket(fileno=1)
|
||||
assert vm.family == socket.AF_UNIX and vm.type == socket.SOCK_DGRAM
|
||||
assert control.family == socket.AF_UNIX and control.type == socket.SOCK_STREAM
|
||||
assert control.recv(4096) == b"softnet.policy.set\\n"
|
||||
control.sendall(b"ok\\n")
|
||||
"""
|
||||
try script.write(to: executable, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
|
||||
let previousPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
|
||||
setenv("PATH", "\(temporaryDirectory.path):\(previousPath)", 1)
|
||||
defer { setenv("PATH", previousPath, 1) }
|
||||
|
||||
var fds: [Int32] = [-1, -1]
|
||||
XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0)
|
||||
defer { close(fds[1]) }
|
||||
|
||||
var timeout = timeval(tv_sec: 5, tv_usec: 0)
|
||||
XCTAssertEqual(setsockopt(fds[1], SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size)), 0)
|
||||
|
||||
let semaphore = AsyncSemaphore(value: 0)
|
||||
let softnet = try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fds[0])
|
||||
try softnet.run(semaphore)
|
||||
|
||||
XCTAssertEqual(fcntl(fds[0], F_GETFD), -1)
|
||||
XCTAssertEqual(errno, EBADF)
|
||||
|
||||
let request = Array("softnet.policy.set\n".utf8)
|
||||
XCTAssertEqual(request.withUnsafeBytes { send(fds[1], $0.baseAddress, $0.count, 0) }, request.count)
|
||||
|
||||
var response = [UInt8](repeating: 0, count: 128)
|
||||
let received = recv(fds[1], &response, response.count, 0)
|
||||
XCTAssertGreaterThan(received, 0)
|
||||
XCTAssertEqual(String(decoding: response.prefix(Int(max(received, 0))), as: UTF8.self), "ok\n")
|
||||
|
||||
await semaphore.wait()
|
||||
}
|
||||
|
||||
func testControlFDIsClosedWhenSoftnetInitializationFails() throws {
|
||||
let previousPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
|
||||
setenv("PATH", "/this/path/does/not/exist", 1)
|
||||
defer { setenv("PATH", previousPath, 1) }
|
||||
|
||||
var fds: [Int32] = [-1, -1]
|
||||
XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0)
|
||||
defer { close(fds[1]) }
|
||||
|
||||
XCTAssertThrowsError(try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fds[0]))
|
||||
XCTAssertEqual(fcntl(fds[0], F_GETFD), -1)
|
||||
XCTAssertEqual(errno, EBADF)
|
||||
}
|
||||
|
||||
func testControlFDIsClosedWhenSoftnetValidationFails() throws {
|
||||
var fds: [Int32] = [-1, -1]
|
||||
XCTAssertEqual(socketpair(AF_UNIX, SOCK_DGRAM, 0, &fds), 0)
|
||||
defer { close(fds[1]) }
|
||||
|
||||
XCTAssertThrowsError(try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fds[0]))
|
||||
XCTAssertEqual(fcntl(fds[0], F_GETFD), -1)
|
||||
XCTAssertEqual(errno, EBADF)
|
||||
}
|
||||
|
||||
func testControlFDImpliesSoftnet() throws {
|
||||
let temporaryHome = try createTemporaryTartHome()
|
||||
defer { try? FileManager.default.removeItem(at: temporaryHome) }
|
||||
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
|
||||
setenv("TART_HOME", temporaryHome.path, 1)
|
||||
defer { restoreEnvironment("TART_HOME", value: previousHome) }
|
||||
|
||||
let command = try Run.parse(["vm", "--net-softnet-control-fd", "3"])
|
||||
|
||||
XCTAssertTrue(command.netSoftnet)
|
||||
XCTAssertEqual(command.netSoftnetControlFd, 3)
|
||||
}
|
||||
|
||||
func testControlFDIsRejectedWithHostNetworking() throws {
|
||||
let temporaryHome = try createTemporaryTartHome()
|
||||
defer { try? FileManager.default.removeItem(at: temporaryHome) }
|
||||
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
|
||||
setenv("TART_HOME", temporaryHome.path, 1)
|
||||
defer { restoreEnvironment("TART_HOME", value: previousHome) }
|
||||
|
||||
XCTAssertThrowsError(
|
||||
try Run.parse(["vm", "--net-host", "--net-softnet-control-fd", "3"])
|
||||
)
|
||||
}
|
||||
|
||||
private func createTemporaryTartHome() throws -> URL {
|
||||
let temporaryHome = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
|
||||
let vm = temporaryHome.appendingPathComponent("vms/vm")
|
||||
try FileManager.default.createDirectory(at: vm, withIntermediateDirectories: true)
|
||||
|
||||
for name in ["config.json", "disk.img", "nvram.bin"] {
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: vm.appendingPathComponent(name).path, contents: nil))
|
||||
}
|
||||
|
||||
return temporaryHome
|
||||
}
|
||||
|
||||
private func restoreEnvironment(_ name: String, value: String?) {
|
||||
if let value = value {
|
||||
setenv(name, value, 1)
|
||||
} else {
|
||||
unsetenv(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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", "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
|
||||
@@ -2,10 +2,18 @@
|
||||
|
||||
Tart comes with a Golang-based benchmarking utility that allows one to easily compare host and guest performance.
|
||||
|
||||
Currently, only Flexible I/O tester workloads are supported. To run them, [install Golang](https://go.dev/) and run the following command from this (`benchmark/`) directory:
|
||||
Currently, only Flexible I/O tester workloads are supported. To run them, first make sure that [passwordless sudo](https://serverfault.com/questions/160581/how-to-setup-passwordless-sudo-on-linux) is configured.
|
||||
|
||||
Then, [install Golang](https://go.dev/). The easiest way is through [Homebrew](https://brew.sh/):
|
||||
|
||||
```shell
|
||||
go run cmd/main.go fio
|
||||
brew install go
|
||||
```
|
||||
|
||||
Finally, run the following command from this (`benchmark/`) directory:
|
||||
|
||||
```shell
|
||||
go run cmd/main.go fio --image ghcr.io/cirruslabs/macos-tahoe-base:latest --prepare 'sudo purge && sync'
|
||||
```
|
||||
|
||||
You can also enable the debugging output to diagnose issues:
|
||||
@@ -16,6 +24,8 @@ go run cmd/main.go fio --debug
|
||||
|
||||
## Results
|
||||
|
||||
### Mar 27, 2024
|
||||
|
||||
Host:
|
||||
|
||||
* Hardware: Mac mini (Apple M2 Pro, 8 performance and 4 efficiency cores, 32 GB RAM, `Mac14,12`)
|
||||
@@ -37,3 +47,164 @@ Random writing of 100MB Tart 2.0 GB/s 493.31 kIOPS
|
||||
Random writing of 1000MB local 1.7 GB/s 414.89 kIOPS
|
||||
Random writing of 1000MB Tart 1.1 GB/s 287.4 kIOPS
|
||||
```
|
||||
|
||||
### Dec 2, 2024
|
||||
|
||||
Host:
|
||||
|
||||
* Hardware: MacBook Pro (Apple M1 Pro, 8 performance and 2 efficiency cores, 32 GB RAM, `MacBookPro18,3`)
|
||||
* OS: macOS Sequoia 15.1.1
|
||||
|
||||
Guest:
|
||||
|
||||
* Hardware: [Virtualization.Framework](https://developer.apple.com/documentation/virtualization)
|
||||
* OS: macOS Sonoma 14.6
|
||||
|
||||
```
|
||||
Name Executor B/W (read) B/W (write) I/O (read) I/O (write) Latency (read) Latency (write) Latency (sync)
|
||||
Single 4KiB random write process local 0 B/s 19 MB/s 0 IOPS 4.81 kIOPS 0s ± 0s 203.418µs ± 155.865µs 0s ± 0s
|
||||
Single 4KiB random write process Tart 0 B/s 18 MB/s 0 IOPS 4.54 kIOPS 0s ± 0s 213.655µs ± 188.822µs 0s ± 0s
|
||||
Single 4KiB random write process Tart (--root-disk-opts="sync=none") 0 B/s 19 MB/s 0 IOPS 4.68 kIOPS 0s ± 0s 208.413µs ± 183.45µs 0s ± 0s
|
||||
Single 4KiB random write process Tart (--root-disk-opts="caching=cached") 0 B/s 24 MB/s 0 IOPS 6.11 kIOPS 0s ± 0s 158.07µs ± 2.294654ms 0s ± 0s
|
||||
Single 4KiB random write process Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 22 MB/s 0 IOPS 5.49 kIOPS 0s ± 0s 173.414µs ± 310.213µs 0s ± 0s
|
||||
16 parallel 64KiB random write processes local 0 B/s 18 GB/s 0 IOPS 273.76 kIOPS 0s ± 0s 323.423µs ± 604.999µs 0s ± 0s
|
||||
16 parallel 64KiB random write processes Tart 0 B/s 16 GB/s 0 IOPS 273.48 kIOPS 0s ± 0s 335.086µs ± 7.591748ms 0s ± 0s
|
||||
16 parallel 64KiB random write processes Tart (--root-disk-opts="sync=none") 0 B/s 18 GB/s 0 IOPS 281.49 kIOPS 0s ± 0s 326.655µs ± 7.485473ms 0s ± 0s
|
||||
16 parallel 64KiB random write processes Tart (--root-disk-opts="caching=cached") 0 B/s 17 GB/s 0 IOPS 266.79 kIOPS 0s ± 0s 340µs ± 7.868384ms 0s ± 0s
|
||||
16 parallel 64KiB random write processes Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 16 GB/s 0 IOPS 251.02 kIOPS 0s ± 0s 355.077µs ± 8.354218ms 0s ± 0s
|
||||
Single 1MiB random write process local 0 B/s 1.3 GB/s 0 IOPS 1.31 kIOPS 0s ± 0s 751.716µs ± 370.731µs 0s ± 0s
|
||||
Single 1MiB random write process Tart 0 B/s 1.1 GB/s 0 IOPS 1.1 kIOPS 0s ± 0s 885.833µs ± 3.572539ms 0s ± 0s
|
||||
Single 1MiB random write process Tart (--root-disk-opts="sync=none") 0 B/s 1.1 GB/s 0 IOPS 1.08 kIOPS 0s ± 0s 898.427µs ± 3.464261ms 0s ± 0s
|
||||
Single 1MiB random write process Tart (--root-disk-opts="caching=cached") 0 B/s 1000 MB/s 0 IOPS 976.47 IOPS 0s ± 0s 972.491µs ± 6.87654ms 0s ± 0s
|
||||
Single 1MiB random write process Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 1.1 GB/s 0 IOPS 1.03 kIOPS 0s ± 0s 925.545µs ± 4.261693ms 0s ± 0s
|
||||
Random reads/writes (4k) local 62 MB/s 62 MB/s 15.37 kIOPS 15.37 kIOPS 2.059453ms ± 1.431822ms 2.098761ms ± 1.445082ms 0s ± 0s
|
||||
Random reads/writes (4k) Tart 38 MB/s 38 MB/s 9.6 kIOPS 9.61 kIOPS 3.30369ms ± 1.500464ms 3.350589ms ± 1.512986ms 0s ± 0s
|
||||
Random reads/writes (4k) Tart (--root-disk-opts="sync=none") 39 MB/s 39 MB/s 9.82 kIOPS 9.83 kIOPS 3.228106ms ± 1.367512ms 3.27626ms ± 1.385964ms 0s ± 0s
|
||||
Random reads/writes (4k) Tart (--root-disk-opts="caching=cached") 35 MB/s 35 MB/s 8.74 kIOPS 8.76 kIOPS 3.640772ms ± 15.472355ms 3.661779ms ± 15.264288ms 0s ± 0s
|
||||
Random reads/writes (4k) Tart (--root-disk-opts="sync=none,caching=cached") 24 MB/s 24 MB/s 5.98 kIOPS 5.99 kIOPS 5.31188ms ± 4.55205ms 5.375047ms ± 5.113847ms 0s ± 0s
|
||||
Random reads/writes (64k) local 435 MB/s 436 MB/s 6.79 kIOPS 6.8 kIOPS 4.955892ms ± 2.066685ms 4.440414ms ± 1.860036ms 0s ± 0s
|
||||
Random reads/writes (64k) Tart 352 MB/s 353 MB/s 5.5 kIOPS 5.51 kIOPS 5.946067ms ± 2.041124ms 5.658948ms ± 1.928372ms 0s ± 0s
|
||||
Random reads/writes (64k) Tart (--root-disk-opts="sync=none") 331 MB/s 332 MB/s 5.16 kIOPS 5.17 kIOPS 6.330765ms ± 1.726782ms 6.033862ms ± 1.671028ms 0s ± 0s
|
||||
Random reads/writes (64k) Tart (--root-disk-opts="caching=cached") 428 MB/s 428 MB/s 6.68 kIOPS 6.69 kIOPS 4.661666ms ± 18.342779ms 4.904961ms ± 18.396772ms 0s ± 0s
|
||||
Random reads/writes (64k) Tart (--root-disk-opts="sync=none,caching=cached") 297 MB/s 298 MB/s 4.64 kIOPS 4.65 kIOPS 6.591009ms ± 2.827053ms 7.166883ms ± 3.001036ms 0s ± 0s
|
||||
sync test local 0 B/s 48 MB/s 0 IOPS 21.15 kIOPS 0s ± 0s 23.471µs ± 81.868µs 23.374µs ± 6.255µs
|
||||
sync test Tart 0 B/s 24 MB/s 0 IOPS 10.72 kIOPS 0s ± 0s 24.983µs ± 61.761µs 67.575µs ± 76.196µs
|
||||
sync test Tart (--root-disk-opts="sync=none") 0 B/s 21 MB/s 0 IOPS 9.5 kIOPS 0s ± 0s 26.973µs ± 63.935µs 77.388µs ± 47.103µs
|
||||
sync test Tart (--root-disk-opts="caching=cached") 0 B/s 30 MB/s 0 IOPS 13.19 kIOPS 0s ± 0s 11.923µs ± 25.225µs 62.894µs ± 208.933µs
|
||||
sync test Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 38 MB/s 0 IOPS 17.02 kIOPS 0s ± 0s 10.124µs ± 21.868µs 47.803µs ± 33.706µs
|
||||
```
|
||||
|
||||
### Dec 4, 2024
|
||||
|
||||
Host:
|
||||
|
||||
* AWS instance: `mac2.metal` + `gp3` EBS volume
|
||||
* Hardware: Mac mini (Apple M1, 4 performance and 4 efficiency cores, 16 GB RAM, `Macmini9,1`)
|
||||
* OS: macOS Sequoia 15.0
|
||||
|
||||
Guest:
|
||||
|
||||
* Hardware: [Virtualization.Framework](https://developer.apple.com/documentation/virtualization)
|
||||
* OS: macOS Sonoma 14.6
|
||||
|
||||
```
|
||||
Name Executor B/W (read) B/W (write) I/O (read) I/O (write) Latency (read) Latency (write) Latency (sync)
|
||||
Single 4KiB random write process local 0 B/s 4.4 MB/s 0 IOPS 1.1 kIOPS 0s ± 0s 702.357µs ± 359.925µs 0s ± 0s
|
||||
Single 4KiB random write process Tart 0 B/s 2.6 MB/s 0 IOPS 656.37 IOPS 0s ± 0s 1.140086ms ± 1.450472ms 0s ± 0s
|
||||
Single 4KiB random write process Tart (--root-disk-opts="sync=none") 0 B/s 2.7 MB/s 0 IOPS 677.07 IOPS 0s ± 0s 1.179872ms ± 1.219626ms 0s ± 0s
|
||||
Single 4KiB random write process Tart (--root-disk-opts="caching=cached") 0 B/s 3.3 MB/s 0 IOPS 832.66 IOPS 0s ± 0s 948.648µs ± 94.141338ms 0s ± 0s
|
||||
Single 4KiB random write process Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 15 MB/s 0 IOPS 3.65 kIOPS 0s ± 0s 260.717µs ± 19.977757ms 0s ± 0s
|
||||
16 parallel 64KiB random write processes local 0 B/s 9.5 GB/s 0 IOPS 147.89 kIOPS 0s ± 0s 753.289µs ± 8.028974ms 0s ± 0s
|
||||
16 parallel 64KiB random write processes Tart 0 B/s 10 GB/s 0 IOPS 176.96 kIOPS 0s ± 0s 429.83µs ± 33.792264ms 0s ± 0s
|
||||
16 parallel 64KiB random write processes Tart (--root-disk-opts="sync=none") 0 B/s 12 GB/s 0 IOPS 180.89 kIOPS 0s ± 0s 383.524µs ± 17.524971ms 0s ± 0s
|
||||
16 parallel 64KiB random write processes Tart (--root-disk-opts="caching=cached") 0 B/s 336 MB/s 0 IOPS 5.24 kIOPS 0s ± 0s 9.970844ms ± 365.808663ms 0s ± 0s
|
||||
16 parallel 64KiB random write processes Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 9.4 GB/s 0 IOPS 147.04 kIOPS 0s ± 0s 524.139µs ± 34.100009ms 0s ± 0s
|
||||
Single 1MiB random write process local 0 B/s 178 MB/s 0 IOPS 173.36 IOPS 0s ± 0s 3.835103ms ± 2.917977ms 0s ± 0s
|
||||
Single 1MiB random write process Tart 0 B/s 140 MB/s 0 IOPS 136.48 IOPS 0s ± 0s 4.721178ms ± 7.744965ms 0s ± 0s
|
||||
Single 1MiB random write process Tart (--root-disk-opts="sync=none") 0 B/s 144 MB/s 0 IOPS 140.63 IOPS 0s ± 0s 4.443507ms ± 11.572454ms 0s ± 0s
|
||||
Single 1MiB random write process Tart (--root-disk-opts="caching=cached") 0 B/s 47 MB/s 0 IOPS 45.55 IOPS 0s ± 0s 13.267881ms ± 358.283094ms 0s ± 0s
|
||||
Single 1MiB random write process Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 196 MB/s 0 IOPS 191.4 IOPS 0s ± 0s 4.102516ms ± 73.117503ms 0s ± 0s
|
||||
Random reads/writes (4k) local 8.7 MB/s 8.7 MB/s 2.16 kIOPS 2.16 kIOPS 193.370794ms ± 42.593607ms 222.272016ms ± 56.586971ms 0s ± 0s
|
||||
Random reads/writes (4k) Tart 4.1 MB/s 4.1 MB/s 1.02 kIOPS 1.03 kIOPS 31.038867ms ± 13.508668ms 31.184305ms ± 14.032766ms 0s ± 0s
|
||||
Random reads/writes (4k) Tart (--root-disk-opts="sync=none") 4.2 MB/s 4.2 MB/s 1.04 kIOPS 1.05 kIOPS 30.368422ms ± 13.505627ms 30.595412ms ± 13.840944ms 0s ± 0s
|
||||
Random reads/writes (4k) Tart (--root-disk-opts="caching=cached") 2.2 MB/s 2.2 MB/s 545.33 IOPS 548.86 IOPS 59.31316ms ± 716.351086ms 57.647852ms ± 711.503882ms 0s ± 0s
|
||||
Random reads/writes (4k) Tart (--root-disk-opts="sync=none,caching=cached") 6.0 MB/s 6.0 MB/s 1.5 kIOPS 1.5 kIOPS 21.244222ms ± 47.808399ms 21.39459ms ± 44.716307ms 0s ± 0s
|
||||
Random reads/writes (64k) local 121 MB/s 121 MB/s 1.89 kIOPS 1.89 kIOPS 61.894699ms ± 21.353345ms 73.176462ms ± 13.02948ms 0s ± 0s
|
||||
Random reads/writes (64k) Tart 72 MB/s 72 MB/s 1.12 kIOPS 1.12 kIOPS 27.842263ms ± 15.320781ms 29.161858ms ± 15.765314ms 0s ± 0s
|
||||
Random reads/writes (64k) Tart (--root-disk-opts="sync=none") 71 MB/s 72 MB/s 1.11 kIOPS 1.11 kIOPS 28.009493ms ± 16.333136ms 29.285868ms ± 16.540589ms 0s ± 0s
|
||||
Random reads/writes (64k) Tart (--root-disk-opts="caching=cached") 28 MB/s 28 MB/s 441.85 IOPS 444.81 IOPS 71.726725ms ± 633.215756ms 72.597238ms ± 630.969305ms 0s ± 0s
|
||||
Random reads/writes (64k) Tart (--root-disk-opts="sync=none,caching=cached") 81 MB/s 81 MB/s 1.26 kIOPS 1.26 kIOPS 24.872043ms ± 36.980111ms 25.568559ms ± 37.027145ms 0s ± 0s
|
||||
sync test local 0 B/s 1.9 MB/s 0 IOPS 868.08 IOPS 0s ± 0s 92.08µs ± 233.598µs 1.059033ms ± 98.751µs
|
||||
sync test Tart 0 B/s 1.5 MB/s 0 IOPS 649.42 IOPS 0s ± 0s 146.737µs ± 434.261µs 1.391898ms ± 699.148µs
|
||||
sync test Tart (--root-disk-opts="sync=none") 0 B/s 1.3 MB/s 0 IOPS 568.82 IOPS 0s ± 0s 158.736µs ± 504.002µs 1.59798ms ± 14.161331ms
|
||||
sync test Tart (--root-disk-opts="caching=cached") 0 B/s 13 MB/s 0 IOPS 5.77 kIOPS 0s ± 0s 26.596µs ± 832.169µs 145.785µs ± 2.864048ms
|
||||
sync test Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 19 MB/s 0 IOPS 8.37 kIOPS 0s ± 0s 20.135µs ± 108.817µs 98.274µs ± 239.631µs
|
||||
```
|
||||
|
||||
Host:
|
||||
|
||||
* AWS instance: `mac2.metal` + `gp3` EBS volume
|
||||
* Hardware: Mac mini (Apple M1, 4 performance and 4 efficiency cores, 16 GB RAM, `Macmini9,1`)
|
||||
* OS: macOS Sequoia 15.0
|
||||
|
||||
Guest:
|
||||
|
||||
* Hardware: [Virtualization.Framework](https://developer.apple.com/documentation/virtualization)
|
||||
* OS: macOS Sequoia 15.1
|
||||
|
||||
```
|
||||
Name Executor B/W (read) B/W (write) I/O (read) I/O (write) Latency (read) Latency (write) Latency (sync)
|
||||
Single 4KiB random write process local 0 B/s 4.8 MB/s 0 IOPS 1.19 kIOPS 0s ± 0s 690.818µs ± 326.595µs 0s ± 0s
|
||||
Single 4KiB random write process Tart 0 B/s 2.8 MB/s 0 IOPS 700.94 IOPS 0s ± 0s 1.090362ms ± 918.444µs 0s ± 0s
|
||||
Single 4KiB random write process Tart (--root-disk-opts="sync=none") 0 B/s 3.0 MB/s 0 IOPS 746.23 IOPS 0s ± 0s 1.028192ms ± 974.533µs 0s ± 0s
|
||||
Single 4KiB random write process Tart (--root-disk-opts="caching=cached") 0 B/s 4.2 MB/s 0 IOPS 1.04 kIOPS 0s ± 0s 916.36µs ± 105.318323ms 0s ± 0s
|
||||
Single 4KiB random write process Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 14 MB/s 0 IOPS 3.57 kIOPS 0s ± 0s 269.796µs ± 22.419599ms 0s ± 0s
|
||||
16 parallel 64KiB random write processes local 0 B/s 9.5 GB/s 0 IOPS 148.74 kIOPS 0s ± 0s 753.46µs ± 8.06509ms 0s ± 0s
|
||||
16 parallel 64KiB random write processes Tart 0 B/s 5.2 GB/s 0 IOPS 81.46 kIOPS 0s ± 0s 778.624µs ± 11.705178ms 0s ± 0s
|
||||
16 parallel 64KiB random write processes Tart (--root-disk-opts="sync=none") 0 B/s 5.3 GB/s 0 IOPS 83.47 kIOPS 0s ± 0s 865.448µs ± 38.369176ms 0s ± 0s
|
||||
16 parallel 64KiB random write processes Tart (--root-disk-opts="caching=cached") 0 B/s 116 MB/s 0 IOPS 1.8 kIOPS 0s ± 0s 37.601112ms ± 727.319309ms 0s ± 0s
|
||||
16 parallel 64KiB random write processes Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 5.3 GB/s 0 IOPS 83.19 kIOPS 0s ± 0s 900.751µs ± 51.223205ms 0s ± 0s
|
||||
Single 1MiB random write process local 0 B/s 177 MB/s 0 IOPS 173.27 IOPS 0s ± 0s 3.833194ms ± 2.873871ms 0s ± 0s
|
||||
Single 1MiB random write process Tart 0 B/s 151 MB/s 0 IOPS 147.44 IOPS 0s ± 0s 4.925853ms ± 7.793808ms 0s ± 0s
|
||||
Single 1MiB random write process Tart (--root-disk-opts="sync=none") 0 B/s 151 MB/s 0 IOPS 147.87 IOPS 0s ± 0s 4.884797ms ± 7.563512ms 0s ± 0s
|
||||
Single 1MiB random write process Tart (--root-disk-opts="caching=cached") 0 B/s 72 MB/s 0 IOPS 69.9 IOPS 0s ± 0s 8.909771ms ± 214.311644ms 0s ± 0s
|
||||
Single 1MiB random write process Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 159 MB/s 0 IOPS 155.69 IOPS 0s ± 0s 4.863448ms ± 88.965211ms 0s ± 0s
|
||||
Random reads/writes (4k) local 8.7 MB/s 8.7 MB/s 2.16 kIOPS 2.16 kIOPS 193.353233ms ± 42.728494ms 222.325905ms ± 56.901372ms 0s ± 0s
|
||||
Random reads/writes (4k) Tart 3.5 MB/s 3.5 MB/s 862.89 IOPS 865.54 IOPS 36.893229ms ± 12.644216ms 37.143334ms ± 12.772017ms 0s ± 0s
|
||||
Random reads/writes (4k) Tart (--root-disk-opts="sync=none") 3.6 MB/s 3.6 MB/s 907.4 IOPS 911.55 IOPS 35.048969ms ± 10.559354ms 35.3046ms ± 10.67824ms 0s ± 0s
|
||||
Random reads/writes (4k) Tart (--root-disk-opts="caching=cached") 2.7 MB/s 2.8 MB/s 684.11 IOPS 688.05 IOPS 48.815322ms ± 687.806727ms 44.395556ms ± 635.532064ms 0s ± 0s
|
||||
Random reads/writes (4k) Tart (--root-disk-opts="sync=none,caching=cached") 7.0 MB/s 7.0 MB/s 1.74 kIOPS 1.74 kIOPS 18.00448ms ± 93.784447ms 18.617037ms ± 107.423001ms 0s ± 0s
|
||||
Random reads/writes (64k) local 121 MB/s 121 MB/s 1.89 kIOPS 1.89 kIOPS 61.983727ms ± 21.324782ms 73.228597ms ± 12.730945ms 0s ± 0s
|
||||
Random reads/writes (64k) Tart 75 MB/s 75 MB/s 1.17 kIOPS 1.17 kIOPS 26.830538ms ± 7.643051ms 27.709602ms ± 7.830965ms 0s ± 0s
|
||||
Random reads/writes (64k) Tart (--root-disk-opts="sync=none") 76 MB/s 77 MB/s 1.19 kIOPS 1.19 kIOPS 26.255337ms ± 7.302592ms 27.256805ms ± 7.388266ms 0s ± 0s
|
||||
Random reads/writes (64k) Tart (--root-disk-opts="caching=cached") 32 MB/s 33 MB/s 505.26 IOPS 508.66 IOPS 65.170269ms ± 747.794957ms 61.062186ms ± 695.614904ms 0s ± 0s
|
||||
Random reads/writes (64k) Tart (--root-disk-opts="sync=none,caching=cached") 79 MB/s 79 MB/s 1.23 kIOPS 1.23 kIOPS 25.861503ms ± 171.669777ms 25.992302ms ± 164.647788ms 0s ± 0s
|
||||
sync test local 0 B/s 1.9 MB/s 0 IOPS 865.16 IOPS 0s ± 0s 100.95µs ± 268.722µs 1.054051ms ± 365.377µs
|
||||
sync test Tart 0 B/s 1.6 MB/s 0 IOPS 704.13 IOPS 0s ± 0s 133.886µs ± 390.263µs 1.285085ms ± 575.27µs
|
||||
sync test Tart (--root-disk-opts="sync=none") 0 B/s 1.6 MB/s 0 IOPS 728.26 IOPS 0s ± 0s 129.246µs ± 472.724µs 1.242713ms ± 1.281286ms
|
||||
sync test Tart (--root-disk-opts="caching=cached") 0 B/s 35 MB/s 0 IOPS 15.67 kIOPS 0s ± 0s 11.319µs ± 24.771µs 51.731µs ± 42.208µs
|
||||
sync test Tart (--root-disk-opts="sync=none,caching=cached") 0 B/s 17 MB/s 0 IOPS 7.39 kIOPS 0s ± 0s 21.23µs ± 81.749µs 113.239µs ± 191.266µs
|
||||
```
|
||||
|
||||
### March 23, 2025
|
||||
|
||||
Host:
|
||||
|
||||
* Hardware: Mac mini (Apple M2 Pro, 8 performance and 4 efficiency cores, 32 GB RAM, `Mac14,12`)
|
||||
* OS: macOS Sequoia 15.3.2
|
||||
* Xcode: 16.2
|
||||
|
||||
Guest:
|
||||
|
||||
* Hardware: [Virtualization.Framework](https://developer.apple.com/documentation/virtualization)
|
||||
* OS: macOS Sonoma 15.3.2
|
||||
* Xcode: 16.2
|
||||
|
||||
```
|
||||
Name Executor Time
|
||||
XcodeBenchmark (d869315) local 2m19s
|
||||
XcodeBenchmark (d869315) Tart 3m59s
|
||||
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none") 3m48s
|
||||
XcodeBenchmark (d869315) Tart (--root-disk-opts="caching=cached") 3m35s
|
||||
XcodeBenchmark (d869315) Tart (--root-disk-opts="sync=none,caching=cached") 3m14s
|
||||
```
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
module github.com/cirruslabs/tart/benchmark
|
||||
|
||||
go 1.22.1
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
require (
|
||||
github.com/avast/retry-go/v4 v4.5.1
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gosuri/uitable v0.0.4
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
golang.org/x/crypto v0.35.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -23,7 +27,8 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -7,6 +7,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
|
||||
@@ -26,26 +28,31 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -7,23 +7,63 @@ type Benchmark struct {
|
||||
|
||||
var benchmarks = []Benchmark{
|
||||
{
|
||||
Name: "Random writing of 1MB",
|
||||
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||
"--size 1MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||
// Ars Technica's "Single 4KiB random write process" test[1]
|
||||
// with JSON output and created file cleanup
|
||||
//
|
||||
// [1]: https://arstechnica.com/gadgets/2020/02/how-fast-are-your-disks-find-out-the-open-source-way-with-fio/
|
||||
Name: "Single 4KiB random write process",
|
||||
Command: "fio --name=benchmark --ioengine=posixaio --rw=randwrite --bs=4k --size=4g --numjobs=1 --iodepth=1 --runtime=60 --time_based --end_fsync=1" +
|
||||
" --output-format json --unlink 1",
|
||||
},
|
||||
{
|
||||
Name: "Random writing of 10MB",
|
||||
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||
"--size 10MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||
// Ars Technica's "16 parallel 64KiB random write processes" test[1]
|
||||
// with JSON outpu, created file cleanup and group reporting (for
|
||||
// easier analysis)
|
||||
//
|
||||
// [1]: https://arstechnica.com/gadgets/2020/02/how-fast-are-your-disks-find-out-the-open-source-way-with-fio/
|
||||
Name: "16 parallel 64KiB random write processes",
|
||||
Command: "fio --name=benchmark --ioengine=posixaio --rw=randwrite --bs=64k --size=256m --numjobs=16 --iodepth=16 --runtime=60 --time_based --end_fsync=1" +
|
||||
" --output-format json --unlink 1 --group_reporting",
|
||||
},
|
||||
{
|
||||
Name: "Random writing of 100MB",
|
||||
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||
"--size 100MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||
// Ars Technica's "16 parallel 64KiB random write processes" test[1]
|
||||
// with JSON output, created file cleanup and reduced file I/O size
|
||||
// from 16 to 10 GB to avoid "No space left on device".
|
||||
//
|
||||
// [1]: https://arstechnica.com/gadgets/2020/02/how-fast-are-your-disks-find-out-the-open-source-way-with-fio/
|
||||
Name: "Single 1MiB random write process",
|
||||
Command: "fio --name=benchmark --ioengine=posixaio --rw=randwrite --bs=1m --size=10g --numjobs=1 --iodepth=1 --runtime=60 --time_based --end_fsync=1" +
|
||||
" --output-format json --unlink 1",
|
||||
},
|
||||
{
|
||||
Name: "Random writing of 1000MB",
|
||||
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||
"--size 1000MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||
// Oracle's "Test random read/writes" (in IOPS Performance Tests[1]) category
|
||||
// with JSON output, created file cleanup, without ETA newline, without custom
|
||||
// file path, with file I/O size reduced from 500GB to 2GB to prevent
|
||||
// "No space left on device" and with posixaio instead of libaio.
|
||||
//
|
||||
// [1]: https://docs.oracle.com/en-us/iaas/Content/Block/References/samplefiocommandslinux.htm#FIO_Commands
|
||||
Name: "Random reads/writes (4k)",
|
||||
Command: "fio --name=benchmark --size=2GB --direct=1 --rw=randrw --bs=4k --ioengine=posixaio --iodepth=256 --runtime=120 --numjobs=4 --time_based --group_reporting" +
|
||||
" --output-format json --unlink 1",
|
||||
},
|
||||
{
|
||||
// Oracle's "Test random read/writes" (in Throughput Performance Tests[1]) category
|
||||
// with JSON output, created file cleanup, without ETA newline, without custom
|
||||
// file path, with file I/O size reduced from 500GB to 2GB to prevent
|
||||
// "No space left on device" and with posixaio instead of libaio.
|
||||
//
|
||||
// [1]: https://docs.oracle.com/en-us/iaas/Content/Block/References/samplefiocommandslinux.htm#Throughput_Performance_Tests
|
||||
Name: "Random reads/writes (64k)",
|
||||
Command: "fio --name=benchmark --size=2GB --direct=1 --rw=randrw --bs=64k --ioengine=posixaio --iodepth=64 --runtime=120 --numjobs=4 --time_based --group_reporting" +
|
||||
" --output-format json --unlink 1",
|
||||
},
|
||||
{
|
||||
// RedHat's "How can I test to see if my environment is fast enough for etcd"[1]
|
||||
// with custom name
|
||||
//
|
||||
// [1]: https://access.redhat.com/solutions/5726511
|
||||
Name: "sync test",
|
||||
Command: "mkdir -p test-data && fio --name=benchmark --rw=write --ioengine=sync --fdatasync=1 --directory=test-data --size=22m --bs=2300" +
|
||||
" --output-format json --unlink 1",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
package fio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor/local"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/executor/tart"
|
||||
executorpkg "github.com/cirruslabs/tart/benchmark/internal/executor"
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/gosuri/uitable"
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapio"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
var debug bool
|
||||
var image string
|
||||
var prepare string
|
||||
|
||||
func NewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
@@ -24,6 +25,8 @@ func NewCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging")
|
||||
cmd.Flags().StringVar(&image, "image", "ghcr.io/cirruslabs/macos-sonoma-base:latest", "image to use for testing")
|
||||
cmd.Flags().StringVar(&prepare, "prepare", "", "command to run before running each benchmark")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -41,28 +44,50 @@ func run(cmd *cobra.Command, args []string) error {
|
||||
_ = logger.Sync()
|
||||
}()
|
||||
|
||||
executors, err := initializeExecutors(cmd.Context(), logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
errs := []error{err}
|
||||
|
||||
for _, executor := range executors {
|
||||
if err := executor.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to close executor %s: %w", executor.Name(), err))
|
||||
}
|
||||
}
|
||||
|
||||
err = errors.Join(errs...)
|
||||
}()
|
||||
|
||||
table := uitable.New()
|
||||
table.AddRow("Name", "Executor", "Bandwidth", "I/O operations")
|
||||
table.AddRow("Name", "Executor", "B/W (read)", "B/W (write)", "I/O (read)", "I/O (write)",
|
||||
"Latency (read)", "Latency (write)", "Latency (sync)")
|
||||
|
||||
for _, benchmark := range benchmarks {
|
||||
for _, executor := range executors {
|
||||
logger.Sugar().Infof("running benchmark %q on %s executor", benchmark.Name, executor.Name())
|
||||
for _, executorInitializer := range executorpkg.DefaultInitializers(cmd.Context(), image, logger) {
|
||||
if prepare != "" {
|
||||
shell := "/bin/sh"
|
||||
|
||||
if shellFromEnv, ok := os.LookupEnv("SHELL"); ok {
|
||||
shell = shellFromEnv
|
||||
}
|
||||
|
||||
logger.Sugar().Infof("running prepare command %q using shell %q",
|
||||
prepare, shell)
|
||||
|
||||
cmd := exec.CommandContext(cmd.Context(), shell, "-c", prepare)
|
||||
|
||||
loggerWriter := &zapio.Writer{Log: logger, Level: zap.DebugLevel}
|
||||
|
||||
cmd.Stdout = loggerWriter
|
||||
cmd.Stderr = loggerWriter
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to run prepare command %q: %v", prepare, err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Sugar().Infof("initializing executor %s", executorInitializer.Name)
|
||||
|
||||
executor, err := executorInitializer.Fn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Sugar().Infof("installing Flexible I/O tester (fio) on executor %s",
|
||||
executorInitializer.Name)
|
||||
|
||||
if _, err := executor.Run(cmd.Context(), "brew install fio"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Sugar().Infof("running benchmark %q on %s executor", benchmark.Name,
|
||||
executorInitializer.Name)
|
||||
|
||||
stdout, err := executor.Run(cmd.Context(), benchmark.Command)
|
||||
if err != nil {
|
||||
@@ -82,12 +107,28 @@ func run(cmd *cobra.Command, args []string) error {
|
||||
|
||||
job := fioResult.Jobs[0]
|
||||
|
||||
readBandwidth := humanize.Bytes(uint64(job.Read.BW)*humanize.KByte) + "/s"
|
||||
readIOPS := humanize.SIWithDigits(job.Read.IOPS, 2, "IOPS")
|
||||
|
||||
logger.Sugar().Infof("read bandwidth: %s, read IOPS: %s, read latency: %s",
|
||||
readBandwidth, readIOPS, job.Read.LatencyNS.String())
|
||||
|
||||
writeBandwidth := humanize.Bytes(uint64(job.Write.BW)*humanize.KByte) + "/s"
|
||||
writeIOPS := humanize.SIWithDigits(job.Write.IOPS, 2, "IOPS")
|
||||
|
||||
logger.Sugar().Infof("write bandwidth: %s, write IOPS: %s\n", writeBandwidth, writeIOPS)
|
||||
logger.Sugar().Infof("write bandwidth: %s, write IOPS: %s, write latency: %s",
|
||||
writeBandwidth, writeIOPS, job.Write.LatencyNS.String())
|
||||
|
||||
table.AddRow(benchmark.Name, executor.Name(), writeBandwidth, writeIOPS)
|
||||
logger.Sugar().Infof("sync latency: %s", job.Sync.LatencyNS.String())
|
||||
|
||||
table.AddRow(benchmark.Name, executorInitializer.Name, readBandwidth, writeBandwidth,
|
||||
readIOPS, writeIOPS, job.Read.LatencyNS.String(), job.Write.LatencyNS.String(),
|
||||
job.Sync.LatencyNS.String())
|
||||
|
||||
if err := executor.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close executor %s: %w",
|
||||
executorInitializer.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,37 +136,3 @@ func run(cmd *cobra.Command, args []string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeExecutors(ctx context.Context, logger *zap.Logger) ([]executor.Executor, error) {
|
||||
var result []executor.Executor
|
||||
|
||||
logger.Info("initializing local executor")
|
||||
|
||||
local, err := local.New(logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, local)
|
||||
|
||||
logger.Info("local executor initialized")
|
||||
|
||||
logger.Info("initializing Tart executor")
|
||||
|
||||
tart, err := tart.New(ctx, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, tart)
|
||||
|
||||
logger.Info("Tart executor initialized")
|
||||
|
||||
for _, executor := range result {
|
||||
logger.Sugar().Infof("installing Flexible I/O tester (fio) on %s executor", executor.Name())
|
||||
|
||||
if _, err := executor.Run(ctx, "brew install fio"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
package fio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Jobs []Job `json:"jobs"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
Name string `json:"jobname"`
|
||||
Write Write `json:"write"`
|
||||
Read Stats `json:"read"`
|
||||
Write Stats `json:"write"`
|
||||
Sync Stats `json:"sync"`
|
||||
}
|
||||
|
||||
type Write struct {
|
||||
BW float64 `json:"bw"`
|
||||
IOPS float64 `json:"iops"`
|
||||
type Stats struct {
|
||||
BW float64 `json:"bw"`
|
||||
IOPS float64 `json:"iops"`
|
||||
LatencyNS Latency `json:"lat_ns"`
|
||||
}
|
||||
|
||||
type Latency struct {
|
||||
Mean float64 `json:"mean"`
|
||||
Stddev float64 `json:"stddev"`
|
||||
}
|
||||
|
||||
func (latency Latency) String() string {
|
||||
meanDuration := time.Duration(latency.Mean) * time.Nanosecond
|
||||
stddevDuration := time.Duration(latency.Stddev) * time.Nanosecond
|
||||
|
||||
return fmt.Sprintf("%v ± %v", meanDuration, stddevDuration)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package command
|
||||
|
||||
import (
|
||||
"github.com/cirruslabs/tart/benchmark/internal/command/fio"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/command/xcode"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -14,6 +15,7 @@ func NewCommand() *cobra.Command {
|
||||
|
||||
cmd.AddCommand(
|
||||
fio.NewCommand(),
|
||||
xcode.NewCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package xcode
|
||||
|
||||
type Benchmark struct {
|
||||
Name string
|
||||
Command string
|
||||
}
|
||||
|
||||
var benchmarks = []Benchmark{
|
||||
{
|
||||
Name: "XcodeBenchmark (d869315)",
|
||||
Command: "git clone https://github.com/devMEremenko/XcodeBenchmark.git && cd XcodeBenchmark && git reset --hard d86931529ada1df2a1c6646dd85958c360954065 && xcrun simctl list && sh benchmark.sh",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package xcode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Output struct {
|
||||
Started time.Time
|
||||
Ended time.Time
|
||||
}
|
||||
|
||||
func ParseOutput(s string) (*Output, error) {
|
||||
// Ensure that the build has succeeded
|
||||
matched, err := regexp.MatchString("(?m)^\\*\\* BUILD SUCCEEDED \\*\\*.*$", s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse output: regexp failed: %v", err)
|
||||
}
|
||||
if !matched {
|
||||
return nil, fmt.Errorf("failed to parse output: \"** BUILD SUCCEEDED **\" string " +
|
||||
"not found on a separate line, make sure you have Xcode installed")
|
||||
}
|
||||
|
||||
re := regexp.MustCompile("Started\\s+(?P<started>.*)\\n.*Ended\\s+(?P<ended>.*)\\n")
|
||||
|
||||
matches := re.FindStringSubmatch(s)
|
||||
|
||||
if len(matches) != re.NumSubexp()+1 {
|
||||
return nil, fmt.Errorf("failed to parse output: cannot find Started and Ended times")
|
||||
}
|
||||
|
||||
startedRaw := matches[re.SubexpIndex("started")]
|
||||
started, err := time.Parse(time.TimeOnly, startedRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse started time %q: unsupported format", startedRaw)
|
||||
}
|
||||
|
||||
endedRaw := matches[re.SubexpIndex("ended")]
|
||||
ended, err := time.Parse(time.TimeOnly, endedRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse ended time %q: unsupported format", startedRaw)
|
||||
}
|
||||
|
||||
return &Output{
|
||||
Started: started,
|
||||
Ended: ended,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package xcode_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/command/xcode"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseOutput(t *testing.T) {
|
||||
result, err := xcode.ParseOutput(`** BUILD SUCCEEDED ** [219.713 sec]
|
||||
|
||||
System Version: 14.6
|
||||
Xcode 15.4
|
||||
Hardware Overview
|
||||
Model Name: Apple Virtual Machine 1
|
||||
Model Identifier: VirtualMac2,1
|
||||
Total Number of Cores: 4
|
||||
Memory: 8 GB
|
||||
|
||||
✅ XcodeBenchmark has completed
|
||||
1️⃣ Take a screenshot of this window (Cmd + Shift + 4 + Space) and resize to include:
|
||||
- Build Time (See ** BUILD SUCCEEDED ** [XYZ sec])
|
||||
- System Version
|
||||
- Xcode Version
|
||||
- Hardware Overview
|
||||
- Started 13:46:20
|
||||
- Ended 13:50:02
|
||||
- Date Thu Jan 16 13:50:02 UTC 2025
|
||||
|
||||
2️⃣ Share your results at https://github.com/devMEremenko/XcodeBenchmark
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
fmt.Println(result)
|
||||
require.Equal(t, 222*time.Second, result.Ended.Sub(result.Started))
|
||||
}
|
||||