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 | ||
|
|
3fb8069edd | ||
|
|
c78c89e274 | ||
|
|
770220f905 | ||
|
|
768d1f9bad | ||
|
|
d49ed46439 | ||
|
|
b52a857698 | ||
|
|
3bf0bb22f3 | ||
|
|
accbd0cb33 | ||
|
|
c0b20932c7 | ||
|
|
3694af946c | ||
|
|
dbf711a6c9 | ||
|
|
b9f24a40c1 | ||
|
|
10c6ace671 | ||
|
|
b98e23956b | ||
|
|
ce23f9c2a7 | ||
|
|
3da91e6518 | ||
|
|
7046886713 | ||
|
|
3fde7d08dd | ||
|
|
227301436c | ||
|
|
106eb5a2c8 | ||
|
|
10bf706653 | ||
|
|
ff928ad77d | ||
|
|
33b5cfe2ed | ||
|
|
3892cdb00d | ||
|
|
5f2199ef3e | ||
|
|
3f26baa341 | ||
|
|
06cae1296e | ||
|
|
1b81b12760 | ||
|
|
4ed73bc775 | ||
|
|
2dc25ce478 | ||
|
|
1e74e268a5 | ||
|
|
bff344fb7f | ||
|
|
ababe8cefc | ||
|
|
ea5313698e | ||
|
|
679289d7ab | ||
|
|
5eccdf7412 | ||
|
|
63e3235d91 | ||
|
|
a760a431c3 | ||
|
|
bf5081b3d9 | ||
|
|
017592075f | ||
|
|
84e1ae2b38 | ||
|
|
d50e113300 | ||
|
|
fce52f1514 | ||
|
|
9484b8b2c9 | ||
|
|
dd46033812 | ||
|
|
c655288de7 | ||
|
|
204002f776 | ||
|
|
a0ae2f4e66 | ||
|
|
7c386e3466 | ||
|
|
dbbd716214 | ||
|
|
13d5ddb4a4 | ||
|
|
626316a4cd | ||
|
|
fbe35302c2 | ||
|
|
e1353f4540 | ||
|
|
985db24474 | ||
|
|
1d01bf63fb | ||
|
|
3ff3850da2 | ||
|
|
c6e8d0bfd7 | ||
|
|
755aad4d7c | ||
|
|
9f38441a42 | ||
|
|
3d46c4e6c2 | ||
|
|
e59221f6a0 | ||
|
|
c6e99345cd | ||
|
|
8bc2e99f63 |
@@ -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,5 +1,11 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
: "${VERSION:?VERSION must be set}"
|
||||
|
||||
TMPFILE=$(mktemp)
|
||||
envsubst < Sources/tart/CI/CI.swift > $TMPFILE
|
||||
mv $TMPFILE Sources/tart/CI/CI.swift
|
||||
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,30 +1,29 @@
|
||||
use_compute_credits: true
|
||||
|
||||
env:
|
||||
XCODE_TAG: latest
|
||||
|
||||
task:
|
||||
name: Test on Sonoma
|
||||
name: Test
|
||||
alias: test
|
||||
persistent_worker:
|
||||
labels:
|
||||
name: dev-mini
|
||||
resources:
|
||||
tart-vms: 1
|
||||
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:
|
||||
# Build Tart
|
||||
- swift build
|
||||
- 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
|
||||
@@ -41,7 +40,7 @@ task:
|
||||
name: Lint
|
||||
alias: lint
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
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:
|
||||
@@ -58,105 +57,21 @@ task:
|
||||
name: Build ($BUILD_ARCH)
|
||||
alias: build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
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-sonoma-xcode:$XCODE_TAG
|
||||
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-sonoma-xcode:$XCODE_TAG
|
||||
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[!c16a5cf7da5f856b4bc2f21fe8cb7aa2a6c981f851c094ed4d3025fd02ea59a58a86cee8b193a69a1fc20fa217e56ac3!]
|
||||
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/
|
||||
|
||||
@@ -16,3 +19,6 @@ dist/
|
||||
|
||||
# mkdocs
|
||||
.cache
|
||||
|
||||
# mkdocs-material
|
||||
site
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Profiling Tart
|
||||
|
||||
## Using `time(1)`
|
||||
|
||||
Perhaps, the easiest, but not the most comprehensive way to tell what's going on with Tart is to use the [`time(1)`](https://ss64.com/mac/time.html) command.
|
||||
|
||||
In the example below, you will run `tart pull` via `time(1)` to gather generalized CPU, I/O and memory usage metrics:
|
||||
|
||||
```shell
|
||||
/usr/bin/time -l tart pull ghcr.io/cirruslabs/macos-tahoe-base:latest
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
**Note:** The `-l` command-line argument makes `time(1)` return much more useful information, for example, maximum memory usage.
|
||||
|
||||
When running the command above, you'll see the `tart pull` output first as it pulls the image, and then the `time(1)` output, which will be printed once the Tart process finishes:
|
||||
|
||||
```
|
||||
172.17 real 10.29 user 8.36 sys
|
||||
353796096 maximum resident set size
|
||||
0 average shared memory size
|
||||
0 average unshared data size
|
||||
0 average unshared stack size
|
||||
23838 page reclaims
|
||||
35 page faults
|
||||
0 swaps
|
||||
0 block input operations
|
||||
0 block output operations
|
||||
8 messages sent
|
||||
8 messages received
|
||||
0 signals received
|
||||
146 voluntary context switches
|
||||
222950 involuntary context switches
|
||||
39683070975 instructions retired
|
||||
27562035252 cycles elapsed
|
||||
170920448 peak memory footprint
|
||||
```
|
||||
|
||||
From the output above, you can tell that `tart pull` spent nearly 90% of time off-CPU (`real` > `user` + `sys`), which means that Tart was mostly waiting for the I/O (be it a network or disk), instead of decompressing disk layers or doing other useful computations.
|
||||
|
||||
## Using `xctrace(1)`
|
||||
|
||||
[`xctrace(1)`](https://keith.github.io/xcode-man-pages/xctrace.1.html) is a `.trace` format recorder for the [Instruments](https://en.wikipedia.org/wiki/Instruments_(software)) app, which yields much more powerful insights compared to `time(1)`. For example, it can tell which Tart functions spent the most time on the CPU, thus allowing the Tart developers to further optimize these functions.
|
||||
|
||||
To use it, make sure that [Xcode](https://developer.apple.com/xcode/resources/) is installed. If you're installing Xcode for the first time on the machine, you'll need to launch it once and click the blue "Install" button. There's no need to choose any platforms except for the macOS.
|
||||
|
||||
Once done, you can create a CPU profile of `tart pull`:
|
||||
|
||||
```shell
|
||||
xctrace record --template "CPU Profiler" --target-stdout - --launch -- /opt/homebrew/bin/tart pull ghcr.io/cirruslabs/macos-tahoe-base:latest
|
||||
```
|
||||
|
||||
Now that `xctrace(1)` is running, you'll see the `tart pull`-related output first, and once finished, the following line will appear:
|
||||
|
||||
```
|
||||
Output file saved as: Launch_[...].trace
|
||||
```
|
||||
|
||||
To view this trace in the Instruments app, simply find this directory in Finder and double-click it. Instruments app will appear:
|
||||
|
||||

|
||||
|
||||
To send this trace, right-click its directory in Finder and choose "Compress [...]". This will result in a similarly named file with a `.zip` at the end, which can now be conveniently sent via email or uploaded.
|
||||
@@ -1,13 +1,31 @@
|
||||
{
|
||||
"originHash" : "6d48639bc0ea02002de0b4f38fe3fce0ddc9d174f2e56180c2ffcbedb7391ef8",
|
||||
"originHash" : "061dfe6cdf4e6dbf32b51c5e7023c4ae69726dcafb42a35b34e5489b0338c17f",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "antlr4",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/antlr/antlr4",
|
||||
"state" : {
|
||||
"branch" : "dev",
|
||||
"revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2"
|
||||
"revision" : "cc82115a4e7f53d71d9d905caa2c2dfa4da58899",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -19,22 +37,49 @@
|
||||
"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",
|
||||
"location" : "https://github.com/groue/Semaphore",
|
||||
"state" : {
|
||||
"revision" : "f1c4a0acabeb591068dea6cffdd39660b86dec28",
|
||||
"version" : "0.0.8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sentry-cocoa",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "ef4fec9dfb8dd5027b09a4a5c9362feafd118e1a",
|
||||
"version" : "8.24.0"
|
||||
"revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -51,17 +96,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "46989693916f56d1186bd59ac15124caef896560",
|
||||
"version" : "1.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-async-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "f05e450f0b909c0e80670a47516c4b9700b9e5da"
|
||||
"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" : "f504716c27d2e5d4144fa4794b12129301d17729",
|
||||
"version" : "1.0.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" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5",
|
||||
"version" : "1.5.4"
|
||||
"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,13 +208,22 @@
|
||||
"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",
|
||||
"location" : "https://github.com/fumoboy007/swift-retry",
|
||||
"state" : {
|
||||
"revision" : "9f133487ffc2ab4539688c29efe57bb1ba31d7b0",
|
||||
"version" : "0.2.3"
|
||||
"revision" : "df9d7b185d2e433147ec0083a73c257e665eea0d",
|
||||
"version" : "0.2.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -118,6 +235,24 @@
|
||||
"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",
|
||||
"location" : "https://github.com/jozefizso/swift-xattr",
|
||||
"state" : {
|
||||
"revision" : "f8605af7b3290dbb235fb182ec6e9035d0c8c3ac",
|
||||
"version" : "3.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftdate",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -132,8 +267,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/nicklockwood/SwiftFormat",
|
||||
"state" : {
|
||||
"revision" : "9df3b01f477163b33d5e63c5e2e5b9f946a49c56",
|
||||
"version" : "0.53.6"
|
||||
"revision" : "ab6844edb79a7b88dc6320e6cee0a0db7674dac3",
|
||||
"version" : "0.54.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -153,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,36 +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", branch: "dev"),
|
||||
.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.24.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,64 +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.
|
||||
Many companies are using Tart in their internal setups. Here are just a few of them:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
|
||||
<a href="https://atlassian.com/" target=_blank>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Atlassian.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Many companies are using Tart in their internal setups. Here are a few of them:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://ahrefs.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://krisp.ai/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||
<a href="https://www.figma.com/" target=_blank>
|
||||
<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://shape.dk/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/shape.png" height="65"/>
|
||||
<a href="https://krisp.ai/" target=_blank>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://suran.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
||||
<a href="https://testingbot.com/" target=_blank>
|
||||
<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://uphold.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Uphold.png" height="65"/>
|
||||
<a href="https://cirrus-ci.org/" target=_blank>
|
||||
<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/openai/tart/raw/main/Resources/Users/Expo.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
|
||||
|
||||
<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 |
@@ -2,22 +2,28 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>tart</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.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>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>Tart</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Tart</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.github.cirruslabs.tart</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>tart</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>UPW Tart</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>UPW Tart</string>
|
||||
<key>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: 1.1 MiB |
|
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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1,4 +0,0 @@
|
||||
If you'd like to highlight your use of Tart, please create a `456px` by `130px` logo and create a PR
|
||||
that adds it to `README.md` in alphabetical order. Don't forget to include a small description of your usage pattern.
|
||||
|
||||
You can refer to `Background.png` as a base for your logo.
|
||||
@@ -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"
|
||||
|
||||
@@ -8,13 +8,11 @@ struct Clone: AsyncParsableCommand {
|
||||
discussion: """
|
||||
Creates a local virtual machine by cloning either a remote or another local virtual machine.
|
||||
|
||||
Due to copy-on-write magic in Apple File System a cloned VM won't actually claim all the space right away.
|
||||
Only changes to a cloned disk will be written and claim new space. By default, Tart checks available capacity
|
||||
in Tart's home directory and checks if there is enough space for the worst possible scenario: when the whole disk
|
||||
will be modified.
|
||||
Due to copy-on-write magic in Apple File System, a cloned VM won't actually claim all the space right away.
|
||||
Only changes to a cloned disk will be written and claim new space. This also speeds up clones enormously.
|
||||
|
||||
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable. This might be helpful
|
||||
for use cases when the original image is very big and a workload is known to only modify a fraction of the cloned disk.
|
||||
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the cloned image
|
||||
to fit. This behaviour is called "automatic pruning" and can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -30,6 +28,12 @@ struct Clone: AsyncParsableCommand {
|
||||
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
|
||||
var concurrency: UInt = 4
|
||||
|
||||
@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")
|
||||
@@ -41,13 +45,13 @@ 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
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency)
|
||||
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
|
||||
}
|
||||
|
||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||
@@ -70,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,15 +10,18 @@ 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")
|
||||
var linux: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Disk size in Gb"))
|
||||
@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,6 +7,7 @@ fileprivate struct VMInfo: Encodable {
|
||||
let Name: String
|
||||
let Disk: Int
|
||||
let Size: Int
|
||||
let Accessed: String
|
||||
let Running: Bool
|
||||
let State: String
|
||||
}
|
||||
@@ -17,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."))
|
||||
@@ -38,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(), 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(), 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
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +49,10 @@ struct Login: AsyncParsableCommand {
|
||||
])
|
||||
|
||||
if !noValidate {
|
||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||
credentialsProviders: [credentialsProvider])
|
||||
|
||||
do {
|
||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||
credentialsProviders: [credentialsProvider])
|
||||
try await registry.ping()
|
||||
} catch {
|
||||
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
||||
@@ -63,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ struct Pull: AsyncParsableCommand {
|
||||
Pulls a virtual machine from a remote OCI-compatible registry. Supports authorization via Keychain (see "tart login --help"),
|
||||
Docker credential helpers defined in ~/.docker/config.json or via TART_REGISTRY_USERNAME/TART_REGISTRY_PASSWORD environment variables.
|
||||
|
||||
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image to fit via "tart prune".
|
||||
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
|
||||
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image
|
||||
to fit. This behaviour is called "automatic pruning" and can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -23,6 +23,9 @@ struct Pull: AsyncParsableCommand {
|
||||
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
|
||||
var concurrency: UInt = 4
|
||||
|
||||
@Flag(help: .hidden)
|
||||
var deduplicate: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
if concurrency < 1 {
|
||||
throw ValidationError("network concurrency cannot be less than 1")
|
||||
@@ -32,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
|
||||
@@ -43,6 +46,6 @@ struct Pull: AsyncParsableCommand {
|
||||
|
||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency)
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ struct Push: AsyncParsableCommand {
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
@Option(help: "network concurrency to use when pushing a local VM to the OCI-compatible registry")
|
||||
var concurrency: UInt = 4
|
||||
|
||||
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
|
||||
discussion: """
|
||||
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
|
||||
@@ -23,16 +26,22 @@ 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() {
|
||||
throw RuntimeError.VMIsRunning(localName)
|
||||
}
|
||||
|
||||
// Parse remote names supplied by the user
|
||||
let remoteNames = try remoteNames.map{
|
||||
@@ -73,7 +82,8 @@ struct Push: AsyncParsableCommand {
|
||||
registry: registry,
|
||||
references: references,
|
||||
chunkSizeMb: chunkSize,
|
||||
diskFormat: diskFormat
|
||||
concurrency: concurrency,
|
||||
labels: parseLabels()
|
||||
)
|
||||
// Populate the local cache (if requested)
|
||||
if populateCache {
|
||||
@@ -107,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 {
|
||||
|
||||
@@ -2,7 +2,7 @@ import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Rename: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Rename a VM")
|
||||
static var configuration = CommandConfiguration(abstract: "Rename a local VM")
|
||||
|
||||
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
@@ -17,10 +17,10 @@ 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 VM: \(name)")
|
||||
throw ValidationError("failed to rename a non-existent local VM: \(name)")
|
||||
}
|
||||
|
||||
if localStorage.exists(newName) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
struct Set: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
|
||||
@@ -13,20 +14,26 @@ 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
|
||||
|
||||
#if arch(arm64)
|
||||
@Flag(help: ArgumentHelp("Generate a new random serial number for the macOS VM."))
|
||||
#endif
|
||||
var randomSerial: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Replace the VM's disk contents with the disk contents at path.", valueName: "path"))
|
||||
var disk: String?
|
||||
|
||||
@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?
|
||||
|
||||
@@ -49,10 +56,31 @@ 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 {
|
||||
vmConfig.platform = Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: oldPlatform.hardwareModel)
|
||||
}
|
||||
#endif
|
||||
|
||||
try vmConfig.save(toURL: vmDir.configURL)
|
||||
|
||||
if let disk = disk {
|
||||
let temporaryDiskURL = try Config().tartTmpDir.appendingPathComponent("set-disk-\(UUID().uuidString)")
|
||||
|
||||
try FileManager.default.copyItem(atPath: disk, toPath: temporaryDiskURL.path())
|
||||
|
||||
_ = try FileManager.default.replaceItemAt(vmDir.diskURL, withItemAt: temporaryDiskURL)
|
||||
}
|
||||
|
||||
if diskSize != nil {
|
||||
try vmDir.resizeDisk(diskSize!)
|
||||
}
|
||||
@@ -61,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,12 +38,17 @@ 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()
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
let outputData = try outPipe.fileHandleForReading.readToEnd()
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
if let outputData = outputData {
|
||||
print(String(decoding: outputData, as: UTF8.self))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
struct LocalLayerCache {
|
||||
struct DigestInfo {
|
||||
let range: Range<Data.Index>
|
||||
let compressedDigest: String
|
||||
let uncompressedContentDigest: String?
|
||||
}
|
||||
|
||||
let name: String
|
||||
let deduplicatedBytes: UInt64
|
||||
let diskURL: URL
|
||||
|
||||
private let mappedDisk: Data
|
||||
private var digestToRange: [String: DigestInfo] = [:]
|
||||
private var offsetToRange: [UInt64: DigestInfo] = [:]
|
||||
|
||||
init?(_ name: String, _ deduplicatedBytes: UInt64, _ diskURL: URL, _ manifest: OCIManifest) throws {
|
||||
self.name = name
|
||||
self.deduplicatedBytes = deduplicatedBytes
|
||||
self.diskURL = diskURL
|
||||
|
||||
// mmap(2) the disk that contains the layers from the manifest
|
||||
self.mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
|
||||
// Record the ranges of the disk layers listed in the manifest
|
||||
var offset: UInt64 = 0
|
||||
|
||||
for layer in manifest.layers.filter({ $0.mediaType == diskV2MediaType }) {
|
||||
guard let uncompressedSize = layer.uncompressedSize() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let info = DigestInfo(
|
||||
range: Int(offset)..<Int(offset + uncompressedSize),
|
||||
compressedDigest: layer.digest,
|
||||
uncompressedContentDigest: layer.uncompressedContentDigest()!
|
||||
)
|
||||
self.digestToRange[layer.digest] = info
|
||||
self.offsetToRange[offset] = info
|
||||
|
||||
offset += uncompressedSize
|
||||
}
|
||||
}
|
||||
|
||||
func findInfo(digest: String, offsetHint: UInt64) -> DigestInfo? {
|
||||
// Layers can have the same digests, for example, empty ones. Let's use the offset hint to make a better guess.
|
||||
if let info = self.offsetToRange[offsetHint], info.compressedDigest == digest {
|
||||
return info
|
||||
}
|
||||
return self.digestToRange[digest]
|
||||
}
|
||||
|
||||
func subdata(_ range: Range<Data.Index>) -> Data {
|
||||
return self.mappedDisk.subdata(in: range)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@ struct ARPCache {
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
try process.run()
|
||||
|
||||
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||
}
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
@@ -60,10 +65,6 @@ struct ARPCache {
|
||||
terminationStatus: process.terminationStatus)
|
||||
}
|
||||
|
||||
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||
}
|
||||
|
||||
self.arpCommandOutput = arpCommandOutput
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
protocol Disk {
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer]
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer]
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws
|
||||
}
|
||||
|
||||
@@ -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, 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) 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()
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,104 @@
|
||||
import Foundation
|
||||
import Compression
|
||||
import System
|
||||
import Retry
|
||||
|
||||
class DiskV2: Disk {
|
||||
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||
private static let holeGranularityBytes = 64 * 1024
|
||||
private static let layerLimitBytes = 512 * 1024 * 1024
|
||||
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||
var pushedLayers: [OCIManifestLayer] = []
|
||||
// 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)] = []
|
||||
|
||||
// Open the disk file
|
||||
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
|
||||
// Compress the disk file as multiple individually decompressible streams,
|
||||
// each equal ``Self.layerLimitBytes`` bytes or slightly larger due to the
|
||||
// internal compressor's buffer
|
||||
var offset: UInt64 = 0
|
||||
// each equal ``Self.layerLimitBytes`` bytes or less due to LZ4 compression
|
||||
try await withThrowingTaskGroup(of: (Int, OCIManifestLayer).self) { group in
|
||||
for (index, data) in mappedDisk.chunks(ofCount: layerLimitBytes).enumerated() {
|
||||
// Respect the concurrency limit
|
||||
if index >= concurrency {
|
||||
if let (index, pushedLayer) = try await group.next() {
|
||||
pushedLayers.append((index, pushedLayer))
|
||||
}
|
||||
}
|
||||
|
||||
while let (compressedData, uncompressedSize, uncompressedDigest) = try compressNextLayerOfLimitBytesOrMore(mappedDisk: mappedDisk, offset: offset) {
|
||||
offset += uncompressedSize
|
||||
// Launch a disk layer pushing task
|
||||
group.addTask {
|
||||
let compressedData = try (data as NSData).compressed(using: .lz4) as Data
|
||||
let compressedDataDigest = Digest.hash(compressedData)
|
||||
|
||||
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||
try await retry(maxAttempts: 5) {
|
||||
if try await !registry.blobExists(compressedDataDigest) {
|
||||
_ = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb, digest: compressedDataDigest)
|
||||
}
|
||||
} recoverFromFailure: { error in
|
||||
if error is URLError {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
print("Attempting to re-try...")
|
||||
|
||||
pushedLayers.append(OCIManifestLayer(
|
||||
mediaType: diskV2MediaType,
|
||||
size: compressedData.count,
|
||||
digest: layerDigest,
|
||||
uncompressedSize: uncompressedSize,
|
||||
uncompressedContentDigest: uncompressedDigest
|
||||
))
|
||||
return .retry
|
||||
}
|
||||
|
||||
// Update progress using a relative value
|
||||
progress.completedUnitCount += Int64(uncompressedSize)
|
||||
return .throw
|
||||
}
|
||||
|
||||
// Update progress using a relative value
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
|
||||
return (index, OCIManifestLayer(
|
||||
mediaType: diskV2MediaType,
|
||||
size: compressedData.count,
|
||||
digest: compressedDataDigest,
|
||||
uncompressedSize: UInt64(data.count),
|
||||
uncompressedContentDigest: Digest.hash(data)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
for try await pushedLayer in group {
|
||||
pushedLayers.append(pushedLayer)
|
||||
}
|
||||
}
|
||||
|
||||
return pushedLayers
|
||||
return pushedLayers.sorted {
|
||||
$0.index < $1.index
|
||||
}.map {
|
||||
$0.pushedLayer
|
||||
}
|
||||
}
|
||||
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws {
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws {
|
||||
// Support resumable pulls
|
||||
let pullResumed = FileManager.default.fileExists(atPath: diskURL.path)
|
||||
|
||||
if !pullResumed && !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||
throw OCIError.FailedToCreateVmFile
|
||||
if !pullResumed {
|
||||
if deduplicate, let localLayerCache = localLayerCache {
|
||||
// Clone the local layer cache's disk and use it as a base, potentially
|
||||
// reducing the space usage since some blocks won't be written at all
|
||||
try FileManager.default.copyItem(at: localLayerCache.diskURL, to: diskURL)
|
||||
} else {
|
||||
// Otherwise create an empty disk
|
||||
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||
throw OCIError.FailedToCreateVmFile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the uncompressed disk size
|
||||
@@ -62,6 +118,15 @@ class DiskV2: Disk {
|
||||
try disk.truncate(atOffset: uncompressedDiskSize)
|
||||
try disk.close()
|
||||
|
||||
// Determine the file system block size
|
||||
var st = stat()
|
||||
if stat(diskURL.path, &st) == -1 {
|
||||
let details = Errno(rawValue: errno)
|
||||
|
||||
throw RuntimeError.PullFailed("failed to stat(2) disk \(diskURL.path): \(details)")
|
||||
}
|
||||
let fsBlockSize = UInt64(st.st_blksize)
|
||||
|
||||
// Concurrently fetch and decompress layers
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
var globalDiskWritingOffset: UInt64 = 0
|
||||
@@ -86,29 +151,52 @@ class DiskV2: Disk {
|
||||
// Launch a fetching and decompression task
|
||||
group.addTask {
|
||||
// No need to fetch and decompress anything if we've already done so
|
||||
if try pullResumed && Digest.hash(diskURL, offset: diskWritingOffset, size: uncompressedLayerSize) == uncompressedLayerContentDigest {
|
||||
if pullResumed {
|
||||
// do not check hash in the condition above to make it lazy e.g. only do expensive calculations if needed
|
||||
if try Digest.hash(diskURL, offset: diskWritingOffset, size: uncompressedLayerSize) == uncompressedLayerContentDigest {
|
||||
// Update the progress
|
||||
progress.completedUnitCount += Int64(diskLayer.size)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Open the disk file for writing
|
||||
let disk = try FileHandle(forWritingTo: diskURL)
|
||||
|
||||
// Also open the disk file for reading and verifying
|
||||
// its contents in case the local layer cache is used
|
||||
let rdisk: FileHandle? = if deduplicate && localLayerCache != nil {
|
||||
try FileHandle(forReadingFrom: diskURL)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
// Check if we already have this layer contents in the local layer cache,
|
||||
// or perhaps even on the cloned disk (when the deduplication is enabled)
|
||||
if let localLayerCache = localLayerCache,
|
||||
let localLayerInfo = localLayerCache.findInfo(digest: diskLayer.digest, offsetHint: diskWritingOffset),
|
||||
localLayerInfo.uncompressedContentDigest == uncompressedLayerContentDigest {
|
||||
if deduplicate && localLayerInfo.range.lowerBound == diskWritingOffset {
|
||||
// Do nothing, because the data is already on the disk that we've inherited from
|
||||
} else {
|
||||
// Fulfil the layer contents from the local blob cache
|
||||
let data = localLayerCache.subdata(localLayerInfo.range)
|
||||
_ = try zeroSkippingWrite(disk, rdisk, fsBlockSize, diskWritingOffset, data)
|
||||
}
|
||||
|
||||
try disk.close()
|
||||
|
||||
if let rdisk = rdisk {
|
||||
try rdisk.close()
|
||||
}
|
||||
|
||||
// Update the progress
|
||||
progress.completedUnitCount += Int64(diskLayer.size)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Open the disk file
|
||||
let disk = try FileHandle(forWritingTo: diskURL)
|
||||
|
||||
// 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 diskWritingOffset = diskWritingOffset
|
||||
|
||||
// Pull and decompress a single layer into the specific offset on disk
|
||||
@@ -117,27 +205,38 @@ class DiskV2: Disk {
|
||||
return
|
||||
}
|
||||
|
||||
for chunk in data.chunks(ofCount: holeGranularityBytes) {
|
||||
// Only write chunks that are not zero
|
||||
if chunk != zeroChunk {
|
||||
try disk.seek(toOffset: diskWritingOffset)
|
||||
disk.write(chunk)
|
||||
}
|
||||
|
||||
diskWritingOffset += UInt64(chunk.count)
|
||||
}
|
||||
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()
|
||||
|
||||
try disk.close()
|
||||
|
||||
if let rdisk = rdisk {
|
||||
try rdisk.close()
|
||||
}
|
||||
}
|
||||
|
||||
globalDiskWritingOffset += uncompressedLayerSize
|
||||
@@ -145,44 +244,50 @@ class DiskV2: Disk {
|
||||
}
|
||||
}
|
||||
|
||||
private static func compressNextLayerOfLimitBytesOrMore(mappedDisk: Data, offset: UInt64) throws -> (Data, UInt64, String)? {
|
||||
var compressedData = Data()
|
||||
var bytesRead: UInt64 = 0
|
||||
let digest = Digest()
|
||||
private static func zeroSkippingWrite(_ disk: FileHandle, _ rdisk: FileHandle?, _ fsBlockSize: UInt64, _ offset: UInt64, _ data: Data) throws -> UInt64 {
|
||||
var offset = offset
|
||||
|
||||
// Create a compressing filter that we will terminate upon
|
||||
// reaching ``Self.layerLimitBytes`` of compressed data
|
||||
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: bufferSizeBytes) { (length: Int) -> Data? in
|
||||
if compressedData.count >= Self.layerLimitBytes {
|
||||
return nil
|
||||
for chunk in data.chunks(ofCount: holeGranularityBytes) {
|
||||
// If the local layer cache is used, only write chunks that differ
|
||||
// since the base disk can contain anything at any position
|
||||
if let rdisk = rdisk {
|
||||
// F_PUNCHHOLE requires the holes to be aligned to file system block boundaries
|
||||
let isHoleAligned = (offset % fsBlockSize) == 0 && (UInt64(chunk.count) % fsBlockSize) == 0
|
||||
|
||||
if isHoleAligned && chunk == zeroChunk {
|
||||
var arg = fpunchhole_t(fp_flags: 0, reserved: 0, fp_offset: off_t(offset), fp_length: off_t(chunk.count))
|
||||
|
||||
if fcntl(disk.fileDescriptor, F_PUNCHHOLE, &arg) == -1 {
|
||||
let details = Errno(rawValue: errno)
|
||||
|
||||
throw RuntimeError.PullFailed("failed to punch hole: \(details)")
|
||||
}
|
||||
} else {
|
||||
try rdisk.seek(toOffset: offset)
|
||||
let actualContentsOnDisk = try rdisk.read(upToCount: chunk.count)
|
||||
|
||||
if chunk != actualContentsOnDisk {
|
||||
try disk.seek(toOffset: offset)
|
||||
try disk.write(contentsOf: chunk)
|
||||
}
|
||||
}
|
||||
|
||||
offset += UInt64(chunk.count)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
let readFromByte = Int(offset + bytesRead)
|
||||
|
||||
let numBytesToRead = min(mappedDisk.count - readFromByte, bufferSizeBytes)
|
||||
if numBytesToRead == 0 {
|
||||
return nil
|
||||
// Otherwise, only write chunks that are not zero
|
||||
// since the base disk is created from scratch and
|
||||
// is zeroed via truncate(2)
|
||||
if chunk != zeroChunk {
|
||||
try disk.seek(toOffset: offset)
|
||||
try disk.write(contentsOf: chunk)
|
||||
}
|
||||
|
||||
let uncompressedChunk = mappedDisk.subdata(in: readFromByte ..< (readFromByte + numBytesToRead))
|
||||
|
||||
bytesRead += UInt64(uncompressedChunk.count)
|
||||
digest.update(uncompressedChunk)
|
||||
|
||||
return uncompressedChunk
|
||||
offset += UInt64(chunk.count)
|
||||
}
|
||||
|
||||
// Retrieve compressed data chunks, but normally no more than ``Self.layerLimitBytes`` bytes
|
||||
while let compressedChunk = try compressingFilter.readData(ofLength: Self.bufferSizeBytes) {
|
||||
compressedData.append(compressedChunk)
|
||||
}
|
||||
|
||||
// Nothing was read this time from the disk,
|
||||
// signal that to the consumer
|
||||
if bytesRead == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (compressedData, bytesRead, digest.finalize())
|
||||
return offset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -78,7 +85,7 @@ struct OCIManifestConfig: Codable, Equatable {
|
||||
var digest: String
|
||||
}
|
||||
|
||||
struct OCIManifestLayer: Codable, Equatable {
|
||||
struct OCIManifestLayer: Codable, Equatable, Hashable {
|
||||
var mediaType: String
|
||||
var size: Int
|
||||
var digest: String
|
||||
@@ -113,6 +120,14 @@ struct OCIManifestLayer: Codable, Equatable {
|
||||
func uncompressedContentDigest() -> String? {
|
||||
annotations?[uncompressedContentDigestAnnotation]
|
||||
}
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
return lhs.digest == rhs.digest
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(digest)
|
||||
}
|
||||
}
|
||||
|
||||
struct Descriptor: Equatable {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
// Generated from Reference.g4 by ANTLR 4.13.2
|
||||
|
||||
import Antlr4
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
// Generated from Reference.g4 by ANTLR 4.13.2
|
||||
import Antlr4
|
||||
|
||||
open class ReferenceLexer: Lexer {
|
||||
@@ -49,7 +49,7 @@ open class ReferenceLexer: Lexer {
|
||||
|
||||
public
|
||||
required init(_ input: CharStream) {
|
||||
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
||||
RuntimeMetaData.checkVersion("4.13.2", RuntimeMetaData.VERSION)
|
||||
super.init(input)
|
||||
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
// Generated from Reference.g4 by ANTLR 4.13.2
|
||||
import Antlr4
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
// Generated from Reference.g4 by ANTLR 4.13.2
|
||||
import Antlr4
|
||||
|
||||
open class ReferenceParser: Parser {
|
||||
@@ -41,7 +41,7 @@ open class ReferenceParser: Parser {
|
||||
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
||||
|
||||
override open
|
||||
func getGrammarFileName() -> String { return "java-escape" }
|
||||
func getGrammarFileName() -> String { return "Reference.g4" }
|
||||
|
||||
override open
|
||||
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
|
||||
@@ -60,7 +60,7 @@ open class ReferenceParser: Parser {
|
||||
|
||||
override public
|
||||
init(_ input:TokenStream) throws {
|
||||
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
||||
RuntimeMetaData.checkVersion("4.13.2", RuntimeMetaData.VERSION)
|
||||
try super.init(input)
|
||||
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
|
||||
}
|
||||
@@ -460,7 +460,7 @@ open class ReferenceParser: Parser {
|
||||
setState(63)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
if ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
||||
if (((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
|
||||
setState(62)
|
||||
try separator()
|
||||
|
||||
@@ -611,7 +611,7 @@ open class ReferenceParser: Parser {
|
||||
setState(84)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
while ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
||||
while (((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
|
||||
setState(79)
|
||||
try separator()
|
||||
setState(80)
|
||||
@@ -664,7 +664,7 @@ open class ReferenceParser: Parser {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(87)
|
||||
_la = try _input.LA(1)
|
||||
if (!((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
|
||||
if (!(((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0))) {
|
||||
try _errHandler.recoverInline(self)
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Foundation
|
||||
import Algorithms
|
||||
import AsyncAlgorithms
|
||||
|
||||
enum RegistryError: Error {
|
||||
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
|
||||
@@ -10,6 +9,7 @@ enum RegistryError: Error {
|
||||
}
|
||||
|
||||
enum HTTPMethod: String {
|
||||
case HEAD = "HEAD"
|
||||
case GET = "GET"
|
||||
case POST = "POST"
|
||||
case PUT = "PUT"
|
||||
@@ -20,21 +20,35 @@ enum HTTPCode: Int {
|
||||
case Ok = 200
|
||||
case Created = 201
|
||||
case Accepted = 202
|
||||
case PartialContent = 206
|
||||
case Unauthorized = 401
|
||||
case NotFound = 404
|
||||
}
|
||||
|
||||
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
|
||||
@@ -112,11 +126,11 @@ class Registry {
|
||||
return host
|
||||
}
|
||||
|
||||
init(urlComponents: URLComponents,
|
||||
init(baseURL: URL,
|
||||
namespace: String,
|
||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
baseURL = urlComponents.url!
|
||||
self.baseURL = baseURL
|
||||
self.namespace = namespace
|
||||
self.credentialsProviders = credentialsProviders
|
||||
}
|
||||
@@ -130,7 +144,17 @@ class Registry {
|
||||
let proto = insecure ? "http" : "https"
|
||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||
|
||||
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProviders: credentialsProviders)
|
||||
guard let baseURL = baseURLComponents.url else {
|
||||
var hint = ""
|
||||
|
||||
if host.hasPrefix("http://") || host.hasPrefix("https://") {
|
||||
hint += ", make sure that it doesn't start with http:// or https://"
|
||||
}
|
||||
|
||||
throw RuntimeError.ImproperlyFormattedHost(host, hint)
|
||||
}
|
||||
|
||||
try self.init(baseURL: baseURL, namespace: namespace, credentialsProviders: credentialsProviders)
|
||||
}
|
||||
|
||||
func ping() async throws {
|
||||
@@ -148,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)
|
||||
@@ -159,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)
|
||||
@@ -179,19 +203,19 @@ class Registry {
|
||||
return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)!
|
||||
}
|
||||
|
||||
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
|
||||
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0, digest: String? = nil) async throws -> String {
|
||||
// Initiate a blob upload
|
||||
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
|
||||
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
|
||||
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||
|
||||
let digest = Digest.hash(fromData)
|
||||
let digest = digest ?? Digest.hash(fromData)
|
||||
|
||||
if chunkSizeMb == 0 {
|
||||
// monolithic upload
|
||||
@@ -206,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
|
||||
}
|
||||
@@ -229,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
|
||||
@@ -239,10 +263,35 @@ class Registry {
|
||||
return digest
|
||||
}
|
||||
|
||||
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 blobExists(_ digest: String) async throws -> Bool {
|
||||
let (data, response) = try await dataRequest(.HEAD, endpointURL("\(namespace)/blobs/\(digest)"))
|
||||
|
||||
switch response.statusCode {
|
||||
case HTTPCode.Ok.rawValue:
|
||||
return true
|
||||
case HTTPCode.NotFound.rawValue:
|
||||
return false
|
||||
default:
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "checking blob", code: response.statusCode, details: data.asTextPreview())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -282,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 {
|
||||
@@ -302,12 +351,11 @@ class Registry {
|
||||
request.httpBody = body
|
||||
}
|
||||
|
||||
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||
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)
|
||||
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile, doAuth: doAuth)
|
||||
}
|
||||
|
||||
return (channel, response)
|
||||
@@ -367,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))
|
||||
@@ -381,18 +429,24 @@ 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) 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 let (name, value) = await authenticationKeeper.header() {
|
||||
request.addValue(value, forHTTPHeaderField: name)
|
||||
if doAuth {
|
||||
if let (name, value) = await authenticationKeeper.header() {
|
||||
request.addValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
}
|
||||
|
||||
request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class PIDLock {
|
||||
if fd == -1 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError.PIDLockFailed("failed to open lock file \(url): \(details)")
|
||||
throw RuntimeError.PIDLockMissing("failed to open lock file \(url): \(details)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import Foundation
|
||||
import System
|
||||
|
||||
extension URL {
|
||||
func accessDate() throws -> Date {
|
||||
@@ -13,7 +14,9 @@ extension URL {
|
||||
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
||||
let ret = utimes(path, times)
|
||||
if ret != 0 {
|
||||
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(ret.explanation())")
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(details)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import XAttr
|
||||
|
||||
extension URL: Prunable {
|
||||
var url: URL {
|
||||
@@ -13,7 +14,31 @@ extension URL: Prunable {
|
||||
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
|
||||
}
|
||||
|
||||
func deduplicatedSizeBytes() throws -> Int {
|
||||
let values = try resourceValues(forKeys: [.totalFileAllocatedSizeKey, .mayShareFileContentKey])
|
||||
// make sure the file's origin file is there and duplication works
|
||||
if values.mayShareFileContent == true {
|
||||
return Int(deduplicatedBytes())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try resourceValues(forKeys: [.totalFileSizeKey]).totalFileSize!
|
||||
}
|
||||
|
||||
func setDeduplicatedBytes(_ size: UInt64) {
|
||||
let data = "\(size)".data(using: .utf8)!
|
||||
try! self.setExtendedAttribute(name: "run.tart.deduplicated-bytes", value: data)
|
||||
}
|
||||
|
||||
func deduplicatedBytes() -> UInt64 {
|
||||
guard let data = try? self.extendedAttributeValue(forName: "run.tart.deduplicated-bytes") else {
|
||||
return 0
|
||||
}
|
||||
if let strValue = String(data: data, encoding: .utf8) {
|
||||
return UInt64(strValue) ?? 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +46,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = [],
|
||||
suspendable: Bool = false,
|
||||
audio: Bool = true
|
||||
nested: Bool = false,
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true,
|
||||
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)
|
||||
@@ -64,7 +70,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable,
|
||||
audio: audio
|
||||
nested: nested,
|
||||
audio: audio,
|
||||
clipboard: clipboard,
|
||||
sync: sync,
|
||||
caching: caching,
|
||||
noTrackpad: noTrackpad,
|
||||
noPointer: noPointer,
|
||||
noKeyboard: noKeyboard
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -92,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)
|
||||
@@ -111,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()
|
||||
@@ -138,6 +147,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
vmDir: VMDirectory,
|
||||
ipswURL: URL,
|
||||
diskSizeGB: UInt16,
|
||||
diskFormat: DiskImageFormat = .raw,
|
||||
network: Network = NetworkShared(),
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
@@ -170,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))
|
||||
@@ -219,45 +230,71 @@ 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 {
|
||||
try await sema.waitUnlessCancelled()
|
||||
do {
|
||||
try await sema.waitUnlessCancelled()
|
||||
} catch is CancellationError {
|
||||
// Triggered by "tart stop", Ctrl+C, or closing the
|
||||
// VM window, so shut down the VM gracefully below.
|
||||
}
|
||||
|
||||
if Task.isCancelled {
|
||||
try await stop()
|
||||
if (self.virtualMachine.state == VZVirtualMachine.State.running) {
|
||||
print("Stopping VM...")
|
||||
try await stop()
|
||||
}
|
||||
}
|
||||
|
||||
try await network.stop()
|
||||
}
|
||||
|
||||
@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()
|
||||
@@ -283,7 +320,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||
serialPorts: [VZSerialPortConfiguration],
|
||||
suspendable: Bool = false,
|
||||
audio: Bool = true
|
||||
nested: Bool = false,
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true,
|
||||
sync: VZDiskImageSynchronizationMode = .full,
|
||||
caching: VZDiskImageCachingMode? = nil,
|
||||
noTrackpad: Bool = false,
|
||||
noPointer: Bool = false,
|
||||
noKeyboard: Bool = false
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -295,29 +339,48 @@ 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)]
|
||||
|
||||
// Audio
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
|
||||
if audio && !suspendable {
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
|
||||
|
||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
|
||||
|
||||
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
|
||||
configuration.audioDevices = [soundDeviceConfiguration]
|
||||
} else {
|
||||
// just a null speaker
|
||||
soundDeviceConfiguration.streams = [VZVirtioSoundDeviceOutputStreamConfiguration()]
|
||||
}
|
||||
|
||||
configuration.audioDevices = [soundDeviceConfiguration]
|
||||
|
||||
// Keyboard and mouse
|
||||
if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
|
||||
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
|
||||
@@ -328,19 +391,30 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
return vio
|
||||
}
|
||||
|
||||
// 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: .full) :
|
||||
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
|
||||
|
||||
var device: VZStorageDeviceConfiguration
|
||||
if #available(macOS 14, *), vmConfig.os == .linux {
|
||||
device = VZNVMExpressControllerDeviceConfiguration(attachment: attachment)
|
||||
} else {
|
||||
device = VZVirtioBlockDeviceConfiguration(attachment: attachment)
|
||||
// Clipboard sharing via Spice agent
|
||||
if clipboard {
|
||||
let spiceAgentConsoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||
let spiceAgentPort = VZVirtioConsolePortConfiguration()
|
||||
spiceAgentPort.name = VZSpiceAgentPortAttachment.spiceAgentPortName
|
||||
let spiceAgentPortAttachment = VZSpiceAgentPortAttachment()
|
||||
spiceAgentPortAttachment.sharesClipboard = true
|
||||
spiceAgentPort.attachment = spiceAgentPortAttachment
|
||||
spiceAgentConsoleDevice.ports[0] = spiceAgentPort
|
||||
configuration.consoleDevices.append(spiceAgentConsoleDevice)
|
||||
}
|
||||
var devices: [VZStorageDeviceConfiguration] = [device]
|
||||
|
||||
// Storage
|
||||
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)
|
||||
configuration.storageDevices = devices
|
||||
|
||||
@@ -359,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,5 +1,8 @@
|
||||
import Compression
|
||||
import Foundation
|
||||
import Sentry
|
||||
import OpenTelemetryApi
|
||||
|
||||
let legacyDiskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
||||
|
||||
enum OCIError: Error {
|
||||
case ShouldBeExactlyOneLayer
|
||||
@@ -10,18 +13,7 @@ enum OCIError: Error {
|
||||
}
|
||||
|
||||
extension VMDirectory {
|
||||
private static let bufferSizeBytes = 64 * 1024 * 1024
|
||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||
|
||||
func pullFromRegistry(registry: Registry, reference: String, concurrency: UInt) async throws {
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
let (manifest, _) = try await registry.pullManifest(reference: reference)
|
||||
|
||||
return try await pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency)
|
||||
}
|
||||
|
||||
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt) async throws {
|
||||
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws {
|
||||
// Pull VM's config file layer and re-serialize it into a config file
|
||||
let configLayers = manifest.layers.filter {
|
||||
$0.mediaType == configMediaType
|
||||
@@ -34,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)...")
|
||||
@@ -61,7 +52,19 @@ extension VMDirectory {
|
||||
let progress = Progress(totalUnitCount: diskCompressedSize)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL, concurrency: concurrency, progress: progress)
|
||||
do {
|
||||
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)")
|
||||
}
|
||||
|
||||
if deduplicate, let llc = localLayerCache {
|
||||
// set custom attribute to remember deduplicated bytes
|
||||
diskURL.setDeduplicatedBytes(llc.deduplicatedBytes)
|
||||
}
|
||||
|
||||
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
||||
defaultLogger.appendNewLine("pulling NVRAM...")
|
||||
@@ -77,16 +80,23 @@ 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()
|
||||
|
||||
// Serialize VM's manifest to enable better deduplication on subsequent "tart pull"'s
|
||||
try manifest.toJSON().write(to: manifestURL)
|
||||
}
|
||||
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String) async throws -> RemoteName {
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, concurrency: UInt, labels: [String: String] = [:]) async throws -> RemoteName {
|
||||
var layers = Array<OCIManifestLayer>()
|
||||
|
||||
// Read VM's config and push it as blob
|
||||
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)
|
||||
@@ -99,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, progress: progress))
|
||||
case "v2":
|
||||
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, 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...")
|
||||
@@ -116,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),
|
||||
|
||||
@@ -23,6 +23,12 @@ struct VMDirectory: Prunable {
|
||||
var stateURL: URL {
|
||||
baseURL.appendingPathComponent("state.vzvmsave")
|
||||
}
|
||||
var manifestURL: URL {
|
||||
baseURL.appendingPathComponent("manifest.json")
|
||||
}
|
||||
var controlSocketURL: URL {
|
||||
URL(fileURLWithPath: "control.sock", relativeTo: baseURL)
|
||||
}
|
||||
|
||||
var explicitlyPulledMark: URL {
|
||||
baseURL.appendingPathComponent(".explicitly-pulled")
|
||||
@@ -136,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))
|
||||
@@ -155,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()
|
||||
|
||||
@@ -179,6 +283,14 @@ struct VMDirectory: Prunable {
|
||||
try allocatedSizeBytes() / 1000 / 1000 / 1000
|
||||
}
|
||||
|
||||
func deduplicatedSizeBytes() throws -> Int {
|
||||
try configURL.deduplicatedSizeBytes() + diskURL.deduplicatedSizeBytes() + nvramURL.deduplicatedSizeBytes()
|
||||
}
|
||||
|
||||
func deduplicatedSizeGB() throws -> Int {
|
||||
try deduplicatedSizeBytes() / 1000 / 1000 / 1000
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||
}
|
||||
@@ -187,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)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ class VMStorageHelper {
|
||||
private static func missingVMWrap<R: Any>(_ name: String, closure: () throws -> R) throws -> R {
|
||||
do {
|
||||
return try closure()
|
||||
} catch RuntimeError.PIDLockMissing {
|
||||
throw RuntimeError.VMDoesNotExist(name: name)
|
||||
} catch {
|
||||
if error.isFileNotFound() {
|
||||
throw RuntimeError.VMDoesNotExist(name: name)
|
||||
@@ -47,6 +49,7 @@ extension Error {
|
||||
}
|
||||
|
||||
enum RuntimeError : Error {
|
||||
case Generic(_ message: String)
|
||||
case VMConfigurationError(_ message: String)
|
||||
case VMDoesNotExist(name: String)
|
||||
case VMMissingFiles(_ message: String)
|
||||
@@ -55,19 +58,27 @@ enum RuntimeError : Error {
|
||||
case VMAlreadyRunning(_ message: String)
|
||||
case NoIPAddressFound(_ message: String)
|
||||
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)
|
||||
case FailedToParseRemoteName(_ message: String)
|
||||
case VMTerminationFailed(_ message: String)
|
||||
case ImproperlyFormattedHost(_ host: String, _ hint: String)
|
||||
case InvalidCredentials(_ message: String)
|
||||
case VMDirectoryAlreadyInitialized(_ message: String)
|
||||
case ExportFailed(_ message: String)
|
||||
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 {
|
||||
@@ -77,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):
|
||||
@@ -93,16 +106,26 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return message
|
||||
case .DiskAlreadyInUse(let message):
|
||||
return message
|
||||
case .FailedToOpenBlockDevice(let path, let explanation):
|
||||
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):
|
||||
return message
|
||||
case .PIDLockMissing(let message):
|
||||
return message
|
||||
case .FailedToParseRemoteName(let cause):
|
||||
return "failed to parse remote name: \(cause)"
|
||||
case .VMTerminationFailed(let message):
|
||||
return message
|
||||
case .ImproperlyFormattedHost(let host, let hint):
|
||||
return "improperly formatted host \"\(host)\" was provided\(hint)"
|
||||
case .InvalidCredentials(let message):
|
||||
return message
|
||||
case .VMDirectoryAlreadyInitialized(let message):
|
||||
@@ -115,10 +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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,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)
|
||||
@@ -66,7 +70,7 @@ class VMStorageLocal: PrunableStorage {
|
||||
}
|
||||
|
||||
func prunables() throws -> [Prunable] {
|
||||
try list().map { (_, vmDir) in vmDir }
|
||||
try list().map { (_, vmDir) in vmDir }.filter { try !$0.running() }
|
||||
}
|
||||
|
||||
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -113,6 +117,10 @@ class VMStorageOCI: PrunableStorage {
|
||||
continue
|
||||
}
|
||||
|
||||
// Split the relative VM's path at the last component
|
||||
// and figure out which character should be used
|
||||
// to join them together, either ":" for tags or
|
||||
// "@" for hashes
|
||||
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
||||
var name: String
|
||||
|
||||
@@ -123,6 +131,9 @@ class VMStorageOCI: PrunableStorage {
|
||||
name = parts.joined(separator: "@")
|
||||
}
|
||||
|
||||
// Remove the percent-encoding, if any
|
||||
name = percentDecode(name)
|
||||
|
||||
result.append((name, vmDir, isSymlink))
|
||||
}
|
||||
|
||||
@@ -133,10 +144,11 @@ class VMStorageOCI: PrunableStorage {
|
||||
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
||||
}
|
||||
|
||||
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt) async throws {
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["imageName": name.description], key: "OCI")
|
||||
}
|
||||
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
|
||||
key: "oci.image-name",
|
||||
value: .string(name.description)
|
||||
)
|
||||
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
@@ -170,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
|
||||
|
||||
@@ -189,18 +208,32 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await retry(maxAttempts: 5, backoff: .exponentialWithFullJitter(baseDelay: .seconds(5), maxDelay: .seconds(60))) {
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency)
|
||||
} recoverFromFailure: { error in
|
||||
print("Error: \(error.localizedDescription)")
|
||||
print("Attempting to re-try...")
|
||||
try await retry(maxAttempts: 5) {
|
||||
// Choose the best base image which has the most deduplication ratio
|
||||
let localLayerCache = try await chooseLocalLayerCache(name, manifest, registry)
|
||||
|
||||
return .retry
|
||||
if let llc = localLayerCache {
|
||||
let deduplicatedHuman = ByteCountFormatter.string(fromByteCount: Int64(llc.deduplicatedBytes), countStyle: .file)
|
||||
|
||||
if deduplicate {
|
||||
defaultLogger.appendNewLine("found an image \(llc.name) that will allow us to deduplicate \(deduplicatedHuman), using it as a base...")
|
||||
} else {
|
||||
defaultLogger.appendNewLine("found an image \(llc.name) that will allow us to avoid fetching \(deduplicatedHuman), will try use it...")
|
||||
}
|
||||
}
|
||||
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache, deduplicate: deduplicate)
|
||||
} recoverFromFailure: { error in
|
||||
if error is URLError {
|
||||
print("Error pulling image: \"\(error.localizedDescription)\", attempting to re-try...")
|
||||
|
||||
return .retry
|
||||
}
|
||||
|
||||
return .throw
|
||||
}
|
||||
try move(digestName, from: tmpVMDir)
|
||||
transaction.finish()
|
||||
}, onCancel: {
|
||||
transaction.finish(status: SentrySpanStatus.cancelled)
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
} else {
|
||||
@@ -215,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 {
|
||||
@@ -233,13 +269,66 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
try gc()
|
||||
}
|
||||
|
||||
func chooseLocalLayerCache(_ name: RemoteName, _ manifest: OCIManifest, _ registry: Registry) async throws -> LocalLayerCache? {
|
||||
// Establish a closure that will calculate how much bytes
|
||||
// we'll deduplicate if we re-use the given manifest
|
||||
let target = Swift.Set(manifest.layers)
|
||||
|
||||
let calculateDeduplicatedBytes = { (manifest: OCIManifest) -> UInt64 in
|
||||
target.intersection(manifest.layers).map({ UInt64($0.size) }).reduce(0, +)
|
||||
}
|
||||
|
||||
// Load OCI VM images and their manifests (if present)
|
||||
var candidates: [(name: String, vmDir: VMDirectory, manifest: OCIManifest, deduplicatedBytes: UInt64)] = []
|
||||
|
||||
for (name, vmDir, isSymlink) in try list() {
|
||||
if isSymlink {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let manifestJSON = try? Data(contentsOf: vmDir.manifestURL) else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let manifest = try? OCIManifest(fromJSON: manifestJSON) else {
|
||||
continue
|
||||
}
|
||||
|
||||
candidates.append((name, vmDir, manifest, calculateDeduplicatedBytes(manifest)))
|
||||
}
|
||||
|
||||
// Previously we haven't stored the OCI VM image manifests, but still fetched the VM image manifest if
|
||||
// what the user was trying to pull was a tagged image, and we already had that image in the OCI VM cache
|
||||
//
|
||||
// Keep supporting this behavior for backwards comaptibility, but only communicate
|
||||
// with the registry if we haven't already retrieved the manifest for that OCI VM image.
|
||||
if name.reference.type == .Tag,
|
||||
let vmDir = try? open(name),
|
||||
let digest = try? digest(name),
|
||||
try !candidates.contains(where: {try $0.manifest.digest() == digest}),
|
||||
let (manifest, _) = try? await registry.pullManifest(reference: digest) {
|
||||
candidates.append((name.description, vmDir, manifest, calculateDeduplicatedBytes(manifest)))
|
||||
}
|
||||
|
||||
// Now, find the best match based on how many bytes we'll deduplicate
|
||||
let choosen = candidates.filter {
|
||||
$0.deduplicatedBytes > 1024 * 1024 * 1024 // save at least 1GB
|
||||
}.max { left, right in
|
||||
return left.deduplicatedBytes < right.deduplicatedBytes
|
||||
}
|
||||
|
||||
return try choosen.flatMap({ choosen in
|
||||
try LocalLayerCache(choosen.name, choosen.deduplicatedBytes, choosen.vmDir.diskURL, choosen.manifest)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
func appendingRemoteName(_ name: RemoteName) -> URL {
|
||||
var result: URL = self
|
||||
|
||||
for pathComponent in (name.host + "/" + name.namespace + "/" + name.reference.value).split(separator: "/") {
|
||||
for pathComponent in (percentEncode(name.host) + "/" + name.namespace + "/" + name.reference.value).split(separator: "/") {
|
||||
result = result.appendingPathComponent(String(pathComponent))
|
||||
}
|
||||
|
||||
@@ -247,6 +336,23 @@ extension URL {
|
||||
}
|
||||
|
||||
func appendingHost(_ name: RemoteName) -> URL {
|
||||
self.appendingPathComponent(name.host, isDirectory: true)
|
||||
self.appendingPathComponent(percentEncode(name.host), isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
// Work around a pretty inane Swift's URL behavior where calling
|
||||
// appendingPathComponent() or deletingLastPathComponent() on a
|
||||
// URL like URL(filePath: "example.com:8080") (note the "filePath")
|
||||
// will flip its isFileURL from "true" to "false" and discard its
|
||||
// absolute path infromation (if any).
|
||||
//
|
||||
// The same kind of operations won't do anything to a URL like
|
||||
// URL(filePath: "127.0.0.1:8080"), which makes things even more
|
||||
// ridiculous.
|
||||
private func percentEncode(_ s: String) -> String {
|
||||
return s.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: ":").inverted)!
|
||||
}
|
||||
|
||||
private func percentDecode(_ s: String) -> String {
|
||||
s.removingPercentEncoding!
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class FullFledgedVNC: VNC {
|
||||
vnc.start()
|
||||
}
|
||||
|
||||
func waitForURL() async throws -> URL {
|
||||
func waitForURL(netBridged: Bool) async throws -> URL {
|
||||
while true {
|
||||
// Port is 0 shortly after start(),
|
||||
// but will be initialized later
|
||||
|
||||
@@ -9,9 +9,9 @@ class ScreenSharingVNC: VNC {
|
||||
self.vmConfig = vmConfig
|
||||
}
|
||||
|
||||
func waitForURL() async throws -> URL {
|
||||
func waitForURL(netBridged: Bool) async throws -> URL {
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
let ip = try await IP.resolveIP(vmMACAddress, secondsToWait: 60)
|
||||
let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: netBridged ? .arp : .dhcp, secondsToWait: 60)
|
||||
|
||||
if let ip = ip {
|
||||
return URL(string: "vnc://\(ip)")!
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
protocol VNC {
|
||||
func waitForURL() async throws -> URL
|
||||
func waitForURL(netBridged: Bool) async throws -> URL
|
||||
func stop() throws
|
||||
}
|
||||
|
||||