Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a336965140 | ||
|
|
6d1d8a3773 | ||
|
|
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 |
@@ -1,161 +0,0 @@
|
||||
use_compute_credits: true
|
||||
|
||||
task:
|
||||
name: Test
|
||||
alias: test
|
||||
persistent_worker:
|
||||
labels:
|
||||
name: dev-mini
|
||||
resources:
|
||||
tart-vms: 1
|
||||
build_script:
|
||||
- swift build
|
||||
test_script:
|
||||
- swift test
|
||||
integration_test_script:
|
||||
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
|
||||
# Run integration tests
|
||||
- cd integration-tests
|
||||
- python3 -m venv --symlinks venv
|
||||
- source venv/bin/activate
|
||||
- pip install -r requirements.txt
|
||||
- pytest --verbose --junit-xml=pytest-junit.xml
|
||||
pytest_junit_result_artifacts:
|
||||
path: "integration-tests/pytest-junit.xml"
|
||||
format: junit
|
||||
|
||||
task:
|
||||
name: Markdown Lint
|
||||
only_if: $CIRRUS_BRANCH != 'gh-pages' && changesInclude('**.md')
|
||||
container:
|
||||
image: node:latest
|
||||
install_script: npm install -g markdownlint-cli
|
||||
lint_script: markdownlint --config=docs/.markdownlint.yml docs/
|
||||
|
||||
task:
|
||||
name: Lint
|
||||
alias: lint
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-runner:sequoia
|
||||
lint_script:
|
||||
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
||||
always:
|
||||
swiftformat_report_artifacts:
|
||||
path: swiftformat.json
|
||||
format: swiftformat
|
||||
|
||||
task:
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
env:
|
||||
matrix:
|
||||
BUILD_ARCH: arm64
|
||||
BUILD_ARCH: x86_64
|
||||
name: Build ($BUILD_ARCH)
|
||||
alias: build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-runner:sequoia
|
||||
build_script: swift build --arch $BUILD_ARCH --product tart
|
||||
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||
binary_artifacts:
|
||||
path: .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||
|
||||
task:
|
||||
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
||||
name: Release (Dry Run)
|
||||
depends_on:
|
||||
- lint
|
||||
- build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-runner:sequoia
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||
setup_script:
|
||||
- cd $HOME
|
||||
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
||||
- security create-keychain -p password101 build.keychain
|
||||
- security default-keychain -s build.keychain
|
||||
- security unlock-keychain -p password101 build.keychain
|
||||
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
|
||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
||||
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||
install_script:
|
||||
- brew install go
|
||||
- brew install mitchellh/gon/gon
|
||||
- brew install --cask goreleaser/tap/goreleaser-pro
|
||||
info_script:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
- swift -version
|
||||
goreleaser_script: goreleaser release --skip=publish --snapshot --clean
|
||||
always:
|
||||
dist_artifacts:
|
||||
path: "dist/*"
|
||||
|
||||
task:
|
||||
name: Release
|
||||
only_if: $CIRRUS_TAG != ''
|
||||
depends_on:
|
||||
- lint
|
||||
- test
|
||||
- build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-runner:sequoia
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||
SENTRY_ORG: cirrus-labs
|
||||
SENTRY_PROJECT: persistent-workers
|
||||
SENTRY_AUTH_TOKEN: ENCRYPTED[!9eaf2875d51b113e2f68598441ff8e6b2e53242e48fcb93633bd75a373fbe2e7caa900d837cc92f0b142b65579731644!]
|
||||
setup_script:
|
||||
- cd $HOME
|
||||
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
||||
- security create-keychain -p password101 build.keychain
|
||||
- security default-keychain -s build.keychain
|
||||
- security unlock-keychain -p password101 build.keychain
|
||||
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
|
||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
||||
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||
install_script:
|
||||
- brew install go getsentry/tools/sentry-cli
|
||||
- brew install mitchellh/gon/gon
|
||||
- brew install --cask goreleaser/tap/goreleaser-pro
|
||||
info_script:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
- swift -version
|
||||
release_script: goreleaser
|
||||
upload_sentry_debug_files_script:
|
||||
- cd .build/arm64-apple-macosx/release/
|
||||
# Generate and upload symbols
|
||||
- dsymutil tart
|
||||
- sentry-cli debug-files upload tart.dSYM/
|
||||
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.dSYM/
|
||||
# Bundle and upload sources
|
||||
- sentry-cli debug-files bundle-sources tart.dSYM
|
||||
- sentry-cli debug-files upload tart.src.zip
|
||||
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.src.zip
|
||||
create_sentry_release_script:
|
||||
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
|
||||
- sentry-cli releases new $SENTRY_RELEASE
|
||||
- sentry-cli releases set-commits $SENTRY_RELEASE --auto
|
||||
- sentry-cli releases finalize $SENTRY_RELEASE
|
||||
|
||||
task:
|
||||
name: Deploy Documentation
|
||||
only_if: $CIRRUS_BRANCH == 'main'
|
||||
container:
|
||||
image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest
|
||||
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
|
||||
env:
|
||||
DEPLOY_TOKEN: ENCRYPTED[!45ed45666558902ed1c2400add734ec063103bec31841847e8c8764802fca229bfa6d85c690e16ad159e047574b48793!]
|
||||
deploy_script:
|
||||
- git config --global user.name "Cirrus CI"
|
||||
- git config --global user.name "hello@cirruslabs.org"
|
||||
- git remote set-url origin https://$DEPLOY_TOKEN@github.com/cirruslabs/tart/
|
||||
- mkdocs --verbose gh-deploy --force --remote-branch gh-pages
|
||||
@@ -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,191 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
markdown_lint:
|
||||
name: Markdown Lint
|
||||
if: github.ref != 'refs/heads/gh-pages'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Install markdownlint
|
||||
run: npm install -g markdownlint-cli
|
||||
- name: Lint Markdown
|
||||
run: markdownlint --config=docs/.markdownlint.yml docs/
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: SwiftFormat
|
||||
run: swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
||||
- name: Upload SwiftFormat report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: swiftformat-report
|
||||
path: swiftformat.json
|
||||
if-no-files-found: ignore
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Build
|
||||
run: swift build
|
||||
- name: Test
|
||||
run: |
|
||||
export PATH="$PATH:/usr/sbin"
|
||||
swift test
|
||||
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
if: github.ref_type != 'tag'
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch:
|
||||
- arm64
|
||||
- x86_64
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Build
|
||||
run: swift build --arch "${{ matrix.arch }}" --product tart
|
||||
- name: Sign
|
||||
run: codesign --sign - --entitlements Resources/tart-dev.entitlements --force ".build/${{ matrix.arch }}-apple-macosx/debug/tart"
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tart-${{ matrix.arch }}
|
||||
path: .build/${{ matrix.arch }}-apple-macosx/debug/tart
|
||||
if-no-files-found: error
|
||||
|
||||
release_dry_run:
|
||||
name: Release (Dry Run)
|
||||
if: github.event_name != 'pull_request' && github.ref_type != 'tag'
|
||||
needs:
|
||||
- lint
|
||||
- build
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
AC_PASSWORD: ${{ secrets.AC_PASSWORD }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Configure signing
|
||||
run: |
|
||||
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"
|
||||
- name: Install tools
|
||||
run: |
|
||||
brew install go
|
||||
brew install mitchellh/gon/gon
|
||||
brew install --cask goreleaser/tap/goreleaser-pro
|
||||
- name: Print tool versions
|
||||
run: |
|
||||
security find-identity -v
|
||||
xcodebuild -version
|
||||
swift -version
|
||||
- name: Run GoReleaser
|
||||
run: goreleaser release --skip=publish --snapshot --clean
|
||||
- name: Upload dist
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-dry-run
|
||||
path: dist/*
|
||||
if-no-files-found: ignore
|
||||
|
||||
release:
|
||||
name: Release
|
||||
if: github.ref_type == 'tag'
|
||||
needs:
|
||||
- lint
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
AC_PASSWORD: ${{ secrets.AC_PASSWORD }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Configure signing
|
||||
run: |
|
||||
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"
|
||||
- name: Install tools
|
||||
run: |
|
||||
brew install go
|
||||
brew install mitchellh/gon/gon
|
||||
brew install --cask goreleaser/tap/goreleaser-pro
|
||||
- name: Print tool versions
|
||||
run: |
|
||||
security find-identity -v
|
||||
xcodebuild -version
|
||||
swift -version
|
||||
- name: Run GoReleaser
|
||||
run: goreleaser
|
||||
|
||||
deploy_docs:
|
||||
name: Deploy Documentation
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write
|
||||
container:
|
||||
image: ghcr.io/squidfunk/mkdocs-material:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Deploy documentation
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
mkdocs --verbose gh-deploy --force --remote-branch gh-pages
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
@@ -25,20 +25,26 @@ universal_binaries:
|
||||
- name_template: tart.app/Contents/MacOS/tart
|
||||
replace: true
|
||||
hooks:
|
||||
post: gon gon.hcl
|
||||
post:
|
||||
- mkdir -p dist/tart_darwin_all/tart.app/Contents/Resources
|
||||
- cp Resources/embedded.provisionprofile dist/tart_darwin_all/tart.app/Contents/
|
||||
- cp Resources/Info.plist dist/tart_darwin_all/tart.app/Contents/
|
||||
- cp "Resources/actool/UPW Tart.icns" "Resources/actool/Assets.car" dist/tart_darwin_all/tart.app/Contents/Resources/
|
||||
- gon gon.hcl
|
||||
|
||||
archives:
|
||||
- name_template: "{{ .ProjectName }}"
|
||||
files:
|
||||
- src: Resources/embedded.provisionprofile
|
||||
dst: tart.app/Contents
|
||||
strip_parent: true
|
||||
- src: Resources/Info.plist
|
||||
dst: tart.app/Contents
|
||||
strip_parent: true
|
||||
- src: Resources/AppIcon.png
|
||||
dst: tart.app/Contents/Resources
|
||||
strip_parent: true
|
||||
- src: dist/tart_darwin_all/tart.app/Contents/Info.plist
|
||||
dst: tart.app/Contents/Info.plist
|
||||
- src: dist/tart_darwin_all/tart.app/Contents/embedded.provisionprofile
|
||||
dst: tart.app/Contents/embedded.provisionprofile
|
||||
- src: dist/tart_darwin_all/tart.app/Contents/Resources/UPW Tart.icns
|
||||
dst: tart.app/Contents/Resources/UPW Tart.icns
|
||||
- src: dist/tart_darwin_all/tart.app/Contents/Resources/Assets.car
|
||||
dst: tart.app/Contents/Resources/Assets.car
|
||||
- src: dist/tart_darwin_all/tart.app/Contents/_CodeSignature/CodeResources
|
||||
dst: tart.app/Contents/_CodeSignature/CodeResources
|
||||
- LICENSE
|
||||
|
||||
release:
|
||||
@@ -65,6 +71,8 @@ brews:
|
||||
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
|
||||
def post_install
|
||||
generate_completions_from_executable(libexec/"tart.app/Contents/MacOS/tart", "--generate-completion-script")
|
||||
end
|
||||
|
||||
@@ -7,7 +7,7 @@ Perhaps, the easiest, but not the most comprehensive way to tell what's going on
|
||||
In the example below, you will run `tart pull` via `time(1)` to gather generalized CPU, I/O and memory usage metrics:
|
||||
|
||||
```shell
|
||||
/usr/bin/time -l tart pull ghcr.io/cirruslabs/macos-sequoia-base:latest
|
||||
/usr/bin/time -l tart pull ghcr.io/cirruslabs/macos-tahoe-base:latest
|
||||
```
|
||||
|
||||
**Note:** you need to specify a full path to `time(1)` binary, otherwise the shell's built-in `time` command will be invoked, which doesn't have the `-l` command-line argument.
|
||||
@@ -48,7 +48,7 @@ To use it, make sure that [Xcode](https://developer.apple.com/xcode/resources/)
|
||||
Once done, you can create a CPU profile of `tart pull`:
|
||||
|
||||
```shell
|
||||
xctrace record --template "CPU Profiler" --target-stdout - --launch -- /opt/homebrew/bin/tart pull ghcr.io/cirruslabs/macos-sequoia-base:latest
|
||||
xctrace record --template "CPU Profiler" --target-stdout - --launch -- /opt/homebrew/bin/tart pull ghcr.io/cirruslabs/macos-tahoe-base:latest
|
||||
```
|
||||
|
||||
Now that `xctrace(1)` is running, you'll see the `tart pull`-related output first, and once finished, the following line will appear:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "668bad809d4882f75f097e66a12a6dbc8e61ec998f1800a7e09439c854fadda1",
|
||||
"originHash" : "061dfe6cdf4e6dbf32b51c5e7023c4ae69726dcafb42a35b34e5489b0338c17f",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "antlr4",
|
||||
@@ -13,19 +13,19 @@
|
||||
{
|
||||
"identity" : "cirruslabs_tart-guest-agent_apple_swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://buf.build/gen/swift/git/1.28.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_apple_swift.git",
|
||||
"location" : "https://buf.build/gen/swift/git/1.33.3-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_apple_swift.git",
|
||||
"state" : {
|
||||
"revision" : "ccfae5de1917cdb0d7c5000008fa5ed0bad032bf",
|
||||
"version" : "1.28.2-00000000000000-17d7dedafb88.1"
|
||||
"revision" : "5c49a653f4b003161077d194bc708b7373628c99",
|
||||
"version" : "1.33.3-20260114140118-bd09c26a260f.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "cirruslabs_tart-guest-agent_grpc_swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://buf.build/gen/swift/git/1.24.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_grpc_swift.git",
|
||||
"location" : "https://buf.build/gen/swift/git/1.27.1-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_grpc_swift.git",
|
||||
"state" : {
|
||||
"branch" : "1.24.2-00000000000000-17d7dedafb88.1",
|
||||
"revision" : "b8421f137325fe8de737ff5b61238f6f2131b2a8"
|
||||
"branch" : "main",
|
||||
"revision" : "4935078c2fe2508360843596d71a1f844ce639a6"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -42,8 +42,35 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/grpc/grpc-swift.git",
|
||||
"state" : {
|
||||
"revision" : "8c5e99d0255c373e0330730d191a3423c57373fb",
|
||||
"version" : "1.24.2"
|
||||
"revision" : "8f57f68b9d247fe3759fa9f18e1fe919911e6031",
|
||||
"version" : "1.27.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "opentelemetry-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/open-telemetry/opentelemetry-swift",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "ed37be9525081509ab62410d38b705c2b3f0d5a4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "opentelemetry-swift-core",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/open-telemetry/opentelemetry-swift-core.git",
|
||||
"state" : {
|
||||
"revision" : "240c8d5e36c3c7b774ed961325369f0b1f2c965f",
|
||||
"version" : "2.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "opentracing-objc",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/undefinedlabs/opentracing-objc",
|
||||
"state" : {
|
||||
"revision" : "18c1a35ca966236cee0c5a714a51a73ff33384c1",
|
||||
"version" : "0.5.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -55,15 +82,6 @@
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sentry-cocoa",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "65b3d2a7608685e8d4a37c68fa2c64f28d0b537e",
|
||||
"version" : "8.51.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -87,8 +105,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
|
||||
"version" : "1.2.0"
|
||||
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -123,8 +141,17 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "9cb486020ebf03bfa5b5df985387a14a98744537",
|
||||
"version" : "1.6.1"
|
||||
"revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181",
|
||||
"version" : "1.9.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-metrics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-metrics.git",
|
||||
"state" : {
|
||||
"revision" : "0743a9364382629da3bf5677b46a2c4b1ce5d2a6",
|
||||
"version" : "2.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -132,8 +159,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"state" : {
|
||||
"revision" : "34d486b01cd891297ac615e40d5999536a1e138d",
|
||||
"version" : "2.83.0"
|
||||
"revision" : "233f61bc2cfbb22d0edeb2594da27a20d2ce514e",
|
||||
"version" : "2.93.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -186,8 +213,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||
"state" : {
|
||||
"revision" : "ebc7251dd5b37f627c93698e4374084d98409633",
|
||||
"version" : "1.28.2"
|
||||
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
|
||||
"version" : "1.33.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -261,6 +288,15 @@
|
||||
"branch" : "master",
|
||||
"revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "thrift-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/undefinedlabs/Thrift-Swift",
|
||||
"state" : {
|
||||
"revision" : "18ff09e6b30e589ed38f90a1af23e193b8ecef8e",
|
||||
"version" : "1.1.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -17,15 +17,17 @@ let package = Package(
|
||||
.package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"),
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
|
||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.51.1"),
|
||||
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"),
|
||||
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"),
|
||||
.package(url: "https://github.com/groue/Semaphore", from: "0.0.8"),
|
||||
.package(url: "https://github.com/fumoboy007/swift-retry", from: "0.2.3"),
|
||||
.package(url: "https://github.com/jozefizso/swift-xattr", from: "3.0.0"),
|
||||
.package(url: "https://github.com/grpc/grpc-swift.git", .upToNextMajor(from: "1.24.2")),
|
||||
.package(url: "https://buf.build/gen/swift/git/1.24.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_grpc_swift.git", revision: "1.24.2-00000000000000-17d7dedafb88.1"),
|
||||
.package(url: "https://github.com/grpc/grpc-swift.git", .upToNextMajor(from: "1.27.0")),
|
||||
.package(url: "https://buf.build/gen/swift/git/1.27.1-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_grpc_swift.git", branch: "main"),
|
||||
.package(url: "https://github.com/open-telemetry/opentelemetry-swift", branch: "main"),
|
||||
.package(url: "https://github.com/open-telemetry/opentelemetry-swift-core", from: "2.3.0"),
|
||||
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
@@ -35,7 +37,6 @@ let package = Package(
|
||||
.product(name: "SwiftDate", package: "SwiftDate"),
|
||||
.product(name: "Antlr4Static", package: "Antlr4"),
|
||||
.product(name: "Atomics", package: "swift-atomics"),
|
||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||
.product(name: "TextTable", package: "TextTable"),
|
||||
.product(name: "Sysctl", package: "swift-sysctl"),
|
||||
.product(name: "SwiftRadix", package: "SwiftRadix"),
|
||||
@@ -44,6 +45,10 @@ let package = Package(
|
||||
.product(name: "XAttr", package: "swift-xattr"),
|
||||
.product(name: "GRPC", package: "grpc-swift"),
|
||||
.product(name: "Cirruslabs_TartGuestAgent_Grpc_Swift", package: "cirruslabs_tart-guest-agent_grpc_swift"),
|
||||
.product(name: "OpenTelemetryApi", package: "opentelemetry-swift-core"),
|
||||
.product(name: "OpenTelemetrySdk", package: "opentelemetry-swift-core"),
|
||||
.product(name: "OpenTelemetryProtocolExporterHTTP", package: "opentelemetry-swift"),
|
||||
.product(name: "ResourceExtension", package: "opentelemetry-swift"),
|
||||
], exclude: [
|
||||
"OCI/Reference/Makefile",
|
||||
"OCI/Reference/Reference.g4",
|
||||
|
||||
|
Before Width: | Height: | Size: 120 KiB |
@@ -7,15 +7,17 @@
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Tart</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.cirruslabs.tart</string>
|
||||
<string>com.github.cirruslabs.tart</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>tart</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon.png</string>
|
||||
</array>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>UPW Tart</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>UPW Tart</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"fill" : "automatic",
|
||||
"groups" : [
|
||||
{
|
||||
"blend-mode" : "normal",
|
||||
"blur-material" : 0.5,
|
||||
"layers" : [
|
||||
{
|
||||
"hidden" : false,
|
||||
"image-name-specializations" : [
|
||||
{
|
||||
"value" : "4.4-–-layer.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "square",
|
||||
"value" : "UPW Tart L4.png"
|
||||
}
|
||||
],
|
||||
"name" : "UPW Tart L4"
|
||||
}
|
||||
],
|
||||
"opacity" : 1,
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 1
|
||||
},
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.25
|
||||
}
|
||||
},
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name-specializations" : [
|
||||
{
|
||||
"value" : "3.3-–-layer.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "square",
|
||||
"value" : "UPW Tart L3.png"
|
||||
}
|
||||
],
|
||||
"name" : "UPW Tart L3",
|
||||
"position-specializations" : [
|
||||
{
|
||||
"idiom" : "square",
|
||||
"value" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "none",
|
||||
"opacity" : 1
|
||||
},
|
||||
"specular" : false,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.25
|
||||
}
|
||||
},
|
||||
{
|
||||
"blur-material" : null,
|
||||
"layers" : [
|
||||
{
|
||||
"image-name-specializations" : [
|
||||
{
|
||||
"value" : "2.2-–-layer.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "square",
|
||||
"value" : "UPW Tart L2.png"
|
||||
}
|
||||
],
|
||||
"name" : "UPW Tart L2"
|
||||
}
|
||||
],
|
||||
"position-specializations" : [
|
||||
{
|
||||
"idiom" : "square",
|
||||
"value" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "none",
|
||||
"opacity" : 1
|
||||
},
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.25
|
||||
}
|
||||
},
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name-specializations" : [
|
||||
{
|
||||
"value" : "1.1-–-layer.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "square",
|
||||
"value" : "UPW Tart L1.png"
|
||||
}
|
||||
],
|
||||
"name" : "UPW Tart L1"
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "layer-color",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.25
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>UPW Tart</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>UPW Tart</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -47,8 +47,16 @@ struct Exec: AsyncParsableCommand {
|
||||
try! group.syncShutdownGracefully()
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
let channel = try GRPCChannelPool.with(
|
||||
target: .unixDomainSocket(vmDir.controlSocketURL.path()),
|
||||
target: .unixDomainSocket(vmDir.controlSocketURL.relativePath),
|
||||
transportSecurity: .plaintext,
|
||||
eventLoopGroup: group,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import ArgumentParser
|
||||
import Foundation
|
||||
import Network
|
||||
import SystemConfiguration
|
||||
import Sentry
|
||||
|
||||
enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
||||
case dhcp, arp, agent
|
||||
@@ -69,7 +68,15 @@ struct IP: AsyncParsableCommand {
|
||||
throw RuntimeError.Generic("Cannot perform IP resolution via Tart Guest Agent when control socket URL is not set")
|
||||
}
|
||||
|
||||
if let ip = try await AgentResolver.ResolveIP(controlSocketURL) {
|
||||
// Change the current working directory to a VM's base directory
|
||||
// to work around Unix domain socket 104 byte limitation [1]
|
||||
//
|
||||
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
|
||||
if let baseURL = controlSocketURL.baseURL {
|
||||
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
|
||||
}
|
||||
|
||||
if let ip = try await AgentResolver.ResolveIP(controlSocketURL.relativePath) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ fileprivate struct VMInfo: Encodable {
|
||||
let Name: String
|
||||
let Disk: Int
|
||||
let Size: Int
|
||||
let SizeOnDisk: Int
|
||||
let Accessed: String
|
||||
let Running: Bool
|
||||
let State: String
|
||||
}
|
||||
@@ -39,13 +39,29 @@ struct List: AsyncParsableCommand {
|
||||
|
||||
if source == nil || source == "local" {
|
||||
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
||||
try VMInfo(Source: "local", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||
try VMInfo(
|
||||
Source: "local",
|
||||
Name: name,
|
||||
Disk: vmDir.sizeGB(),
|
||||
Size: vmDir.allocatedSizeGB(),
|
||||
Accessed: formatAccessDate(try vmDir.accessDate()),
|
||||
Running: vmDir.running(),
|
||||
State: vmDir.state().rawValue
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if source == nil || source == "oci" {
|
||||
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
||||
try VMInfo(Source: "OCI", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||
try VMInfo(
|
||||
Source: "OCI",
|
||||
Name: name,
|
||||
Disk: vmDir.sizeGB(),
|
||||
Size: vmDir.allocatedSizeGB(),
|
||||
Accessed: formatAccessDate(try vmDir.accessDate()),
|
||||
Running: vmDir.running(),
|
||||
State: vmDir.state().rawValue
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,4 +77,16 @@ struct List: AsyncParsableCommand {
|
||||
private func sortedInfos(_ infos: [VMInfo]) -> [VMInfo] {
|
||||
infos.sorted(by: { left, right in left.Name < right.Name })
|
||||
}
|
||||
|
||||
private func formatAccessDate(_ accessDate: Date) -> String {
|
||||
switch format {
|
||||
case .text:
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .full
|
||||
return formatter.localizedString(for: accessDate, relativeTo: Date())
|
||||
case .json:
|
||||
let formatter = ISO8601DateFormatter()
|
||||
return formatter.string(from: accessDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import Sentry
|
||||
import OpenTelemetryApi
|
||||
import SwiftUI
|
||||
import SwiftDate
|
||||
|
||||
@@ -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,8 +146,8 @@ 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] = [try VMStorageOCI(), try IPSWCache()]
|
||||
let prunables: [Prunable] = try prunableStorages
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ struct Push: AsyncParsableCommand {
|
||||
discussion: "Can be specified multiple times to attach multiple labels."))
|
||||
var labels: [String] = []
|
||||
|
||||
@Option(help: .hidden)
|
||||
var diskFormat: String = "v2"
|
||||
|
||||
@Flag(help: ArgumentHelp("cache pushed images locally",
|
||||
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
|
||||
var populateCache: Bool = false
|
||||
@@ -85,7 +82,6 @@ struct Push: AsyncParsableCommand {
|
||||
registry: registry,
|
||||
references: references,
|
||||
chunkSizeMb: chunkSize,
|
||||
diskFormat: diskFormat,
|
||||
concurrency: concurrency,
|
||||
labels: parseLabels()
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import Darwin
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
import Sentry
|
||||
import OpenTelemetryApi
|
||||
import System
|
||||
|
||||
var vm: VM?
|
||||
@@ -137,7 +137,7 @@ struct Run: AsyncParsableCommand {
|
||||
To work with block devices, the easiest way is to modify their permissions to be accessible to the current user:
|
||||
|
||||
sudo chown $USER /dev/diskX
|
||||
tart run sequoia --disk=/dev/diskX
|
||||
tart run macos --disk=/dev/diskX
|
||||
|
||||
Warning: after running the chown command above, all software running under the current user will be able to access /dev/diskX. If that violates your threat model, we recommend avoiding mounting block devices altogether.
|
||||
""", valueName: "path[:options]"), completion: .file())
|
||||
@@ -525,14 +525,15 @@ struct Run: AsyncParsableCommand {
|
||||
try vncImpl.stop()
|
||||
}
|
||||
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
// Capture the error into Sentry
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
// Capture the error into OpenTelemetry
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error)
|
||||
|
||||
fputs("\(error)\n", stderr)
|
||||
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -566,12 +567,14 @@ struct Run: AsyncParsableCommand {
|
||||
} else {
|
||||
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
|
||||
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(1)
|
||||
}
|
||||
#endif
|
||||
} catch (let e) {
|
||||
print(RuntimeError.SuspendFailed(e.localizedDescription))
|
||||
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -816,6 +819,12 @@ struct MainApp: App {
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||
func applicationDidFinishLaunching(_ : Notification) {
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
if (kill(getpid(), MainApp.suspendable ? SIGUSR1 : SIGINT) == 0) {
|
||||
return .terminateLater
|
||||
|
||||
@@ -21,8 +21,16 @@ class ControlSocket {
|
||||
// if any, otherwise we may get the "address already in use" error
|
||||
try? FileManager.default.removeItem(atPath: controlSocketURL.path())
|
||||
|
||||
// Change the current working directory to a VM's base directory
|
||||
// to work around Unix domain socket 104 byte limitation [1]
|
||||
//
|
||||
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
|
||||
if let baseURL = controlSocketURL.baseURL {
|
||||
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
|
||||
}
|
||||
|
||||
let serverChannel = try await ServerBootstrap(group: eventLoopGroup)
|
||||
.bind(unixDomainSocketPath: controlSocketURL.path()) { childChannel in
|
||||
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
|
||||
childChannel.eventLoop.makeCompletedFuture {
|
||||
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
|
||||
wrappingChannelSynchronously: childChannel
|
||||
|
||||
@@ -15,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,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 ""
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,15 @@ import Cirruslabs_TartGuestAgent_Apple_Swift
|
||||
import Cirruslabs_TartGuestAgent_Grpc_Swift
|
||||
|
||||
class AgentResolver {
|
||||
static func ResolveIP(_ controlSocketURL: URL) async throws -> IPv4Address? {
|
||||
static func ResolveIP(_ controlSocketPath: String) async throws -> IPv4Address? {
|
||||
do {
|
||||
return try await resolveIP(controlSocketURL)
|
||||
return try await resolveIP(controlSocketPath)
|
||||
} catch let error as GRPCConnectionPoolError {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveIP(_ controlSocketURL: URL) async throws -> IPv4Address? {
|
||||
private static func resolveIP(_ controlSocketPath: String) async throws -> IPv4Address? {
|
||||
// Create a gRPC channel connected to the VM's control socket
|
||||
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
defer {
|
||||
@@ -22,7 +22,7 @@ class AgentResolver {
|
||||
}
|
||||
|
||||
let channel = try GRPCChannelPool.with(
|
||||
target: .unixDomainSocket(controlSocketURL.path()),
|
||||
target: .unixDomainSocket(controlSocketPath),
|
||||
transportSecurity: .plaintext,
|
||||
eventLoopGroup: group,
|
||||
)
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import Foundation
|
||||
import Compression
|
||||
|
||||
class DiskV1: Disk {
|
||||
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||
var pushedLayers: [OCIManifestLayer] = []
|
||||
|
||||
// Open the disk file
|
||||
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
var mappedDiskReadOffset = 0
|
||||
|
||||
// Compress the disk file as a single stream
|
||||
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
||||
// Determine the size of the next chunk
|
||||
let bytesRead = min(length, mappedDisk.count - mappedDiskReadOffset)
|
||||
|
||||
// Read the next uncompressed chunk
|
||||
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
||||
|
||||
// Advance the offset
|
||||
mappedDiskReadOffset += bytesRead
|
||||
|
||||
// Provide the uncompressed chunk to the compressing filter
|
||||
return data
|
||||
}
|
||||
|
||||
// Cut the compressed stream into layers, each equal exactly ``Self.layerLimitBytes`` bytes,
|
||||
// except for the last one, which may be smaller
|
||||
while let compressedData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
||||
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||
|
||||
pushedLayers.append(OCIManifestLayer(
|
||||
mediaType: diskV1MediaType,
|
||||
size: compressedData.count,
|
||||
digest: layerDigest
|
||||
))
|
||||
|
||||
// Update progress using an absolute value
|
||||
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
||||
}
|
||||
|
||||
return pushedLayers
|
||||
}
|
||||
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws {
|
||||
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||
throw OCIError.FailedToCreateVmFile
|
||||
}
|
||||
|
||||
// Open the disk file
|
||||
let disk = try FileHandle(forWritingTo: diskURL)
|
||||
defer { try! disk.close() }
|
||||
|
||||
// Decompress the layers onto the disk in a single stream
|
||||
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||
if let data = data {
|
||||
try disk.write(contentsOf: data)
|
||||
}
|
||||
}
|
||||
|
||||
for diskLayer in diskLayers {
|
||||
try await registry.pullBlob(diskLayer.digest) { data in
|
||||
try filter.write(data)
|
||||
|
||||
// Update the progress
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
}
|
||||
}
|
||||
|
||||
try filter.finalize()
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
|
||||
|
||||
// Layer media types
|
||||
let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
|
||||
let diskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
||||
let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2"
|
||||
let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import ArgumentParser
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Sentry
|
||||
import OpenTelemetryApi
|
||||
import OpenTelemetrySdk
|
||||
import OpenTelemetryProtocolExporterHttp
|
||||
|
||||
@main
|
||||
struct Root: AsyncParsableCommand {
|
||||
@@ -50,50 +52,27 @@ struct Root: AsyncParsableCommand {
|
||||
// Set line-buffered output for stdout
|
||||
setlinebuf(stdout)
|
||||
|
||||
defer { OTel.shared.flush() }
|
||||
|
||||
do {
|
||||
// Parse command
|
||||
var command = try parseAsRoot()
|
||||
|
||||
// Initialize Sentry
|
||||
if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
|
||||
SentrySDK.start { options in
|
||||
options.dsn = dsn
|
||||
options.releaseName = CI.release
|
||||
options.tracesSampleRate = Float(
|
||||
ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
|
||||
) as NSNumber?
|
||||
// Create a root span for the command we're about to run
|
||||
let span = OTel.shared.tracer.spanBuilder(spanName: type(of: command)._commandName).startSpan()
|
||||
defer { span.end() }
|
||||
OpenTelemetry.instance.contextProvider.setActiveSpan(span)
|
||||
|
||||
// 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)
|
||||
]
|
||||
|
||||
// https://github.com/cirruslabs/tart/issues/1163
|
||||
options.enableAppLaunchProfiling = false
|
||||
options.configureProfiling = {
|
||||
$0.profileAppStarts = false
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Enrich root command span with command's arguments
|
||||
let commandLineArguments = ProcessInfo.processInfo.arguments.map { argument in
|
||||
AttributeValue.string(argument)
|
||||
}
|
||||
defer {
|
||||
if ProcessInfo.processInfo.environment["SENTRY_DSN"] != nil {
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,19 +94,18 @@ struct Root: AsyncParsableCommand {
|
||||
} catch {
|
||||
// Not an error, just a custom exit code from "tart exec"
|
||||
if let execCustomExitCodeError = error as? ExecCustomExitCodeError {
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(execCustomExitCodeError.exitCode)
|
||||
}
|
||||
|
||||
// Capture the error into Sentry
|
||||
if ProcessInfo.processInfo.environment["SENTRY_DSN"] != nil {
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
}
|
||||
// Capture the error into OpenTelemetry
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.recordException(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)
|
||||
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(errorWithExitCode.exitCode)
|
||||
}
|
||||
|
||||
@@ -136,7 +114,7 @@ struct Root: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseCirrusSentryTag(_ tag: String.SubSequence) -> (String, String)? {
|
||||
private static func splitEnvironmentVariable(_ tag: String.SubSequence) -> (String, String)? {
|
||||
let splits = tag.split(separator: "=", maxSplits: 1)
|
||||
if splits.count != 2 {
|
||||
return nil
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Compression
|
||||
import Foundation
|
||||
import Sentry
|
||||
import OpenTelemetryApi
|
||||
|
||||
let legacyDiskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
||||
|
||||
enum OCIError: Error {
|
||||
case ShouldBeExactlyOneLayer
|
||||
@@ -29,21 +31,20 @@ extension VMDirectory {
|
||||
try configFile.close()
|
||||
|
||||
// Pull VM's disk layers and decompress them into a disk file
|
||||
let diskImplType: Disk.Type
|
||||
let layers: [OCIManifestLayer]
|
||||
if manifest.layers.contains(where: { $0.mediaType == legacyDiskV1MediaType }) {
|
||||
throw RuntimeError.Generic("Pulling OCI images with legacy disk media type \(legacyDiskV1MediaType) is no longer supported, please re-push the image using a current Tart version")
|
||||
}
|
||||
|
||||
if manifest.layers.contains(where: { $0.mediaType == diskV1MediaType }) {
|
||||
diskImplType = DiskV1.self
|
||||
layers = manifest.layers.filter { $0.mediaType == diskV1MediaType }
|
||||
} else if manifest.layers.contains(where: { $0.mediaType == diskV2MediaType }) {
|
||||
diskImplType = DiskV2.self
|
||||
layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
|
||||
} else {
|
||||
let layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
|
||||
if layers.isEmpty {
|
||||
throw OCIError.ShouldBeAtLeastOneLayer
|
||||
}
|
||||
|
||||
let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +)
|
||||
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte)
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
|
||||
key: "compressed_disk_size_bytes",
|
||||
value: .int(Int(diskCompressedSize))
|
||||
)
|
||||
|
||||
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
||||
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
||||
@@ -52,10 +53,10 @@ extension VMDirectory {
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
do {
|
||||
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
|
||||
concurrency: concurrency, progress: progress,
|
||||
localLayerCache: localLayerCache,
|
||||
deduplicate: deduplicate)
|
||||
try await DiskV2.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
|
||||
concurrency: concurrency, progress: progress,
|
||||
localLayerCache: localLayerCache,
|
||||
deduplicate: deduplicate)
|
||||
} catch let error where error is FilterError {
|
||||
throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)")
|
||||
}
|
||||
@@ -87,7 +88,7 @@ extension VMDirectory {
|
||||
try manifest.toJSON().write(to: manifestURL)
|
||||
}
|
||||
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String, concurrency: UInt, labels: [String: String] = [:]) async throws -> RemoteName {
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, concurrency: UInt, labels: [String: String] = [:]) async throws -> RemoteName {
|
||||
var layers = Array<OCIManifestLayer>()
|
||||
|
||||
// Read VM's config and push it as blob
|
||||
@@ -108,14 +109,7 @@ extension VMDirectory {
|
||||
let progress = Progress(totalUnitCount: diskSize)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
switch diskFormat {
|
||||
case "v1":
|
||||
layers.append(contentsOf: try await DiskV1.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
|
||||
case "v2":
|
||||
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
|
||||
default:
|
||||
throw RuntimeError.OCIUnsupportedDiskFormat(diskFormat)
|
||||
}
|
||||
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
|
||||
|
||||
// Read VM's NVRAM and push it as blob
|
||||
defaultLogger.appendNewLine("pushing NVRAM...")
|
||||
|
||||
@@ -2,25 +2,6 @@ import Foundation
|
||||
import Virtualization
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Disk Image Info Structures
|
||||
struct DiskImageInfo: Codable {
|
||||
let sizeInfo: SizeInfo?
|
||||
let size: UInt64?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case sizeInfo = "Size Info"
|
||||
case size = "Size"
|
||||
}
|
||||
}
|
||||
|
||||
struct SizeInfo: Codable {
|
||||
let totalBytes: UInt64?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case totalBytes = "Total Bytes"
|
||||
}
|
||||
}
|
||||
|
||||
struct VMDirectory: Prunable {
|
||||
enum State: String {
|
||||
case Running = "running"
|
||||
@@ -46,7 +27,7 @@ struct VMDirectory: Prunable {
|
||||
baseURL.appendingPathComponent("manifest.json")
|
||||
}
|
||||
var controlSocketURL: URL {
|
||||
baseURL.appendingPathComponent("control.sock")
|
||||
URL(fileURLWithPath: "control.sock", relativeTo: baseURL)
|
||||
}
|
||||
|
||||
var explicitlyPulledMark: URL {
|
||||
@@ -201,69 +182,28 @@ struct VMDirectory: Prunable {
|
||||
}
|
||||
|
||||
private func resizeASIFDisk(_ sizeGB: UInt16) throws {
|
||||
guard let diskutilURL = resolveBinaryPath("diskutil") else {
|
||||
throw RuntimeError.FailedToResizeDisk("diskutil not found in PATH")
|
||||
}
|
||||
|
||||
// First, get current disk image info to check current size
|
||||
let infoProcess = Process()
|
||||
infoProcess.executableURL = diskutilURL
|
||||
infoProcess.arguments = ["image", "info", "--plist", diskURL.path]
|
||||
|
||||
let infoPipe = Pipe()
|
||||
infoProcess.standardOutput = infoPipe
|
||||
infoProcess.standardError = infoPipe
|
||||
|
||||
do {
|
||||
try infoProcess.run()
|
||||
infoProcess.waitUntilExit()
|
||||
let diskImageInfo = try Diskutil.imageInfo(diskURL)
|
||||
|
||||
let infoData = infoPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let currentSizeBytes = try diskImageInfo.totalBytes()
|
||||
let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||
|
||||
if infoProcess.terminationStatus != 0 {
|
||||
let output = String(data: infoData, encoding: .utf8) ?? "Unknown error"
|
||||
throw RuntimeError.FailedToResizeDisk("Failed to get ASIF disk info: \(output)")
|
||||
}
|
||||
if desiredSizeBytes < currentSizeBytes {
|
||||
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentSizeBytes))
|
||||
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredSizeBytes))
|
||||
|
||||
// Parse the plist using PropertyListDecoder
|
||||
do {
|
||||
let diskImageInfo = try PropertyListDecoder().decode(DiskImageInfo.self, from: infoData)
|
||||
|
||||
// Extract current size from the decoded structure
|
||||
var currentSizeBytes: UInt64?
|
||||
|
||||
// Try to get size from Size Info -> Total Bytes first
|
||||
if let totalBytes = diskImageInfo.sizeInfo?.totalBytes {
|
||||
currentSizeBytes = totalBytes
|
||||
} else if let size = diskImageInfo.size {
|
||||
// Fallback to top-level Size field
|
||||
currentSizeBytes = size
|
||||
}
|
||||
|
||||
guard let currentSizeBytes = currentSizeBytes else {
|
||||
throw RuntimeError.FailedToResizeDisk("Could not find size information in disk image info")
|
||||
}
|
||||
|
||||
let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||
|
||||
if desiredSizeBytes < currentSizeBytes {
|
||||
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentSizeBytes))
|
||||
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredSizeBytes))
|
||||
throw RuntimeError.InvalidDiskSize("new disk size of \(desiredLengthHuman) should be larger " +
|
||||
"than the current disk size of \(currentLengthHuman)")
|
||||
} else if desiredSizeBytes > currentSizeBytes {
|
||||
// Resize the ASIF disk image using diskutil
|
||||
try performASIFResize(sizeGB)
|
||||
}
|
||||
throw RuntimeError.InvalidDiskSize("New disk size of \(desiredLengthHuman) should be larger " +
|
||||
"than the current disk size of \(currentLengthHuman)")
|
||||
} else if desiredSizeBytes > currentSizeBytes {
|
||||
// Resize the ASIF disk image using diskutil
|
||||
try performASIFResize(sizeGB)
|
||||
} else {
|
||||
// If sizes are equal, no action needed
|
||||
} catch let error as RuntimeError {
|
||||
throw error
|
||||
} catch {
|
||||
let outputString = String(data: infoData, encoding: .utf8) ?? "Unable to decode output"
|
||||
throw RuntimeError.FailedToResizeDisk("Failed to parse disk image info: \(error). Output: \(outputString)")
|
||||
}
|
||||
} catch let error as RuntimeError {
|
||||
throw error
|
||||
} catch {
|
||||
throw RuntimeError.FailedToResizeDisk("Failed to get disk image info: \(error)")
|
||||
throw RuntimeError.FailedToResizeDisk("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +244,7 @@ struct VMDirectory: Prunable {
|
||||
case .raw:
|
||||
try createRawDisk(sizeGB: sizeGB)
|
||||
case .asif:
|
||||
try createASIFDisk(sizeGB: sizeGB)
|
||||
try Diskutil.imageCreate(diskURL: diskURL, sizeGB: sizeGB)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,38 +258,6 @@ struct VMDirectory: Prunable {
|
||||
try diskFileHandle.close()
|
||||
}
|
||||
|
||||
private func createASIFDisk(sizeGB: UInt16) throws {
|
||||
guard let diskutilURL = resolveBinaryPath("diskutil") else {
|
||||
throw RuntimeError.FailedToCreateDisk("diskutil not found in PATH")
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = diskutilURL
|
||||
process.arguments = [
|
||||
"image", "create", "blank",
|
||||
"--format", "ASIF",
|
||||
"--size", "\(sizeGB)G",
|
||||
"--volumeName", "Tart",
|
||||
diskURL.path
|
||||
]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
if process.terminationStatus != 0 {
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let output = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||
throw RuntimeError.FailedToCreateDisk("Failed to create ASIF disk image: \(output)")
|
||||
}
|
||||
} catch {
|
||||
throw RuntimeError.FailedToCreateDisk("Failed to execute diskutil: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func delete() throws {
|
||||
let lock = try lock()
|
||||
@@ -391,6 +299,21 @@ struct VMDirectory: Prunable {
|
||||
try sizeBytes() / 1000 / 1000 / 1000
|
||||
}
|
||||
|
||||
func diskSizeBytes() throws -> Int {
|
||||
let vmConfig = try VMConfig(fromURL: configURL)
|
||||
|
||||
return switch vmConfig.diskFormat {
|
||||
case .raw:
|
||||
try sizeBytes()
|
||||
case .asif:
|
||||
try Diskutil.imageInfo(diskURL).totalBytes()
|
||||
}
|
||||
}
|
||||
|
||||
func diskSizeGB() throws -> Int {
|
||||
try diskSizeBytes() / 1000 / 1000 / 1000
|
||||
}
|
||||
|
||||
func markExplicitlyPulled() {
|
||||
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ enum RuntimeError : Error {
|
||||
case ImportFailed(_ message: String)
|
||||
case SoftnetFailed(_ message: String)
|
||||
case OCIStorageError(_ message: String)
|
||||
case OCIUnsupportedDiskFormat(_ format: String)
|
||||
case SuspendFailed(_ message: String)
|
||||
case PullFailed(_ message: String)
|
||||
case VirtualMachineLimitExceeded(_ hint: String)
|
||||
@@ -139,8 +138,6 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return "Softnet failed: \(message)"
|
||||
case .OCIStorageError(let message):
|
||||
return "OCI storage error: \(message)"
|
||||
case .OCIUnsupportedDiskFormat(let format):
|
||||
return "OCI disk format \(format) is not supported by this version of Tart"
|
||||
case .SuspendFailed(let message):
|
||||
return "Failed to suspend the VM: \(message)"
|
||||
case .PullFailed(let message):
|
||||
@@ -169,14 +166,3 @@ extension RuntimeError : HasExitCode {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Customize error description for Sentry[1]
|
||||
//
|
||||
// [1]: https://docs.sentry.io/platforms/apple/guides/ios/usage/#customizing-error-descriptions
|
||||
extension RuntimeError : CustomNSError {
|
||||
var errorUserInfo: [String : Any] {
|
||||
[
|
||||
NSDebugDescriptionErrorKey: description,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import Sentry
|
||||
import OpenTelemetryApi
|
||||
import Retry
|
||||
|
||||
class VMStorageOCI: PrunableStorage {
|
||||
@@ -145,9 +145,10 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["imageName": name.description], key: "OCI")
|
||||
}
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
|
||||
key: "oci.image-name",
|
||||
value: .string(name.description)
|
||||
)
|
||||
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
@@ -181,7 +182,9 @@ 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,
|
||||
@@ -194,9 +197,10 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
// 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
|
||||
|
||||
@@ -229,9 +233,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
return .throw
|
||||
}
|
||||
try move(digestName, from: tmpVMDir)
|
||||
transaction.finish()
|
||||
}, onCancel: {
|
||||
transaction.finish(status: SentrySpanStatus.cancelled)
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class DiskutilTests: XCTestCase {
|
||||
func testDiskutilInfo() throws {
|
||||
// Create a temporary directory
|
||||
let tempDirURL = FileManager.default.temporaryDirectory.appendingPathComponent("tart-diskutil-tests-\(UUID().uuidString)")
|
||||
try? FileManager.default.createDirectory(at: tempDirURL, withIntermediateDirectories: true)
|
||||
addTeardownBlock {
|
||||
try? FileManager.default.removeItem(at: tempDirURL)
|
||||
}
|
||||
|
||||
// Create a 123 GB ASIF disk
|
||||
let diskURL = tempDirURL.appendingPathComponent("disk.asif")
|
||||
try Diskutil.imageCreate(diskURL: diskURL, sizeGB: 123)
|
||||
|
||||
// Retrieve its information and ensure that it does indeed take 123 GB
|
||||
let info = try Diskutil.imageInfo(diskURL)
|
||||
XCTAssertEqual(123 * 1000 * 1000 * 1000, try info.totalBytes())
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ final class LayerizerTests: XCTestCase {
|
||||
do {
|
||||
registryRunner = try await RegistryRunner()
|
||||
} catch {
|
||||
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
||||
try XCTSkip("Registry is unavailable: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,30 +24,9 @@ final class LayerizerTests: XCTestCase {
|
||||
registryRunner = nil
|
||||
}
|
||||
|
||||
func testDiskV1() async throws {
|
||||
// Original disk file to be pushed to the registry
|
||||
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
|
||||
addTeardownBlock {
|
||||
try FileManager.default.removeItem(at: originalDiskFileURL)
|
||||
}
|
||||
|
||||
// Disk file to be pulled from the registry
|
||||
// and compared against the original disk file
|
||||
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
|
||||
print("pushing disk...")
|
||||
let diskLayers = try await DiskV1.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, concurrency: 4, progress: Progress())
|
||||
|
||||
print("pulling disk...")
|
||||
try await DiskV1.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
|
||||
|
||||
print("comparing disks...")
|
||||
try XCTAssertEqual(Digest.hash(originalDiskFileURL), Digest.hash(pulledDiskFileURL))
|
||||
}
|
||||
|
||||
func testDiskV2() async throws {
|
||||
// Original disk file to be pushed to the registry
|
||||
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
|
||||
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 1 * 1024 * 1024 * 1024)
|
||||
addTeardownBlock {
|
||||
try FileManager.default.removeItem(at: originalDiskFileURL)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ final class RegistryTests: XCTestCase {
|
||||
do {
|
||||
registryRunner = try await RegistryRunner()
|
||||
} catch {
|
||||
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
||||
try XCTSkip("Registry is unavailable: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class RegistryRunner {
|
||||
let stdoutPipe = Pipe()
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
|
||||
proc.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/docker")
|
||||
proc.arguments = arguments
|
||||
proc.standardOutput = stdoutPipe
|
||||
try proc.run()
|
||||
@@ -31,7 +31,7 @@ class RegistryRunner {
|
||||
|
||||
init() async throws {
|
||||
// Start container
|
||||
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "127.0.0.1:0:5000", "registry:2")
|
||||
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "127.0.0.1::5000", "registry:2")
|
||||
.trimmingCharacters(in: CharacterSet.newlines)
|
||||
containerID = container
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Set shell options to enable fail-fast behavior
|
||||
#
|
||||
# * -e: fail the script when an error occurs or command fails
|
||||
# * -u: fail the script when attempting to reference unset parameters
|
||||
# * -o pipefail: by default an exit status of a pipeline is that of its
|
||||
# last command, this fails the pipe early if an error in
|
||||
# any of its commands occurs
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
OUTPUT_PATH="Resources/actool"
|
||||
PLIST_PATH="$OUTPUT_PATH/Info.plist"
|
||||
|
||||
rm -rf "${OUTPUT_PATH}"
|
||||
mkdir -p "${OUTPUT_PATH}"
|
||||
|
||||
actool "Resources/UPW Tart.icon" \
|
||||
--compile "${OUTPUT_PATH}" \
|
||||
--output-format human-readable-text \
|
||||
--notices \
|
||||
--warnings \
|
||||
--errors \
|
||||
--app-icon "UPW Tart" \
|
||||
--output-partial-info-plist $PLIST_PATH \
|
||||
--include-all-app-icons \
|
||||
--target-device mac \
|
||||
--minimum-deployment-target 13.0 \
|
||||
--platform macosx
|
||||
@@ -13,7 +13,7 @@ brew install go
|
||||
Finally, run the following command from this (`benchmark/`) directory:
|
||||
|
||||
```shell
|
||||
go run cmd/main.go fio --image ghcr.io/cirruslabs/macos-sequoia-base:latest --prepare 'sudo purge && sync'
|
||||
go run cmd/main.go fio --image ghcr.io/cirruslabs/macos-tahoe-base:latest --prepare 'sudo purge && sync'
|
||||
```
|
||||
|
||||
You can also enable the debugging output to diagnose issues:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module github.com/cirruslabs/tart/benchmark
|
||||
|
||||
go 1.22.1
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
require (
|
||||
|
||||
@@ -23,7 +23,7 @@ func NewCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging")
|
||||
cmd.Flags().StringVar(&image, "image", "ghcr.io/cirruslabs/macos-sequoia-xcode:latest", "image to use for testing")
|
||||
cmd.Flags().StringVar(&image, "image", "ghcr.io/cirruslabs/macos-tahoe-xcode:latest", "image to use for testing")
|
||||
cmd.Flags().StringVar(&prepare, "prepare", "", "command to run before running each benchmark")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -60,10 +60,14 @@ device without a physical display connected. For example, a Mac Mini with a HDMI
|
||||
but a Mac Mini on a desk with a connected physical display is considered a personal computer. **Usage on personal computers
|
||||
and before reaching the 100 CPU cores limit is royalty-free and does not have the viral properties of AGPL.**
|
||||
|
||||
!!! note "Pricing update"
|
||||
This post announced Tart licensing in February 2023 and originally listed monthly prices.
|
||||
Pricing has since changed to yearly billing. See [Licensing and Support](../../licensing.md#license-tiers) for the latest terms.
|
||||
|
||||
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Tier License](../../licensing.md#license-tiers),
|
||||
which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](../../licensing.md#license-tiers)
|
||||
(\$3000 per month) will be required, and for organizations that exceed 3000 CPU cores, a custom [Diamond Tier License](../../licensing.md#license-tiers)
|
||||
(\$1 per core per month) will be necessary. **All paid license tiers will include priority feature development and SLAs on support with urgent issues.**
|
||||
which costs \$12,000 per year. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](../../licensing.md#license-tiers)
|
||||
(\$36,000 per year) will be required, and for organizations that exceed 3000 CPU cores, a custom [Diamond Tier License](../../licensing.md#license-tiers)
|
||||
(\$12 per core per year) will be necessary. **All paid license tiers will include priority feature development and SLAs on support with urgent issues.**
|
||||
|
||||
## Have we considered alternatives?
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ Remote images are pulled into `~/.tart/cache/OCIs/`.
|
||||
## Nested virtualization support?
|
||||
|
||||
Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
|
||||
supports nested virtualization only on M3 or M4 chips running macOS 15 (Sequoia). By default, it is disabled, but can be enabled by passing the `--nested` flag to `tart run`.
|
||||
supports nested virtualization only on M3 or M4 chips running macOS 15 (Sequoia) or later and [only for Linux VMs](https://github.com/cirruslabs/tart/issues/1231#issuecomment-4410915463). By default, it is disabled, but can be enabled by passing the `--nested` flag to `tart run`.
|
||||
|
||||
## Connecting to a service running on host
|
||||
|
||||
@@ -77,6 +77,21 @@ Note: that accessing host is only possible with the default NAT network. If you
|
||||
[Softnet](https://github.com/cirruslabs/softnet) (via `tart run --net-softnet <VM NAME>)`, then the network isolation
|
||||
is stricter and it's not possible to access the host.
|
||||
|
||||
## Avoiding the "Local Network" permission pop-up
|
||||
|
||||
Starting from macOS 15 (Sequoia), a GUI "Local Network" permission pop-up might appear when Tart is used by another tool that needs to connect to a VM over a private IPv4 network, for example [Packer](https://developer.hashicorp.com/packer/integrations/cirruslabs/tart/latest/components/builder/tart).
|
||||
|
||||
This is not caused by Tart itself, but by the host-side process that needs network access into the guest.
|
||||
|
||||
If you need a non-interactive workaround, you can configure the [local network privacy preferences](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy#macOS-considerations) on the host so that all [RFC 1918](https://datatracker.ietf.org/doc/html/rfc1918#section-3) address ranges that Tart VMs commonly use are excluded from the prompt:
|
||||
|
||||
```shell
|
||||
sudo defaults write com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"
|
||||
sudo defaults write com.apple.network.local-network AllowedWiFiLocalNetworkAddresses -array "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"
|
||||
```
|
||||
|
||||
After applying these settings, reboot the host.
|
||||
|
||||
## Changing the default NAT subnet
|
||||
|
||||
To change the default network to `192.168.77.1`:
|
||||
@@ -184,7 +199,7 @@ security unlock-keychain login.keychain
|
||||
|
||||
This command also supports the `-p` command-line argument that allows you to supply a password and unlock non-interactively, which is great for scripts.
|
||||
|
||||
Alternatively, you can pass the credentials via the environment variables, see [Registry Authorization](integrations/vm-management.md#registry-authorization) for more details on how to do that.
|
||||
Alternatively, you can pass the credentials via the environment variables, see [Registry Authorization](quick-start.md#registry-authorization) for more details on how to do that.
|
||||
|
||||
## How is Tart different from Anka?
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ steps:
|
||||
- command: uname -a
|
||||
plugins:
|
||||
- cirruslabs/tart#main:
|
||||
image: ghcr.io/cirruslabs/macos-sequoia-base:latest
|
||||
image: ghcr.io/cirruslabs/macos-tahoe-base:latest
|
||||
```
|
||||
|
||||
This will run `uname -r` in a macOS Tart VM cloned from `ghcr.io/cirruslabs/macos-sequoia-base:latest`.
|
||||
This will run `uname -r` in a macOS Tart VM cloned from `ghcr.io/cirruslabs/macos-tahoe-base:latest`.
|
||||
|
||||
See plugin's [Configuration section](https://github.com/cirruslabs/tart-buildkite-plugin#configuration) for the full list of available options.
|
||||
|
||||
@@ -18,7 +18,7 @@ task:
|
||||
name: hello
|
||||
macos_instance:
|
||||
# can be a remote or a local virtual machine
|
||||
image: ghcr.io/cirruslabs/macos-sequoia-base:latest
|
||||
image: ghcr.io/cirruslabs/macos-tahoe-base:latest
|
||||
hello_script:
|
||||
- echo "Hello from within a Tart VM!"
|
||||
- echo "Here is my CPU info:"
|
||||
@@ -50,7 +50,7 @@ exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tas
|
||||
task:
|
||||
name: Build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-sequoia-xcode:latest
|
||||
image: ghcr.io/cirruslabs/macos-tahoe-xcode:latest
|
||||
build_script: swift build --product tart
|
||||
binary_artifacts:
|
||||
path: .build/debug/tart
|
||||
|
||||
@@ -42,7 +42,7 @@ Now you can use Tart Images in your `.gitlab-ci.yml`:
|
||||
```yaml
|
||||
# You can use any remote Tart Image.
|
||||
# Tart Executor will pull it from the registry and use it for creating ephemeral VMs.
|
||||
image: ghcr.io/cirruslabs/macos-sequoia-base:latest
|
||||
image: ghcr.io/cirruslabs/macos-tahoe-base:latest
|
||||
|
||||
test:
|
||||
tags:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: Automating VM image building with Packer
|
||||
description: Use Packer to build custom VM images, configure VMs and work with remote OCI registries.
|
||||
---
|
||||
|
||||
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
|
||||
Here is an example of a template to build a local image based of a remote image:
|
||||
|
||||
```hcl
|
||||
packer {
|
||||
required_plugins {
|
||||
tart = {
|
||||
version = ">= 0.5.3"
|
||||
source = "github.com/cirruslabs/tart"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
source "tart-cli" "tart" {
|
||||
vm_base_name = "ghcr.io/cirruslabs/macos-tahoe-base:latest"
|
||||
vm_name = "my-custom-tahoe"
|
||||
cpu_count = 4
|
||||
memory_gb = 8
|
||||
disk_size_gb = 70
|
||||
ssh_password = "admin"
|
||||
ssh_timeout = "120s"
|
||||
ssh_username = "admin"
|
||||
}
|
||||
|
||||
build {
|
||||
sources = ["source.tart-cli.tart"]
|
||||
|
||||
provisioner "shell" {
|
||||
inline = ["echo 'Disabling spotlight indexing...'", "sudo mdutil -a -i off"]
|
||||
}
|
||||
|
||||
# more provisioners
|
||||
}
|
||||
```
|
||||
|
||||
Here is a [repository with Packer templates](https://github.com/cirruslabs/macos-image-templates) used to build [all the images managed by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
||||
@@ -1,143 +0,0 @@
|
||||
---
|
||||
title: Managing Virtual Machine
|
||||
description: Use Packer to build custom VM images, configure VMs and work with remote OCI registries.
|
||||
---
|
||||
|
||||
# Managing Virtual Machine
|
||||
|
||||
## Creating from scratch
|
||||
|
||||
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regardless of the underlying OS a particular VM image has.
|
||||
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
|
||||
|
||||
### Creating a macOS VM image from scratch
|
||||
|
||||
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
|
||||
use `latest` instead of a path to `*.ipsw` to download the latest available version:
|
||||
|
||||
```bash
|
||||
tart create --from-ipsw=latest sequoia-vanilla
|
||||
tart run sequoia-vanilla
|
||||
```
|
||||
|
||||
After the initial booting of the VM, you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
|
||||
|
||||
1. Enable Auto-Login. Users & Groups -> Login Options -> Automatic login -> admin.
|
||||
2. Allow SSH. Sharing -> Remote Login
|
||||
3. Disable Lock Screen. Preferences -> Lock Screen -> disable "Require Password" after 5.
|
||||
4. Disable Screen Saver.
|
||||
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
|
||||
|
||||
### Creating a Linux VM image from scratch
|
||||
|
||||
Linux VMs are supported on hosts running macOS 13.0 (Ventura) or newer.
|
||||
|
||||
```bash
|
||||
# Create a bare VM
|
||||
tart create --linux ubuntu
|
||||
|
||||
# Install Ubuntu
|
||||
tart run --disk focal-desktop-arm64.iso ubuntu
|
||||
|
||||
# Run VM
|
||||
tart run ubuntu
|
||||
```
|
||||
|
||||
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y openssh-server
|
||||
sudo ufw allow ssh
|
||||
```
|
||||
|
||||
## Configuring a VM
|
||||
|
||||
By default, a Tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed after VM creation with `tart set` command.
|
||||
Please refer to `tart set --help` for additional details.
|
||||
|
||||
## Building with Packer
|
||||
|
||||
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
|
||||
Here is an example of a template to build a local image based of a remote image:
|
||||
|
||||
```hcl
|
||||
packer {
|
||||
required_plugins {
|
||||
tart = {
|
||||
version = ">= 0.5.3"
|
||||
source = "github.com/cirruslabs/tart"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
source "tart-cli" "tart" {
|
||||
vm_base_name = "ghcr.io/cirruslabs/macos-sequoia-base:latest"
|
||||
vm_name = "my-custom-sequoia"
|
||||
cpu_count = 4
|
||||
memory_gb = 8
|
||||
disk_size_gb = 70
|
||||
ssh_password = "admin"
|
||||
ssh_timeout = "120s"
|
||||
ssh_username = "admin"
|
||||
}
|
||||
|
||||
build {
|
||||
sources = ["source.tart-cli.tart"]
|
||||
|
||||
provisioner "shell" {
|
||||
inline = ["echo 'Disabling spotlight indexing...'", "sudo mdutil -a -i off"]
|
||||
}
|
||||
|
||||
# more provisioners
|
||||
}
|
||||
```
|
||||
|
||||
Here is a [repository with Packer templates](https://github.com/cirruslabs/macos-image-templates) used to build [all the images managed by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
||||
|
||||
## Working with a Remote OCI Container Registry
|
||||
|
||||
Tart supports interacting with Open Container Initiative (OCI) registries, but only runs images created and pushed by Tart. This means images created for container engines, like Docker, can't be pulled. Instead, create a custom image as documented above.
|
||||
|
||||
For example, let's say you want to push/pull images to an OCI registry hosted at `https://acme.io/`.
|
||||
|
||||
### Registry Authorization
|
||||
|
||||
First, you need to login to `acme.io` with the `tart login` command:
|
||||
|
||||
```bash
|
||||
tart login acme.io
|
||||
```
|
||||
|
||||
If you login to your registry with OAuth, you may need to create an access token to use as the password.
|
||||
Credentials are securely stored in Keychain.
|
||||
|
||||
In addition, Tart supports [Docker credential helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers)
|
||||
if defined in `~/.docker/config.json`.
|
||||
|
||||
Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override authorization
|
||||
for all registries which might useful for integrating with your CI's secret management.
|
||||
|
||||
### Pushing a Local Image
|
||||
|
||||
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
|
||||
|
||||
```bash
|
||||
tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:v1.0.0
|
||||
```
|
||||
|
||||
### Pulling a Remote Image
|
||||
|
||||
You can either pull an image:
|
||||
|
||||
```bash
|
||||
tart pull acme.io/remoteorg/name:latest
|
||||
```
|
||||
|
||||
or create a VM from a remote image:
|
||||
|
||||
```bash
|
||||
tart clone acme.io/remoteorg/name:latest my-local-vm-name
|
||||
```
|
||||
|
||||
If the specified image is not already present, this invocation calls the `tart pull` implicitly before cloning.
|
||||
@@ -14,7 +14,7 @@ This page covers Terms of Service only for Cirrus Runners and Tart Documentation
|
||||
Cirrus Labs Inc ("Cirrus Labs") operates the [Cirrus Runners service](https://cirrus-runners.app/) which we hope you use.
|
||||
If you use it, please use it responsibly. If you don't, we'll have to terminate your subscription.
|
||||
|
||||
For paid plans, you'll be charged on a monthly basis. You can cancel anytime, but there are no refunds.
|
||||
For paid plans, you'll be charged on a yearly basis. You can cancel anytime, but there are no refunds.
|
||||
|
||||
The Terms of Service and our prices can change at any time unless specified in your agreement. We'll warn you 30 days in advance of any price changes.
|
||||
We'll try to warn you about major changes to the Terms of Service, but we make no guarantees.
|
||||
|
||||
@@ -36,19 +36,19 @@ Free Tier license has a 100 CPU core limit for Tart and 4 Orchard Workers limit
|
||||
|
||||
### Gold Tier
|
||||
|
||||
If an organization wishes to exceed the limits of the Free Tier license, a purchase of the [Gold Tier License](#get-the-license) is required, which costs \$1000 per month.
|
||||
If an organization wishes to exceed the limits of the Free Tier license, a purchase of the [Gold Tier License](#get-the-license) is required, which costs \$12,000 per year.
|
||||
|
||||
Gold Tier license has a 500 CPU core limit for Tart and 20 Orchard Workers limit for Orchard.
|
||||
|
||||
### Platinum Tier
|
||||
|
||||
If an organization wishes to exceed the limits of the Gold Tier license, a purchase of the [Platinum Tier License](#get-the-license) is required, which costs \$3000 per month.
|
||||
If an organization wishes to exceed the limits of the Gold Tier license, a purchase of the [Platinum Tier License](#get-the-license) is required, which costs \$36,000 per year.
|
||||
|
||||
Platinum Tier license has a 3,000 CPU core limit for Tart and 200 Orchard Workers limit for Orchard.
|
||||
|
||||
### Diamond Tier
|
||||
|
||||
For organizations that wish to exceed the limits of the Platinum Tier license, a purchase of a [custom Diamond Tier License](#get-the-license) is required, which costs \$1 per CPU core per month and gives the ability to run unlimited Orchard Workers.
|
||||
For organizations that wish to exceed the limits of the Platinum Tier license, a purchase of a [custom Diamond Tier License](#get-the-license) is required, which costs \$12 per CPU core per year and gives the ability to run unlimited Orchard Workers.
|
||||
|
||||
## Get the license
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ def main():
|
||||
# Create VM
|
||||
response = requests.post("http://127.0.0.1:6120/v1/vms", auth=basic_auth, json={
|
||||
"name": vm_name,
|
||||
"image": "ghcr.io/cirruslabs/macos-sequoia-base:latest",
|
||||
"image": "ghcr.io/cirruslabs/macos-tahoe-base:latest",
|
||||
"cpu": 4,
|
||||
"memory": 4096,
|
||||
"startup_script": {
|
||||
@@ -144,7 +144,7 @@ func main() {
|
||||
Meta: v1.Meta{
|
||||
Name: vmName,
|
||||
},
|
||||
Image: "ghcr.io/cirruslabs/macos-sequoia-base:latest",
|
||||
Image: "ghcr.io/cirruslabs/macos-tahoe-base:latest",
|
||||
CPU: 4,
|
||||
Memory: 4096,
|
||||
StartupScript: &v1.VMScript{
|
||||
|
||||
@@ -26,7 +26,7 @@ more information.
|
||||
Now, let's create a Virtual Machine:
|
||||
|
||||
```shell
|
||||
orchard create vm --image ghcr.io/cirruslabs/macos-sequoia-base:latest sequoia-base
|
||||
orchard create vm --image ghcr.io/cirruslabs/macos-tahoe-base:latest tahoe-base
|
||||
```
|
||||
|
||||
You can check a list of VM resources to see if the Virtual Machine we've created above is already running:
|
||||
@@ -48,7 +48,7 @@ instance. Orchard Controller instance is secured by default and all API calls ar
|
||||
To SSH into a VM, use the `orchard ssh` command:
|
||||
|
||||
```shell
|
||||
orchard ssh vm sequoia-base
|
||||
orchard ssh vm tahoe-base
|
||||
```
|
||||
|
||||
You can specify the `--username` and `--password` flags to specify the username/password pair to use for the SSH
|
||||
@@ -58,14 +58,14 @@ You can also execute remote commands instead of spawning a login shell, similarl
|
||||
a command argument:
|
||||
|
||||
```shell
|
||||
orchard ssh vm sequoia-base "uname -a"
|
||||
orchard ssh vm tahoe-base "uname -a"
|
||||
```
|
||||
|
||||
You can execute scripts remotely this way, by telling the remote command-line interpreter to read from the standard
|
||||
input and using the redirection operator as follows:
|
||||
|
||||
```shell
|
||||
orchard ssh vm sequoia-base "bash -s" < script.sh
|
||||
orchard ssh vm tahoe-base "bash -s" < script.sh
|
||||
```
|
||||
|
||||
### VNC
|
||||
@@ -73,7 +73,7 @@ orchard ssh vm sequoia-base "bash -s" < script.sh
|
||||
Similarly to `ssh` command, you can use `vnc` command to open Screen Sharing into a remote VM:
|
||||
|
||||
```shell
|
||||
orchard vnc vm sequoia-base
|
||||
orchard vnc vm tahoe-base
|
||||
```
|
||||
|
||||
You can specify the `--username` and `--password` flags to specify the username/password pair to use for the VNC
|
||||
@@ -84,7 +84,7 @@ protocol. By default, `admin`/`admin` is used.
|
||||
The following command will delete the VM we've created above and clean-up the resources associated with it:
|
||||
|
||||
```shell
|
||||
orchard delete vm sequoia-base
|
||||
orchard delete vm tahoe-base
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
@@ -9,8 +9,8 @@ Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura)
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/tart
|
||||
tart clone ghcr.io/cirruslabs/macos-sequoia-base:latest sequoia-base
|
||||
tart run sequoia-base
|
||||
tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest tahoe-base
|
||||
tart run tahoe-base
|
||||
```
|
||||
|
||||
??? info "Manual installation from a release archive"
|
||||
@@ -19,8 +19,8 @@ tart run sequoia-base
|
||||
```bash
|
||||
curl -LO https://github.com/cirruslabs/tart/releases/latest/download/tart.tar.gz
|
||||
tar -xzvf tart.tar.gz
|
||||
./tart.app/Contents/MacOS/tart clone ghcr.io/cirruslabs/macos-sequoia-base:latest sequoia-base
|
||||
./tart.app/Contents/MacOS/tart run sequoia-base
|
||||
./tart.app/Contents/MacOS/tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest tahoe-base
|
||||
./tart.app/Contents/MacOS/tart run tahoe-base
|
||||
```
|
||||
|
||||
Please note that `./tart.app/Contents/MacOS/tart` binary is required to be used in order to trick macOS
|
||||
@@ -34,6 +34,10 @@ tart run sequoia-base
|
||||
|
||||
The following macOS images are currently available:
|
||||
|
||||
* macOS 26 (Tahoe)
|
||||
* `ghcr.io/cirruslabs/macos-tahoe-vanilla:latest`
|
||||
* `ghcr.io/cirruslabs/macos-tahoe-base:latest`
|
||||
* `ghcr.io/cirruslabs/macos-tahoe-xcode:latest`
|
||||
* macOS 15 (Sequoia)
|
||||
* `ghcr.io/cirruslabs/macos-sequoia-vanilla:latest`
|
||||
* `ghcr.io/cirruslabs/macos-sequoia-base:latest`
|
||||
@@ -86,7 +90,7 @@ These credentials work both for logging in via GUI, console (Linux) and SSH.
|
||||
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
|
||||
|
||||
```bash
|
||||
ssh admin@$(tart ip sequoia-base)
|
||||
ssh admin@$(tart ip tahoe-base)
|
||||
```
|
||||
|
||||
!!! tip "Running scripts inside Tart virtual machines"
|
||||
@@ -95,10 +99,61 @@ ssh admin@$(tart ip sequoia-base)
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/sshpass
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" -o "UserKnownHostsFile=/dev/null" admin@$(tart ip sequoia-base) "uname -a"
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" -o "UserKnownHostsFile=/dev/null" admin@$(tart ip sequoia-base) < script.sh
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" -o "UserKnownHostsFile=/dev/null" admin@$(tart ip tahoe-base) "uname -a"
|
||||
sshpass -p admin ssh -o "StrictHostKeyChecking no" -o "UserKnownHostsFile=/dev/null" admin@$(tart ip tahoe-base) < script.sh
|
||||
```
|
||||
|
||||
## Creating VM images
|
||||
|
||||
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regardless of the underlying OS a particular VM image has.
|
||||
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
|
||||
|
||||
### Creating a macOS VM image from scratch
|
||||
|
||||
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
|
||||
use `latest` instead of a path to `*.ipsw` to download the latest available version:
|
||||
|
||||
```bash
|
||||
tart create --from-ipsw=latest tahoe-vanilla
|
||||
tart run tahoe-vanilla
|
||||
```
|
||||
|
||||
After the initial booting of the VM, you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
|
||||
|
||||
1. Enable Auto-Login. Users & Groups -> Login Options -> Automatic login -> admin.
|
||||
2. Allow SSH. Sharing -> Remote Login
|
||||
3. Disable Lock Screen. Preferences -> Lock Screen -> disable "Require Password" after 5.
|
||||
4. Disable Screen Saver.
|
||||
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
|
||||
|
||||
### Creating a Linux VM image from scratch
|
||||
|
||||
Linux VMs are supported on hosts running macOS 13.0 (Ventura) or newer.
|
||||
|
||||
```bash
|
||||
# Create a bare VM
|
||||
tart create --linux ubuntu
|
||||
|
||||
# Install Ubuntu
|
||||
tart run --disk focal-desktop-arm64.iso ubuntu
|
||||
|
||||
# Run VM
|
||||
tart run ubuntu
|
||||
```
|
||||
|
||||
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y openssh-server
|
||||
sudo ufw allow ssh
|
||||
```
|
||||
|
||||
### Configuring a VM
|
||||
|
||||
By default, a Tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed after VM creation with `tart set` command.
|
||||
Please refer to `tart set --help` for additional details.
|
||||
|
||||
## Mounting directories
|
||||
|
||||
To mount a directory, run the VM with the `--dir` argument:
|
||||
@@ -161,3 +216,52 @@ The directory we've mounted above will be accessible from the `/mnt/shared/proje
|
||||
```shell
|
||||
com.apple.virtio-fs.automount /mnt/shared virtiofs rw,relatime 0 0
|
||||
```
|
||||
|
||||
## Working with a Remote OCI Container Registry
|
||||
|
||||
Tart supports interacting with Open Container Initiative (OCI) registries, but only runs images created and pushed by Tart. This means images created for container engines, like Docker, can't be pulled. Instead, create a custom image as documented above.
|
||||
|
||||
For example, let's say you want to push/pull images to an OCI registry hosted at `https://acme.io/`.
|
||||
|
||||
### Registry Authorization
|
||||
|
||||
First, you need to login to `acme.io` with the `tart login` command:
|
||||
|
||||
```bash
|
||||
tart login acme.io
|
||||
```
|
||||
|
||||
If you login to your registry with OAuth, you may need to create an access token to use as the password.
|
||||
Credentials are securely stored in Keychain.
|
||||
|
||||
In addition, Tart supports [Docker credential helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers)
|
||||
if defined in `~/.docker/config.json`.
|
||||
|
||||
Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override authorization
|
||||
for all registries, which might be useful for integrating with your CI's secret management.
|
||||
|
||||
You can also set the `TART_REGISTRY_HOSTNAME` environment variable to apply these overrides only to a specific host.
|
||||
|
||||
### Pushing a Local Image
|
||||
|
||||
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
|
||||
|
||||
```bash
|
||||
tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:v1.0.0
|
||||
```
|
||||
|
||||
### Pulling a Remote Image
|
||||
|
||||
You can either pull an image:
|
||||
|
||||
```bash
|
||||
tart pull acme.io/remoteorg/name:latest
|
||||
```
|
||||
|
||||
or create a VM from a remote image:
|
||||
|
||||
```bash
|
||||
tart clone acme.io/remoteorg/name:latest my-local-vm-name
|
||||
```
|
||||
|
||||
If the specified image is not already present, this invocation calls the `tart pull` implicitly before cloning.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block announce %}
|
||||
<a href="https://cirruslabs.org/">
|
||||
️🎉🎉🎉️  Big milestone for Cirrus Labs — we’re joining <strong>OpenAI</strong> to work on Agent Infrastructure 🎉🎉🎉
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
<!-- Render landing page under tabs -->
|
||||
{% block tabs %} {{ super() }}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
source = [ "dist/tart_darwin_all/tart.app/Contents/MacOS/tart" ]
|
||||
source = [ "dist/tart_darwin_all/tart.app" ]
|
||||
bundle_id = "com.github.cirruslabs.tart"
|
||||
|
||||
apple_id {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
module integration
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.opentelemetry.io/otel v1.38.0
|
||||
go.opentelemetry.io/proto/otlp v1.9.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5 h1:jP1RStw811EvUDzsUQ9oESqw2e4RqCjSAD9qIL8eMns=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5/go.mod h1:WXNBZ64q3+ZUemCMXD9kYnr56H7CgZxDBHCVwstfl3s=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,109 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"encoding/hex"
|
||||
"integration/tart"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
|
||||
tracepkg "go.opentelemetry.io/proto/otlp/collector/trace/v1"
|
||||
v1 "go.opentelemetry.io/proto/otlp/common/v1"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestOpenTelemetry(t *testing.T) {
|
||||
// Start a mock OpenTelemetry collector server
|
||||
var traces []*tracepkg.ExportTraceServiceRequest
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
var trace tracepkg.ExportTraceServiceRequest
|
||||
|
||||
reader := request.Body
|
||||
var err error
|
||||
|
||||
if request.Header.Get("Content-Encoding") == "gzip" {
|
||||
reader, err = gzip.NewReader(reader)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
requestBytes, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
switch request.Header.Get("Content-Type") {
|
||||
case "application/x-protobuf":
|
||||
require.NoError(t, proto.Unmarshal(requestBytes, &trace))
|
||||
default:
|
||||
require.FailNowf(t, "unsupported content type",
|
||||
"we do not support %q yet", request.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
traces = append(traces, &trace)
|
||||
|
||||
var response tracepkg.ExportTraceServiceResponse
|
||||
|
||||
responseBytes, err := proto.Marshal(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
writer.Header().Set("Content-Type", "application/x-protobuf")
|
||||
_, err = writer.Write(responseBytes)
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
|
||||
// Start a "tart list" command
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", serverURL.JoinPath("v1/traces").String())
|
||||
t.Setenv("CIRRUS_SENTRY_TAGS", "A=B,C=D")
|
||||
t.Setenv("TRACEPARENT", "00-00000000000000000000000000000001-0000000000000001-01")
|
||||
|
||||
_, _, err = tart.Tart(t, "list")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ensure that the mock OpenTelemetry collector received a trace from "tart list"
|
||||
require.Len(t, traces, 1)
|
||||
|
||||
resourceSpans := traces[0].GetResourceSpans()
|
||||
require.Len(t, resourceSpans, 1)
|
||||
|
||||
// Ensure that service name and version resources are set
|
||||
resourceSpan := resourceSpans[0]
|
||||
stringAttributes := stringAttributesToMap(resourceSpan.GetResource().GetAttributes())
|
||||
require.Equal(t, "tart", stringAttributes[string(semconv.ServiceNameKey)])
|
||||
require.Equal(t, "SNAPSHOT", stringAttributes[string(semconv.ServiceVersionKey)])
|
||||
|
||||
scopeSpans := resourceSpan.GetScopeSpans()
|
||||
require.Len(t, scopeSpans, 1)
|
||||
|
||||
spans := scopeSpans[0].GetSpans()
|
||||
require.Len(t, spans, 1)
|
||||
|
||||
// Ensure that the root span is correctly named
|
||||
span := spans[0]
|
||||
require.Equal(t, "list", span.Name)
|
||||
|
||||
// Ensure that CIRRUS_SENTRY_TAGS are propagated
|
||||
stringAttributes = stringAttributesToMap(span.GetAttributes())
|
||||
require.Equal(t, stringAttributes["A"], "B")
|
||||
require.Equal(t, stringAttributes["C"], "D")
|
||||
|
||||
// Ensure that W3C Trace Context is propagated
|
||||
require.Equal(t, "00000000000000000000000000000001", hex.EncodeToString(span.GetTraceId()))
|
||||
require.Equal(t, "0000000000000001", hex.EncodeToString(span.GetParentSpanId()))
|
||||
}
|
||||
|
||||
func stringAttributesToMap(attributes []*v1.KeyValue) map[string]string {
|
||||
result := map[string]string{}
|
||||
|
||||
for _, attribute := range attributes {
|
||||
result[attribute.GetKey()] = attribute.GetValue().GetStringValue()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package tart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const tartCommandName = "tart"
|
||||
|
||||
var (
|
||||
ErrTartNotFound = errors.New("tart command not found")
|
||||
ErrTartFailed = errors.New("tart command returned non-zero exit code")
|
||||
)
|
||||
|
||||
func Tart(t *testing.T, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.CommandContext(t.Context(), tartCommandName, args...)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", "", fmt.Errorf("%w: %s command not found in PATH, make sure Tart is installed",
|
||||
ErrTartNotFound, tartCommandName)
|
||||
}
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
// Tart command failed, redefine the error to be the Tart-specific output
|
||||
err = fmt.Errorf("%w: %q", ErrTartFailed, firstNonEmptyLine(stderr.String(), stdout.String()))
|
||||
}
|
||||
}
|
||||
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
|
||||
func firstNonEmptyLine(outputs ...string) string {
|
||||
for _, output := range outputs {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if line != "" {
|
||||
return line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -46,7 +46,7 @@ def vm_with_random_disk(tart):
|
||||
disk_path = os.path.join(tart.home(), "vms", vm_name, "disk.img")
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tf:
|
||||
tf.write(os.urandom(amount_to_transfer.bytes))
|
||||
tf.write(os.urandom(int(amount_to_transfer.bytes)))
|
||||
tf.close()
|
||||
os.rename(tf.name, disk_path)
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ plugins:
|
||||
debug: true
|
||||
- search
|
||||
- minify
|
||||
- redirects:
|
||||
redirect_maps:
|
||||
'integrations/vm-management.md': quick-start.md
|
||||
|
||||
markdown_extensions:
|
||||
- markdown.extensions.admonition
|
||||
@@ -96,7 +99,7 @@ nav:
|
||||
- "GitHub Actions": https://cirrus-runners.app/
|
||||
- "GitLab Runner": integrations/gitlab-runner.md
|
||||
- "Buildkite": integrations/buildkite.md
|
||||
- "Managing VMs": integrations/vm-management.md
|
||||
- "Packer": integrations/packer.md
|
||||
- "Support & Licensing": licensing.md
|
||||
- "Orchestration":
|
||||
- "Quick Start": orchard/quick-start.md
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
docker run --pull=always --rm -it -p 8000:8000 -v ${PWD}:/docs ghcr.io/cirruslabs/mkdocs-material-insiders:latest build
|
||||
docker run --pull=always --rm -it -p 8000:8000 -v ${PWD}:/docs ghcr.io/squidfunk/mkdocs-material:latest build
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
#!/bin/sh
|
||||
|
||||
# helper script to build and run a signed tart binary
|
||||
# usage: ./scripts/run-signed.sh run sequoia-base
|
||||
# usage: ./scripts/run-signed.sh run macos
|
||||
|
||||
set -e
|
||||
|
||||
swift build --product tart
|
||||
codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
|
||||
rm -Rf .build/tart.app/
|
||||
mkdir -p .build/tart.app/Contents/MacOS .build/tart.app/Contents/Resources
|
||||
cp -c .build/debug/tart .build/tart.app/Contents/MacOS/tart
|
||||
cp -c Resources/embedded.provisionprofile .build/tart.app/Contents/embedded.provisionprofile
|
||||
cp -c Resources/Info.plist .build/tart.app/Contents/Info.plist
|
||||
cp -c Resources/AppIcon.png .build/tart.app/Contents/Resources
|
||||
cp -c "Resources/actool/UPW Tart.icns" "Resources/actool/Assets.car" .build/tart.app/Contents/Resources/
|
||||
|
||||
codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/tart.app
|
||||
|
||||
.build/tart.app/Contents/MacOS/tart "$@"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
docker run --pull=always --rm -it -p 8000:8000 -v ${PWD}:/docs ghcr.io/cirruslabs/mkdocs-material-insiders:latest
|
||||
docker run --pull=always --rm -it -p 8000:8000 -v ${PWD}:/docs ghcr.io/squidfunk/mkdocs-material:latest
|
||||
|
||||