Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae9520430b | ||
|
|
cfd685e137 | ||
|
|
40eaff0e82 | ||
|
|
f77dd9f6e5 | ||
|
|
f371ebe980 |
@@ -1,24 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
ARCH="$1"
|
||||
SCRATCH_PATH=".build/$ARCH"
|
||||
OUTPUT_PATH=".build/prebuilt/$ARCH"
|
||||
|
||||
swift build \
|
||||
--build-system swiftbuild \
|
||||
--scratch-path "$SCRATCH_PATH" \
|
||||
--arch "$ARCH" \
|
||||
--configuration release \
|
||||
--product tart
|
||||
|
||||
BIN_PATH=$(swift build \
|
||||
--build-system swiftbuild \
|
||||
--scratch-path "$SCRATCH_PATH" \
|
||||
--arch "$ARCH" \
|
||||
--configuration release \
|
||||
--show-bin-path)
|
||||
|
||||
mkdir -p "$OUTPUT_PATH"
|
||||
cp "$BIN_PATH/tart" "$OUTPUT_PATH/tart"
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
set -e
|
||||
|
||||
export VERSION="${VERSION:-0}"
|
||||
export VERSION="${CIRRUS_TAG:-0}"
|
||||
|
||||
mkdir -p .ci/pkg/
|
||||
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
: "${VERSION:?VERSION must be set}"
|
||||
|
||||
TMPFILE=$(mktemp)
|
||||
perl -pe 's/\$\{VERSION\}/$ENV{VERSION}/g' Sources/tart/CI/CI.swift > "$TMPFILE"
|
||||
mv "$TMPFILE" Sources/tart/CI/CI.swift
|
||||
envsubst < Sources/tart/CI/CI.swift > $TMPFILE
|
||||
mv $TMPFILE Sources/tart/CI/CI.swift
|
||||
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string ${VERSION}" Resources/Info.plist
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string ${CIRRUS_TAG}" Resources/Info.plist
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
APP_PATH="dist/tart_darwin_all/tart.app"
|
||||
|
||||
if [ "${TART_RELEASE_SNAPSHOT:-false}" = "true" ]; then
|
||||
codesign \
|
||||
--force \
|
||||
--deep \
|
||||
--sign - \
|
||||
--entitlements Resources/tart-dev.entitlements \
|
||||
"$APP_PATH"
|
||||
else
|
||||
codesign \
|
||||
--force \
|
||||
--verbose \
|
||||
--sign "Developer ID Application: Cirrus Labs, Inc. (9M2P8L4D89)" \
|
||||
--timestamp \
|
||||
--options runtime \
|
||||
--keychain "$RUNNER_TEMP/build.keychain" \
|
||||
--entitlements Resources/tart-prod.entitlements \
|
||||
"$APP_PATH"
|
||||
fi
|
||||
|
||||
codesign --verify --strict --verbose=2 "$APP_PATH"
|
||||
"$APP_PATH/Contents/MacOS/tart" --version
|
||||
|
||||
if [ "${TART_RELEASE_SNAPSHOT:-false}" != "true" ]; then
|
||||
NOTARIZATION_ARCHIVE="$RUNNER_TEMP/tart-notarization.zip"
|
||||
|
||||
ditto -c -k --keepParent "$APP_PATH" "$NOTARIZATION_ARCHIVE"
|
||||
xcrun notarytool submit "$NOTARIZATION_ARCHIVE" \
|
||||
--keychain-profile "notarytool" \
|
||||
--keychain "$RUNNER_TEMP/build.keychain" \
|
||||
--wait \
|
||||
--timeout 20m
|
||||
xcrun stapler staple "$APP_PATH"
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
spctl --assess --type execute --verbose=4 "$APP_PATH"
|
||||
fi
|
||||
@@ -1,78 +1,84 @@
|
||||
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:
|
||||
# Add /usr/sbin to PATH, otherwise testDiskutilInfo() fails to locate "diskutil"
|
||||
- export PATH=$PATH:/usr/sbin
|
||||
- swift test
|
||||
integration_test_script:
|
||||
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
|
||||
# Run integration tests
|
||||
- cd integration-tests
|
||||
- python3 -m venv --symlinks venv
|
||||
- source venv/bin/activate
|
||||
- pip install -r requirements.txt
|
||||
- pytest --verbose --junit-xml=pytest-junit.xml
|
||||
- go test -v ./...
|
||||
pytest_junit_result_artifacts:
|
||||
path: "integration-tests/pytest-junit.xml"
|
||||
format: junit
|
||||
|
||||
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
|
||||
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
||||
name: Release (Dry Run)
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-runner:tahoe
|
||||
lint_script:
|
||||
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
||||
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:
|
||||
swiftformat_report_artifacts:
|
||||
path: swiftformat.json
|
||||
format: swiftformat
|
||||
dist_artifacts:
|
||||
path: "dist/*"
|
||||
|
||||
task:
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
env:
|
||||
matrix:
|
||||
BUILD_ARCH: arm64
|
||||
BUILD_ARCH: x86_64
|
||||
name: Build ($BUILD_ARCH)
|
||||
alias: build
|
||||
name: Release
|
||||
only_if: $CIRRUS_TAG != ''
|
||||
depends_on:
|
||||
- lint
|
||||
- test
|
||||
- build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-runner:tahoe
|
||||
build_script: swift build --arch $BUILD_ARCH --product tart
|
||||
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||
binary_artifacts:
|
||||
path: .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||
|
||||
task:
|
||||
name: Deploy Documentation
|
||||
only_if: $CIRRUS_BRANCH == 'main'
|
||||
container:
|
||||
image: ghcr.io/squidfunk/mkdocs-material: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
|
||||
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
|
||||
|
||||
@@ -9,10 +9,10 @@ permissions:
|
||||
jobs:
|
||||
build_cached:
|
||||
name: Build tart (cached)
|
||||
runs-on: xcode-27
|
||||
runs-on: ghcr.io/cirruslabs/macos-runner:tahoe
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
|
||||
- uses: actions/checkout@v5
|
||||
- name: Build
|
||||
run: |
|
||||
export COMPILATION_CACHE_ENABLE_CACHING=YES
|
||||
@@ -29,9 +29,9 @@ jobs:
|
||||
|
||||
build_no_cache:
|
||||
name: Build tart (no cache)
|
||||
runs-on: xcode-27
|
||||
runs-on: ghcr.io/cirruslabs/macos-runner:tahoe
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
|
||||
- uses: actions/checkout@v5
|
||||
- name: Build
|
||||
run: swift build --build-system swiftbuild --product tart
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: xcode-27
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: integration-tests/go.mod
|
||||
cache-dependency-path: integration-tests/go.sum
|
||||
- name: Build
|
||||
run: swift build --build-system swiftbuild
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
export PATH="$PATH:/usr/sbin"
|
||||
swift test --build-system swiftbuild
|
||||
# The Python suite boots Tart VMs, but hosted ARM macOS runners do not support nested virtualization.
|
||||
- name: Run OpenTelemetry integration tests
|
||||
run: |
|
||||
bin_path="$(swift build --build-system swiftbuild --show-bin-path)"
|
||||
codesign --sign - --entitlements Resources/tart-dev.entitlements --force "$bin_path/tart"
|
||||
cd integration-tests
|
||||
PATH="$bin_path:$PATH" go test -v ./...
|
||||
@@ -1,111 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.event_name == 'push' && github.repository == 'openai/tart'
|
||||
name: Release
|
||||
runs-on: xcode-27
|
||||
environment: publish
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
AC_PASSWORD: ${{ secrets.AC_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: temporary-password
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
P12_PASSWORD: password101
|
||||
run: |
|
||||
echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/build.keychain"
|
||||
security set-keychain-settings -lut 21600 "$RUNNER_TEMP/build.keychain"
|
||||
security default-keychain -s "$RUNNER_TEMP/build.keychain"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/build.keychain"
|
||||
security import "$RUNNER_TEMP/certificate.p12" \
|
||||
-k "$RUNNER_TEMP/build.keychain" \
|
||||
-P "$P12_PASSWORD" \
|
||||
-T /usr/bin/codesign \
|
||||
-T /usr/bin/pkgbuild
|
||||
security set-key-partition-list \
|
||||
-S apple-tool:,apple:,codesign: \
|
||||
-s \
|
||||
-k "$KEYCHAIN_PASSWORD" \
|
||||
"$RUNNER_TEMP/build.keychain"
|
||||
security list-keychain -d user -s "$RUNNER_TEMP/build.keychain"
|
||||
xcrun notarytool store-credentials "notarytool" \
|
||||
--apple-id "hello@cirruslabs.org" \
|
||||
--team-id "9M2P8L4D89" \
|
||||
--password "$AC_PASSWORD" \
|
||||
--keychain "$RUNNER_TEMP/build.keychain"
|
||||
- name: Create release app token for this repo
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
- name: Create release app token for homebrew-tools
|
||||
id: tap-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
owner: openai
|
||||
repositories: homebrew-tools
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
- name: Release
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }}
|
||||
|
||||
snapshot:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
name: Release (Dry Run)
|
||||
runs-on: xcode-27
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
TART_RELEASE_SNAPSHOT: "true"
|
||||
VERSION: snapshot
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Build snapshot
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
version: "~> v2"
|
||||
args: release --skip=publish --snapshot --clean
|
||||
- name: Upload snapshot artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: tart-snapshot
|
||||
path: dist/*
|
||||
@@ -5,8 +5,8 @@ project_name: tart
|
||||
before:
|
||||
hooks:
|
||||
- .ci/set-version.sh
|
||||
- sh .ci/build-release.sh arm64
|
||||
- sh .ci/build-release.sh x86_64
|
||||
- swift build --arch arm64 --configuration release --product tart
|
||||
- swift build --arch x86_64 --configuration release --product tart
|
||||
|
||||
builds:
|
||||
- id: tart
|
||||
@@ -19,33 +19,34 @@ builds:
|
||||
- amd64
|
||||
binary: tart.app/Contents/MacOS/tart
|
||||
prebuilt:
|
||||
path: '.build/prebuilt/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}/tart'
|
||||
path: '.build/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}-apple-macosx/release/tart'
|
||||
|
||||
universal_binaries:
|
||||
- name_template: tart.app/Contents/MacOS/tart
|
||||
replace: true
|
||||
hooks:
|
||||
post:
|
||||
pre:
|
||||
- mkdir -p dist/tart_darwin_all/tart.app/Contents/Resources
|
||||
- cp Resources/embedded.provisionprofile dist/tart_darwin_all/tart.app/Contents/
|
||||
- cp Resources/Info.plist dist/tart_darwin_all/tart.app/Contents/
|
||||
- cp "Resources/actool/UPW Tart.icns" "Resources/actool/Assets.car" dist/tart_darwin_all/tart.app/Contents/Resources/
|
||||
- cmd: .ci/sign-release.sh
|
||||
output: true
|
||||
- cp Resources/embedded.provisionprofile dist/tart_darwin_all/tart.app/Contents/embedded.provisionprofile
|
||||
- cp Resources/Info.plist dist/tart_darwin_all/tart.app/Contents/Info.plist
|
||||
- cp Resources/AppIcon.png dist/tart_darwin_all/tart.app/Contents/Resources/AppIcon.png
|
||||
post:
|
||||
- gon gon.hcl
|
||||
|
||||
archives:
|
||||
- name_template: "{{ .ProjectName }}"
|
||||
files:
|
||||
- src: dist/tart_darwin_all/tart.app/Contents/Info.plist
|
||||
dst: tart.app/Contents/Info.plist
|
||||
- src: dist/tart_darwin_all/tart.app/Contents/embedded.provisionprofile
|
||||
dst: tart.app/Contents/embedded.provisionprofile
|
||||
- src: dist/tart_darwin_all/tart.app/Contents/Resources/UPW Tart.icns
|
||||
dst: tart.app/Contents/Resources/UPW Tart.icns
|
||||
- src: dist/tart_darwin_all/tart.app/Contents/Resources/Assets.car
|
||||
dst: tart.app/Contents/Resources/Assets.car
|
||||
- src: dist/tart_darwin_all/tart.app/Contents/_CodeSignature/CodeResources
|
||||
dst: tart.app/Contents/_CodeSignature/CodeResources
|
||||
- src: dist/tart_darwin_all/tart.app/Contents/embedded.provisionprofile
|
||||
dst: tart.app/Contents
|
||||
strip_parent: true
|
||||
- src: dist/tart_darwin_all/tart.app/Contents/Info.plist
|
||||
dst: tart.app/Contents
|
||||
strip_parent: true
|
||||
- src: dist/tart_darwin_all/tart.app/Contents/Resources/AppIcon.png
|
||||
dst: tart.app/Contents/Resources
|
||||
strip_parent: true
|
||||
- LICENSE
|
||||
|
||||
release:
|
||||
@@ -53,14 +54,9 @@ release:
|
||||
|
||||
brews:
|
||||
- name: tart
|
||||
directory: Formula
|
||||
repository:
|
||||
owner: openai
|
||||
name: homebrew-tools
|
||||
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
|
||||
branch: "tart-{{ .Version }}"
|
||||
pull_request:
|
||||
enabled: true
|
||||
owner: cirruslabs
|
||||
name: homebrew-cli
|
||||
caveats: |
|
||||
Tart has been installed. You might want to reduce the default DHCP lease time
|
||||
from 86,400 to 600 seconds to avoid DHCP shortage when running lots of VMs daily:
|
||||
@@ -68,19 +64,15 @@ brews:
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.InternetSharing.default.plist bootpd -dict DHCPLeaseTimeSecs -int 600
|
||||
|
||||
See https://tart.run/faq/#changing-the-default-dhcp-lease-time for more details.
|
||||
homepage: https://github.com/openai/tart
|
||||
license: FSL-1.1-ALv2
|
||||
homepage: https://github.com/cirruslabs/tart
|
||||
license: "Fair Source"
|
||||
description: Run macOS and Linux VMs on Apple Hardware
|
||||
skip_upload: auto
|
||||
dependencies:
|
||||
- "openai/tools/softnet"
|
||||
- "cirruslabs/cli/softnet"
|
||||
install: |
|
||||
libexec.install Dir["*"]
|
||||
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
|
||||
generate_completions_from_executable(libexec/"tart.app/Contents/MacOS/tart", "--generate-completion-script")
|
||||
custom_block: |
|
||||
on_macos do
|
||||
depends_on :macos => :ventura
|
||||
end
|
||||
def post_install
|
||||
generate_completions_from_executable(libexec/"tart.app/Contents/MacOS/tart", "--generate-completion-script")
|
||||
end
|
||||
depends_on :macos => :ventura
|
||||
|
||||
@@ -20,7 +20,7 @@ Table of Contents
|
||||
```
|
||||
## How to Create an Issue/Enhancement
|
||||
|
||||
1. Go to the [Issue page](https://github.com/openai/tart/issues) of the repository
|
||||
1. Go to the [Issue page](https://github.com/cirruslabs/tart/issues) of the repository
|
||||
2. Click on the "New Issue" button
|
||||
3. Provide a descriptive title and detailed description of the issue or enhancement you're suggesting
|
||||
4. Submit the issue
|
||||
|
||||
@@ -1,105 +1,45 @@
|
||||
# Functional Source License, Version 1.1, ALv2 Future License
|
||||
Fair Source License, version 0.9
|
||||
|
||||
## Abbreviation
|
||||
Copyright (C) 2023 Cirrus Labs, Inc.
|
||||
|
||||
FSL-1.1-ALv2
|
||||
Licensor: Cirrus Labs, Inc.
|
||||
|
||||
## Notice
|
||||
Software: Tart
|
||||
|
||||
Copyright 2022-2026 OpenAI
|
||||
Use Limitation: 100 users. User is defined as a single core of a central processing unit (CPU) used by the product.
|
||||
The Use Limitation does not apply to CPUs installed in devices used by a single individual.
|
||||
|
||||
## Terms and Conditions
|
||||
License Grant. Licensor hereby grants to each recipient of the
|
||||
Software ("you") a non-exclusive, non-transferable, royalty-free and
|
||||
fully-paid-up license, under all of the Licensor's copyright and
|
||||
patent rights, to use, copy, distribute, prepare derivative works of,
|
||||
publicly perform and display the Software, subject to the Use
|
||||
Limitation and the conditions set forth below.
|
||||
|
||||
### Licensor ("We")
|
||||
Use Limitation. The license granted above allows use by up to the
|
||||
number of users per entity set forth above (the "Use Limitation"). For
|
||||
determining the number of users, "you" includes all affiliates,
|
||||
meaning legal entities controlling, controlled by, or under common
|
||||
control with you. If you exceed the Use Limitation, your use is
|
||||
subject to payment of Licensor's then-current list price for licenses.
|
||||
|
||||
The party offering the Software under these Terms and Conditions.
|
||||
Conditions. Redistribution in source code or other forms must include
|
||||
a copy of this license document to be provided in a reasonable
|
||||
manner. Any redistribution of the Software is only allowed subject to
|
||||
this license.
|
||||
|
||||
### The Software
|
||||
Trademarks. This license does not grant you any right in the
|
||||
trademarks, service marks, brand names or logos of Licensor.
|
||||
|
||||
The "Software" is each version of the software that we make available under
|
||||
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
||||
Conditions with the Software.
|
||||
DISCLAIMER. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OR
|
||||
CONDITION, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. LICENSORS HEREBY DISCLAIM ALL LIABILITY, WHETHER IN
|
||||
AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE.
|
||||
|
||||
### License Grant
|
||||
|
||||
Subject to your compliance with this License Grant and the Patents,
|
||||
Redistribution and Trademark clauses below, we hereby grant you the right to
|
||||
use, copy, modify, create derivative works, publicly perform, publicly display
|
||||
and redistribute the Software for any Permitted Purpose identified below.
|
||||
|
||||
### Permitted Purpose
|
||||
|
||||
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
||||
means making the Software available to others in a commercial product or
|
||||
service that:
|
||||
|
||||
1. substitutes for the Software;
|
||||
|
||||
2. substitutes for any other product or service we offer using the Software
|
||||
that exists as of the date we make the Software available; or
|
||||
|
||||
3. offers the same or substantially similar functionality as the Software.
|
||||
|
||||
Permitted Purposes specifically include using the Software:
|
||||
|
||||
1. for your internal use and access;
|
||||
|
||||
2. for non-commercial education;
|
||||
|
||||
3. for non-commercial research; and
|
||||
|
||||
4. in connection with professional services that you provide to a licensee
|
||||
using the Software in accordance with these Terms and Conditions.
|
||||
|
||||
### Patents
|
||||
|
||||
To the extent your use for a Permitted Purpose would necessarily infringe our
|
||||
patents, the license grant above includes a license under our patents. If you
|
||||
make a claim against any party that the Software infringes or contributes to
|
||||
the infringement of any patent, then your patent license to the Software ends
|
||||
immediately.
|
||||
|
||||
### Redistribution
|
||||
|
||||
The Terms and Conditions apply to all copies, modifications and derivatives of
|
||||
the Software.
|
||||
|
||||
If you redistribute any copies, modifications or derivatives of the Software,
|
||||
you must include a copy of or a link to these Terms and Conditions and not
|
||||
remove any copyright notices provided in or with the Software.
|
||||
|
||||
### Disclaimer
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
||||
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
||||
|
||||
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
||||
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
||||
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
||||
|
||||
### Trademarks
|
||||
|
||||
Except for displaying the License Details and identifying us as the origin of
|
||||
the Software, you have no right under these Terms and Conditions to use our
|
||||
trademarks, trade names, service marks or product names.
|
||||
|
||||
## Grant of Future License
|
||||
|
||||
We hereby irrevocably grant you an additional license to use the Software under
|
||||
the Apache License, Version 2.0 that is effective on the second anniversary of
|
||||
the date we make the Software available. On or after that date, you may use the
|
||||
Software under the Apache License, Version 2.0, in which case the following
|
||||
will apply:
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
this file except in compliance with the License.
|
||||
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed
|
||||
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations under the License.
|
||||
Termination. If you violate the terms of this license, your rights
|
||||
will terminate automatically and will not be reinstated without the
|
||||
prior written consent of Licensor. Any such termination will not
|
||||
affect the right of others who may have received copies of the
|
||||
Software from you.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/TartSocial.png"/>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
|
||||
|
||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon.
|
||||
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
||||
@@ -8,52 +8,67 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
||||
* Use Tart Packer Plugin to automate VM creation.
|
||||
* Easily integrates with any CI system.
|
||||
|
||||
Tart powers [Cirrus Runners](https://cirrus-runners.app/)
|
||||
service — a drop-in replacement for the standard GitHub-hosted runners, offering 2-3 times better performance for a fraction of the price.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cirrus-runners.app/?utm_source=github&utm_medium=referral" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Many companies are using Tart in their internal setups. Here are just a few of them:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://atlassian.com/" target=_blank>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Atlassian.png" height="65"/>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Atlassian.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://www.figma.com/" target=_blank>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Figma.png" height="65"/>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Figma.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://mullvad.net/" target=_blank>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://krisp.ai/" target=_blank>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://testingbot.com/" target=_blank>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://symflower.com/" target=_blank>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://transloadit.com/" target=_blank>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://cirrus-ci.org/" target=_blank>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://www.pitsdatarecovery.net/" target=_blank>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://expo.dev/" target=_blank>
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/Users/Expo.png" height="65"/>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Expo.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**Note:** If your company or project is using Tart please consider [sharing with the community](https://github.com/openai/tart/discussions/857).
|
||||
**Note:** If your company or project is using Tart please consider [sharing with the community](https://github.com/cirruslabs/tart/discussions/857).
|
||||
|
||||
<p align="center">
|
||||
<a href="https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws?utm_source=github&utm_medium=referral" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/AWSMarkeplaceLogo.png" height="90"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Usage
|
||||
|
||||
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
|
||||
|
||||
```bash
|
||||
brew install openai/tools/tart
|
||||
brew install cirruslabs/cli/tart
|
||||
tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest tahoe-base
|
||||
tart run tahoe-base
|
||||
```
|
||||
|
||||
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/openai/tart/discussions)
|
||||
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
|
||||
for remaining questions.
|
||||
|
||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 120 KiB |
@@ -10,14 +10,12 @@
|
||||
<string>com.github.cirruslabs.tart</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>tart</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>UPW Tart</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>UPW Tart</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon.png</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 42 KiB |
@@ -1,140 +0,0 @@
|
||||
{
|
||||
"fill" : "automatic",
|
||||
"groups" : [
|
||||
{
|
||||
"blend-mode" : "normal",
|
||||
"blur-material" : 0.5,
|
||||
"layers" : [
|
||||
{
|
||||
"hidden" : false,
|
||||
"image-name-specializations" : [
|
||||
{
|
||||
"value" : "4.4-–-layer.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "square",
|
||||
"value" : "UPW Tart L4.png"
|
||||
}
|
||||
],
|
||||
"name" : "UPW Tart L4"
|
||||
}
|
||||
],
|
||||
"opacity" : 1,
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 1
|
||||
},
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.25
|
||||
}
|
||||
},
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name-specializations" : [
|
||||
{
|
||||
"value" : "3.3-–-layer.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "square",
|
||||
"value" : "UPW Tart L3.png"
|
||||
}
|
||||
],
|
||||
"name" : "UPW Tart L3",
|
||||
"position-specializations" : [
|
||||
{
|
||||
"idiom" : "square",
|
||||
"value" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "none",
|
||||
"opacity" : 1
|
||||
},
|
||||
"specular" : false,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.25
|
||||
}
|
||||
},
|
||||
{
|
||||
"blur-material" : null,
|
||||
"layers" : [
|
||||
{
|
||||
"image-name-specializations" : [
|
||||
{
|
||||
"value" : "2.2-–-layer.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "square",
|
||||
"value" : "UPW Tart L2.png"
|
||||
}
|
||||
],
|
||||
"name" : "UPW Tart L2"
|
||||
}
|
||||
],
|
||||
"position-specializations" : [
|
||||
{
|
||||
"idiom" : "square",
|
||||
"value" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "none",
|
||||
"opacity" : 1
|
||||
},
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.25
|
||||
}
|
||||
},
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name-specializations" : [
|
||||
{
|
||||
"value" : "1.1-–-layer.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "square",
|
||||
"value" : "UPW Tart L1.png"
|
||||
}
|
||||
],
|
||||
"name" : "UPW Tart L1"
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "layer-color",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.25
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>UPW Tart</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>UPW Tart</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,5 +1,5 @@
|
||||
struct CI {
|
||||
private static let rawVersion = "${VERSION}"
|
||||
private static let rawVersion = "${CIRRUS_TAG}"
|
||||
|
||||
static var version: String {
|
||||
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
|
||||
|
||||
@@ -31,9 +31,6 @@ struct Clone: AsyncParsableCommand {
|
||||
@Flag(help: .hidden)
|
||||
var deduplicate: Bool = false
|
||||
|
||||
@Flag(help: "create a stacked disk that uses the source image as an immutable base")
|
||||
var stacked: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
|
||||
var pruneLimit: UInt = 100
|
||||
|
||||
@@ -50,43 +47,14 @@ struct Clone: AsyncParsableCommand {
|
||||
func run() async throws {
|
||||
let ociStorage = try VMStorageOCI()
|
||||
let localStorage = try VMStorageLocal()
|
||||
let remoteName = try? RemoteName(sourceName)
|
||||
|
||||
if stacked {
|
||||
guard remoteName != nil else {
|
||||
throw ValidationError("--stacked requires a remote image")
|
||||
}
|
||||
try DiskImageStack.requireSupport()
|
||||
}
|
||||
|
||||
if let remoteName, try !ociStorage.hasUsableCachedImageForClone(remoteName, requireManifest: stacked) {
|
||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
var resolvedManifest: (manifest: OCIManifest, data: Data)?
|
||||
|
||||
// Fail before pulling disk content when this host cannot create a writable stacked disk.
|
||||
if !stacked {
|
||||
let (manifest, manifestData) = try await registry.pullManifest(reference: remoteName.reference.value)
|
||||
if manifest.layers.contains(where: { $0.mediaType == asifOverlayMediaType }) {
|
||||
try DiskImageStack.requireSupport()
|
||||
}
|
||||
resolvedManifest = (manifest, manifestData)
|
||||
}
|
||||
|
||||
try await ociStorage.pull(
|
||||
remoteName,
|
||||
registry: registry,
|
||||
concurrency: concurrency,
|
||||
deduplicate: deduplicate,
|
||||
requireManifest: stacked,
|
||||
resolvedManifest: resolvedManifest
|
||||
)
|
||||
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
|
||||
}
|
||||
|
||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||
if sourceVM.isStackedVM || sourceVM.isStackedCachedImage {
|
||||
try DiskImageStack.requireSupport()
|
||||
}
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
@@ -98,28 +66,9 @@ struct Clone: AsyncParsableCommand {
|
||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
try lock.lock()
|
||||
|
||||
let sourceState = try sourceVM.state()
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
&& sourceState != .Suspended
|
||||
|
||||
if stacked {
|
||||
guard sourceVM.isStandalone else {
|
||||
throw ValidationError("--stacked cannot use an image that already has a stacked disk")
|
||||
}
|
||||
guard try VMConfig(fromURL: sourceVM.configURL).os == .darwin else {
|
||||
throw ValidationError("--stacked currently supports only macOS images")
|
||||
}
|
||||
try sourceVM.cloneAsStackedBase(to: tmpVMDir, generateMAC: generateMAC)
|
||||
} else if sourceVM.isStackedCachedImage {
|
||||
try sourceVM.cloneStacked(to: tmpVMDir, copyWritableOverlay: false, generateMAC: generateMAC)
|
||||
} else if sourceVM.isStackedVM {
|
||||
guard sourceState == .Stopped else {
|
||||
throw RuntimeError.VMConfigurationError("VM \"\(sourceName)\" must be stopped before cloning")
|
||||
}
|
||||
try sourceVM.cloneStacked(to: tmpVMDir, copyWritableOverlay: true, generateMAC: generateMAC)
|
||||
} else {
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
}
|
||||
&& sourceVM.state() != .Suspended
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
|
||||
@@ -129,26 +78,14 @@ struct Clone: AsyncParsableCommand {
|
||||
// is not actually claiming new space until the VM is started and it writes something to disk.
|
||||
//
|
||||
// So, once we clone the VM let's try to claim the rest of space for the VM to run without errors.
|
||||
if sourceVM.isStandalone {
|
||||
let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
|
||||
// Avoid reclaiming an excessive amount of disk space.
|
||||
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
|
||||
if reclaimBytes > 0 {
|
||||
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM)
|
||||
}
|
||||
} else if sourceVM.isStackedVM || sourceVM.isStackedCachedImage {
|
||||
let clonedVM = try localStorage.open(newName)
|
||||
// A stacked clone owns only its writable overlay locally, but that
|
||||
// overlay may grow to the full guest-visible disk block layout at
|
||||
// runtime. Reclaim against the clone so it is not pruned itself.
|
||||
let unallocatedBytes = try clonedVM.diskSizeBytes() - clonedVM.allocatedSizeBytes()
|
||||
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
|
||||
if reclaimBytes > 0 {
|
||||
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), clonedVM)
|
||||
}
|
||||
let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
|
||||
// Avoid reclaiming an excessive amount of disk space.
|
||||
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
|
||||
if reclaimBytes > 0 {
|
||||
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM)
|
||||
}
|
||||
}, onCancel: {
|
||||
try? tmpVMDir.removeFromDisk()
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,10 +74,6 @@ struct Create: AsyncParsableCommand {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize, diskFormat: diskFormat)
|
||||
}
|
||||
|
||||
// Publish under the same lock that run holds while opening VM files.
|
||||
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
try storageLock.lock()
|
||||
defer { withExtendedLifetime(storageLock) {} }
|
||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import NIOPosix
|
||||
import GRPC
|
||||
import Cirruslabs_TartGuestAgent_Grpc_Swift
|
||||
|
||||
@@ -40,12 +41,27 @@ struct Exec: AsyncParsableCommand {
|
||||
throw RuntimeError.VMNotRunning(name)
|
||||
}
|
||||
|
||||
// Create a gRPC channel connected to the VM's control socket
|
||||
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
defer {
|
||||
try! group.syncShutdownGracefully()
|
||||
}
|
||||
|
||||
// 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.absoluteURL.path(percentEncoded: false))
|
||||
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
|
||||
}
|
||||
|
||||
let channel = try GRPCChannelPool.with(
|
||||
target: .unixDomainSocket(vmDir.controlSocketURL.relativePath),
|
||||
transportSecurity: .plaintext,
|
||||
eventLoopGroup: group,
|
||||
)
|
||||
defer {
|
||||
try! channel.close().wait()
|
||||
}
|
||||
|
||||
// Switch controlling terminal into raw mode when remote pseudo-terminal is requested
|
||||
@@ -63,10 +79,7 @@ struct Exec: AsyncParsableCommand {
|
||||
|
||||
// Execute a command in a running VM
|
||||
do {
|
||||
let controlSocketPath = vmDir.controlSocketURL.relativePath
|
||||
try await withGuestAgentChannel(unixDomainSocketPath: controlSocketPath) { channel in
|
||||
try await execute(channel)
|
||||
}
|
||||
try await execute(channel)
|
||||
} catch let error as GRPCConnectionPoolError {
|
||||
throw RuntimeError.Generic("Failed to connect to the VM using its control socket: \(error.localizedDescription), is the Tart Guest Agent running?")
|
||||
}
|
||||
@@ -129,11 +142,6 @@ struct Exec: AsyncParsableCommand {
|
||||
let data = handle.availableData
|
||||
|
||||
if data.isEmpty {
|
||||
// EOF: unregister the handler, otherwise the fd stays permanently
|
||||
// "readable" and Foundation re-invokes us in a tight loop, burning
|
||||
// 100% of a core for the rest of the command's lifetime
|
||||
handle.readabilityHandler = nil
|
||||
|
||||
continuation.finish()
|
||||
} else {
|
||||
continuation.yield(data)
|
||||
|
||||
@@ -37,7 +37,7 @@ struct Export: AsyncParsableCommand {
|
||||
func userWantsOverwrite(_ filename: String) -> Bool {
|
||||
print("file \(filename) already exists, are you sure you want to overwrite it? (yes, [no])? ", terminator: "")
|
||||
|
||||
let answer = readLine()
|
||||
let answer = readLine()!
|
||||
|
||||
return answer == "yes"
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ fileprivate struct VMInfo: Encodable {
|
||||
let OS: OS
|
||||
let CPU: Int
|
||||
let Memory: UInt64
|
||||
let Disk: HumanReadableByteCount
|
||||
let Disk: Int
|
||||
let DiskFormat: String
|
||||
let Size: HumanReadableByteCount
|
||||
let Size: String
|
||||
let Display: String
|
||||
let Running: Bool
|
||||
let State: String
|
||||
@@ -27,20 +27,7 @@ struct Get: AsyncParsableCommand {
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||
|
||||
let info = VMInfo(
|
||||
OS: vmConfig.os,
|
||||
CPU: vmConfig.cpuCount,
|
||||
Memory: memorySizeInMb,
|
||||
// ASIF capacity lookup can fail while a running VM holds the disk open.
|
||||
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||
DiskFormat: vmConfig.diskFormat.rawValue,
|
||||
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) {
|
||||
String(format: "%.3f", Float($0) / 1000 / 1000 / 1000)
|
||||
},
|
||||
Display: vmConfig.display.description,
|
||||
Running: try vmDir.running(),
|
||||
State: try vmDir.state().rawValue
|
||||
)
|
||||
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
|
||||
print(format.renderSingle(info))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ struct IP: AsyncParsableCommand {
|
||||
//
|
||||
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
|
||||
if let baseURL = controlSocketURL.baseURL {
|
||||
FileManager.default.changeCurrentDirectoryPath(baseURL.absoluteURL.path(percentEncoded: false))
|
||||
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
|
||||
}
|
||||
|
||||
if let ip = try await AgentResolver.ResolveIP(controlSocketURL.relativePath) {
|
||||
|
||||
@@ -21,9 +21,6 @@ struct Import: AsyncParsableCommand {
|
||||
|
||||
// Create a temporary VM directory to which we will load the export file
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
defer {
|
||||
try? tmpVMDir.removeFromDisk()
|
||||
}
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
// while we're running
|
||||
@@ -33,9 +30,6 @@ struct Import: AsyncParsableCommand {
|
||||
// Populate the temporary VM directory with the export file contents
|
||||
print("importing...")
|
||||
try tmpVMDir.importFromArchive(path: path)
|
||||
guard tmpVMDir.initialized else {
|
||||
throw RuntimeError.ImportFailed("archive does not contain a runnable VM")
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
// Acquire a global lock
|
||||
@@ -51,7 +45,7 @@ struct Import: AsyncParsableCommand {
|
||||
|
||||
try lock.unlock()
|
||||
}, onCancel: {
|
||||
try? tmpVMDir.removeFromDisk()
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import SwiftUI
|
||||
fileprivate struct VMInfo: Encodable {
|
||||
let Source: String
|
||||
let Name: String
|
||||
let Disk: HumanReadableByteCount
|
||||
let Size: HumanReadableByteCount
|
||||
let Disk: Int
|
||||
let Size: Int
|
||||
let Accessed: String
|
||||
let Running: Bool
|
||||
let State: String
|
||||
@@ -42,9 +42,8 @@ struct List: AsyncParsableCommand {
|
||||
try VMInfo(
|
||||
Source: "local",
|
||||
Name: name,
|
||||
// ASIF capacity lookup can fail while a running VM holds the disk open.
|
||||
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||
Disk: vmDir.sizeGB(),
|
||||
Size: vmDir.allocatedSizeGB(),
|
||||
Accessed: formatAccessDate(try vmDir.accessDate()),
|
||||
Running: vmDir.running(),
|
||||
State: vmDir.state().rawValue
|
||||
@@ -57,8 +56,8 @@ struct List: AsyncParsableCommand {
|
||||
try VMInfo(
|
||||
Source: "OCI",
|
||||
Name: name,
|
||||
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||
Disk: vmDir.sizeGB(),
|
||||
Size: vmDir.allocatedSizeGB(),
|
||||
Accessed: formatAccessDate(try vmDir.accessDate()),
|
||||
Running: vmDir.running(),
|
||||
State: vmDir.state().rawValue
|
||||
|
||||
@@ -81,34 +81,27 @@ struct Prune: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
|
||||
while true {
|
||||
let prunables: [Prunable] = try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() > $1.accessDate() }
|
||||
let prunables: [Prunable] = try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() > $1.accessDate() }
|
||||
|
||||
var remainingBudgetBytes = spaceBudgetBytes
|
||||
var prunableToDelete: Prunable?
|
||||
var spaceBudgetBytes = spaceBudgetBytes
|
||||
var prunablesToDelete: [Prunable] = []
|
||||
|
||||
for prunable in prunables {
|
||||
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
|
||||
for prunable in prunables {
|
||||
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
|
||||
|
||||
if prunableSizeBytes <= remainingBudgetBytes {
|
||||
// Don't mark for deletion as there is budget available
|
||||
remainingBudgetBytes -= prunableSizeBytes
|
||||
} else {
|
||||
prunableToDelete = prunable
|
||||
break
|
||||
}
|
||||
if prunableSizeBytes <= spaceBudgetBytes {
|
||||
// Don't mark for deletion as
|
||||
// there's a budget available
|
||||
spaceBudgetBytes -= prunableSizeBytes
|
||||
} else {
|
||||
// Mark for deletion
|
||||
prunablesToDelete.append(prunable)
|
||||
}
|
||||
|
||||
guard let prunableToDelete else {
|
||||
return
|
||||
}
|
||||
|
||||
// Deleting one cached stacked image can change which remaining image
|
||||
// owns shared immutable content. Rebuild before choosing another.
|
||||
try prunableToDelete.delete()
|
||||
}
|
||||
|
||||
try prunablesToDelete.forEach { try $0.delete() }
|
||||
}
|
||||
|
||||
static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
||||
@@ -152,51 +145,46 @@ struct Prune: AsyncParsableCommand {
|
||||
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated, initiator)
|
||||
}
|
||||
|
||||
static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
||||
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
||||
let span = OTel.shared.tracer.spanBuilder(spanName: "prune").startSpan()
|
||||
defer { span.end() }
|
||||
|
||||
let prunableStorages: [PrunableStorage] = [try VMStorageOCI(), try IPSWCache()]
|
||||
let prunables = {
|
||||
try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||
}
|
||||
let prunables: [Prunable] = try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||
|
||||
// Does it even make sense to start?
|
||||
let initialPrunables = try prunables()
|
||||
let initialCacheUsedBytes = try initialPrunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
|
||||
guard let reclaimBytes = Int(exactly: reclaimBytes), initialCacheUsedBytes >= reclaimBytes else {
|
||||
let cacheUsedBytes = try prunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
|
||||
if cacheUsedBytes < reclaimBytes {
|
||||
return
|
||||
}
|
||||
|
||||
let targetCacheUsedBytes = initialCacheUsedBytes - reclaimBytes
|
||||
var currentCacheUsedBytes = initialCacheUsedBytes
|
||||
let initiatorPath = initiator.map {
|
||||
$0.url.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
}
|
||||
var cacheReclaimedBytes: Int = 0
|
||||
|
||||
while currentCacheUsedBytes > targetCacheUsedBytes {
|
||||
// Deleting one cached stacked image can transfer ownership of shared
|
||||
// immutable content to another record without reclaiming those bytes.
|
||||
// Rebuild the candidates after every deletion so automatic pruning
|
||||
// measures the cache that remains rather than a stale ownership snapshot.
|
||||
guard let prunable = try prunables().first(where: {
|
||||
$0.url.resolvingSymlinksInPath().standardizedFileURL.path != initiatorPath
|
||||
}) else {
|
||||
var it = prunables.makeIterator()
|
||||
|
||||
while cacheReclaimedBytes <= reclaimBytes {
|
||||
guard let prunable = it.next() else {
|
||||
break
|
||||
}
|
||||
|
||||
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
|
||||
// do not prune the initiator
|
||||
continue
|
||||
}
|
||||
|
||||
let allocatedSizeBytes = try prunable.allocatedSizeBytes()
|
||||
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?
|
||||
.addEvent(name: "Pruned \(allocatedSizeBytes) bytes for \(prunable.url.path)")
|
||||
|
||||
cacheReclaimedBytes += allocatedSizeBytes
|
||||
|
||||
try prunable.delete()
|
||||
currentCacheUsedBytes = try prunables().map { try $0.allocatedSizeBytes() }.reduce(0, +)
|
||||
}
|
||||
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?
|
||||
.addEvent(name: "Reclaimed \(initialCacheUsedBytes - currentCacheUsedBytes) bytes")
|
||||
.addEvent(name: "Reclaimed \(cacheReclaimedBytes) bytes")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ struct Push: AsyncParsableCommand {
|
||||
let references = remoteNamesForRegistry.map{ $0.reference.value }
|
||||
|
||||
let pushedRemoteName: RemoteName
|
||||
// If we're pushing a cached remote image, check if it points to an existing registry manifest
|
||||
// If we're pushing a local OCI VM, check if points to an already existing registry manifest
|
||||
// and if so, only upload manifests (without config, disk and NVRAM) to the user-specified references
|
||||
if let remoteName = try? RemoteName(localName) {
|
||||
pushedRemoteName = try await lightweightPushToRegistry(
|
||||
@@ -78,18 +78,17 @@ struct Push: AsyncParsableCommand {
|
||||
references: references
|
||||
)
|
||||
} else {
|
||||
let pushedImage = try await localVMDir.pushToRegistry(
|
||||
pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||
registry: registry,
|
||||
references: references,
|
||||
chunkSizeMb: chunkSize,
|
||||
concurrency: concurrency,
|
||||
labels: parseLabels()
|
||||
)
|
||||
pushedRemoteName = pushedImage.name
|
||||
|
||||
// Populate the local cache (if requested)
|
||||
if populateCache {
|
||||
try ociStorage.populate(pushedImage.name, from: localVMDir, manifest: pushedImage.manifest)
|
||||
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
||||
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +102,7 @@ struct Push: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func lightweightPushToRegistry(registry: Registry, remoteName: RemoteName, references: [String]) async throws -> RemoteName {
|
||||
// Is the cached remote image already present in the registry?
|
||||
// Is the local OCI VM already present in the registry?
|
||||
let digest = try VMStorageOCI().digest(remoteName)
|
||||
|
||||
let (remoteManifest, _) = try await registry.pullManifest(reference: digest)
|
||||
|
||||
@@ -18,9 +18,6 @@ struct Rename: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
let localStorage = try VMStorageLocal()
|
||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
try lock.lock()
|
||||
defer { withExtendedLifetime(lock) {} }
|
||||
|
||||
if !localStorage.exists(name) {
|
||||
throw ValidationError("failed to rename a non-existent local VM: \(name)")
|
||||
|
||||
@@ -90,9 +90,6 @@ struct Run: AsyncParsableCommand {
|
||||
@Flag(help: "Disable audio pass-through to host.")
|
||||
var noAudio: Bool = false
|
||||
|
||||
@Flag(help: "Disable USB accessories.")
|
||||
var noUSBAccessories: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Disable clipboard sharing between host and guest.",
|
||||
discussion: "Clipboard sharing requires spice-vdagent package on Linux and https://github.com/cirruslabs/tart-guest-agent on macOS."))
|
||||
@@ -227,13 +224,6 @@ struct Run: AsyncParsableCommand {
|
||||
""", valueName: "comma-separated CIDRs"))
|
||||
var netSoftnetBlock: String?
|
||||
|
||||
@Option(help: ArgumentHelp("Connected Unix stream socket file descriptor to use for the Softnet control channel (e.g. --net-softnet-control-fd=3)", discussion: """
|
||||
This option enables the Softnet control channel on an inherited Unix stream socket. It can be used to dynamically replace Softnet allow and block lists while the VM is running.
|
||||
|
||||
The file descriptor must be greater than 2. Implies --net-softnet.
|
||||
""", valueName: "file descriptor"))
|
||||
var netSoftnetControlFd: Int32?
|
||||
|
||||
@Option(help: ArgumentHelp("Comma-separated list of TCP ports to expose (e.g. --net-softnet-expose 2222:22,8080:80)", discussion: """
|
||||
Options are comma-separated and are as follows:
|
||||
|
||||
@@ -295,35 +285,13 @@ struct Run: AsyncParsableCommand {
|
||||
@Flag(help: ArgumentHelp("Disable the keyboard"))
|
||||
var noKeyboard: Bool = false
|
||||
|
||||
#if arch(arm64) && compiler(>=6.4)
|
||||
@Option(help: ArgumentHelp("Provision a macOS guest on first boot using the guest provisioning API", discussion: """
|
||||
Takes a comma-separated list of key=value pairs that configure the initial setup of a macOS guest
|
||||
|
||||
Requires the host to be running macOS 27 (or newer) and only takes effect on the first boot after
|
||||
creation of a macOS 27 (or newer) guest VM.
|
||||
|
||||
Supported keys (matching VZMacGuestProvisioningOptions):
|
||||
|
||||
* fullName=<NAME> — the person's full name to configure
|
||||
|
||||
* username=<USERNAME> — the username for logging into the guest
|
||||
|
||||
* password=<PASSWORD> — the password to configure for the guest
|
||||
|
||||
* logsInAutomatically=true|false — whether to automatically log the person in at startup
|
||||
|
||||
* enablesRemoteLogin=true|false — whether to enable Remote Login (SSH) in the guest
|
||||
""", valueName: "key=value,..."))
|
||||
var provisioningOpts: String?
|
||||
#endif
|
||||
|
||||
mutating func validate() throws {
|
||||
if vnc && vncExperimental {
|
||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||
}
|
||||
|
||||
// Automatically enable --net-softnet when any of its related options are specified
|
||||
if netSoftnetAllow != nil || netSoftnetBlock != nil || netSoftnetExpose != nil || netSoftnetControlFd != nil {
|
||||
if netSoftnetAllow != nil || netSoftnetBlock != nil || netSoftnetExpose != nil {
|
||||
netSoftnet = true
|
||||
}
|
||||
|
||||
@@ -384,19 +352,6 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
#if arch(arm64) && compiler(>=6.4)
|
||||
if provisioningOpts != nil {
|
||||
if #unavailable(macOS 27) {
|
||||
throw ValidationError("--provisioning-opts requires the host to be running macOS 27 (or newer)")
|
||||
}
|
||||
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
if config.os != .darwin {
|
||||
throw ValidationError("--provisioning-opts can only be used with macOS VMs")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
for disk in disk {
|
||||
if disk.hasSuffix("-amd64.iso") {
|
||||
throw ValidationError("Seems you have a disk targeting x86 architecture (hence amd64 in the name). Please use an 'arm64' version of the disk.")
|
||||
@@ -405,7 +360,7 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runOnMainThread() throws {
|
||||
func run() async throws {
|
||||
let localStorage = try VMStorageLocal()
|
||||
let vmDir = try localStorage.open(name)
|
||||
|
||||
@@ -453,27 +408,16 @@ struct Run: AsyncParsableCommand {
|
||||
// Parse root disk options
|
||||
let diskOptions = DiskOptions(rootDiskOpts)
|
||||
|
||||
// Parse guest provisioning options
|
||||
#if arch(arm64) && compiler(>=6.4)
|
||||
let provisioning = try provisioningOpts.map { try GuestProvisioningOptions($0) }
|
||||
#endif
|
||||
|
||||
// Keep these values alive while the VM runs. Some additional disks own a
|
||||
// lock that protects their temporary backing files from Config.gc().
|
||||
let additionalDisks = try additionalDisks()
|
||||
defer { withExtendedLifetime(additionalDisks) {} }
|
||||
|
||||
vm = try VM(
|
||||
vmDir: vmDir,
|
||||
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||
additionalStorageDevices: additionalDisks.map(\.configuration),
|
||||
additionalStorageDevices: try additionalDiskAttachments(),
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable,
|
||||
nested: nested,
|
||||
audio: !noAudio,
|
||||
clipboard: !noClipboard,
|
||||
noUSBAccessories: noUSBAccessories,
|
||||
sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw),
|
||||
caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw),
|
||||
noTrackpad: noTrackpad,
|
||||
@@ -529,11 +473,7 @@ struct Run: AsyncParsableCommand {
|
||||
#endif
|
||||
|
||||
do {
|
||||
#if arch(arm64) && compiler(>=6.4)
|
||||
try await vm!.start(recovery: recovery, resume: resume, provisioning: provisioning)
|
||||
#else
|
||||
try await vm!.start(recovery: recovery, resume: resume)
|
||||
#endif
|
||||
try await vm!.start(recovery: recovery, resume: resume)
|
||||
} catch let error as VZError {
|
||||
if error.code == .virtualMachineLimitExceeded {
|
||||
var hint = ""
|
||||
@@ -574,10 +514,8 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
if #available(macOS 14, *) {
|
||||
let controlSocket = try await ControlSocket(vmDir.controlSocketURL)
|
||||
|
||||
ErrorReportingTask("Failed to run control socket") {
|
||||
try await controlSocket.run()
|
||||
Task {
|
||||
try await ControlSocket(vmDir.controlSocketURL).run()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,7 +586,7 @@ struct Run: AsyncParsableCommand {
|
||||
signal(SIGUSR2, SIG_IGN)
|
||||
let sigusr2Src = DispatchSource.makeSignalSource(signal: SIGUSR2)
|
||||
sigusr2Src.setEventHandler {
|
||||
ErrorReportingTask("Failed to request guest OS to stop") {
|
||||
Task {
|
||||
print("Requesting guest OS to stop...")
|
||||
try vm!.virtualMachine.requestStop()
|
||||
}
|
||||
@@ -699,13 +637,13 @@ struct Run: AsyncParsableCommand {
|
||||
if netSoftnet {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: softnetExtraArguments, controlFD: netSoftnetControlFd)
|
||||
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: softnetExtraArguments)
|
||||
}
|
||||
|
||||
if netHost {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"] + softnetExtraArguments, controlFD: netSoftnetControlFd)
|
||||
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"] + softnetExtraArguments)
|
||||
}
|
||||
|
||||
if netBridged.count > 0 {
|
||||
@@ -738,9 +676,9 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
func additionalDisks() throws -> [AdditionalDisk] {
|
||||
func additionalDiskAttachments() throws -> [VZStorageDeviceConfiguration] {
|
||||
try disk.map {
|
||||
try AdditionalDisk(parseFrom: $0)
|
||||
try AdditionalDisk(parseFrom: $0).configuration
|
||||
}
|
||||
}
|
||||
|
||||
@@ -820,11 +758,6 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// "tart run" drives an AppKit/SwiftUI run loop and therefore must own the main
|
||||
// thread at the top level, so it opts out of Root's asynchronous command path.
|
||||
// See Root.main() for the rationale.
|
||||
extension Run: MainThreadCommand {}
|
||||
|
||||
struct MainApp: App {
|
||||
static var suspendable: Bool = false
|
||||
static var capturesSystemKeys: Bool = false
|
||||
@@ -865,13 +798,13 @@ struct MainApp: App {
|
||||
CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) }
|
||||
CommandMenu("Control") {
|
||||
Button("Start") {
|
||||
ErrorReportingTask("Failed to start VM") { try await vm!.virtualMachine.start() }
|
||||
Task { try await vm!.virtualMachine.start() }
|
||||
}
|
||||
Button("Stop") {
|
||||
ErrorReportingTask("Failed to stop VM") { try await vm!.virtualMachine.stop() }
|
||||
Task { try await vm!.virtualMachine.stop() }
|
||||
}
|
||||
Button("Request Stop") {
|
||||
ErrorReportingTask("Failed to request VM stop") { try vm!.virtualMachine.requestStop() }
|
||||
Task { try vm!.virtualMachine.requestStop() }
|
||||
}
|
||||
if #available(macOS 14, *) {
|
||||
if (MainApp.suspendable) {
|
||||
@@ -963,32 +896,14 @@ struct VMView: NSViewRepresentable {
|
||||
|
||||
struct AdditionalDisk {
|
||||
let configuration: VZStorageDeviceConfiguration
|
||||
// Retained for as long as the additional disk is attached, so Config.gc()
|
||||
// cannot remove a temporary backing file or stacked-disk directory.
|
||||
private let temporaryDiskLock: FileLock?
|
||||
|
||||
init(parseFrom: String) throws {
|
||||
let (diskPath, readOnly, syncModeRaw, cachingModeRaw) = Self.parseOptions(parseFrom)
|
||||
|
||||
self = try Self.craft(
|
||||
diskPath,
|
||||
readOnly: readOnly,
|
||||
syncModeRaw: syncModeRaw,
|
||||
cachingModeRaw: cachingModeRaw
|
||||
)
|
||||
self.configuration = try Self.craft(diskPath, readOnly: readOnly, syncModeRaw: syncModeRaw, cachingModeRaw: cachingModeRaw)
|
||||
}
|
||||
|
||||
private init(configuration: VZStorageDeviceConfiguration, temporaryDiskLock: FileLock? = nil) {
|
||||
self.configuration = configuration
|
||||
self.temporaryDiskLock = temporaryDiskLock
|
||||
}
|
||||
|
||||
private static func craft(
|
||||
_ diskPath: String,
|
||||
readOnly diskReadOnly: Bool,
|
||||
syncModeRaw: String,
|
||||
cachingModeRaw: String
|
||||
) throws -> AdditionalDisk {
|
||||
static func craft(_ diskPath: String, readOnly diskReadOnly: Bool, syncModeRaw: String, cachingModeRaw: String) throws -> VZStorageDeviceConfiguration {
|
||||
let diskURL = URL(string: diskPath)
|
||||
|
||||
if (["nbd", "nbds", "nbd+unix", "nbds+unix"].contains(diskURL?.scheme)) {
|
||||
@@ -1003,7 +918,7 @@ struct AdditionalDisk {
|
||||
synchronizationMode: try VZDiskSynchronizationMode(syncModeRaw)
|
||||
)
|
||||
|
||||
return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment))
|
||||
return VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment)
|
||||
}
|
||||
|
||||
// Expand the tilde (~) since at this point we're dealing with a local path,
|
||||
@@ -1034,37 +949,13 @@ struct AdditionalDisk {
|
||||
let blockAttachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: FileHandle(fileDescriptor: fd, closeOnDealloc: true),
|
||||
readOnly: diskReadOnly, synchronizationMode: try VZDiskSynchronizationMode(syncModeRaw))
|
||||
|
||||
return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: blockAttachment))
|
||||
return VZVirtioBlockDeviceConfiguration(attachment: blockAttachment)
|
||||
}
|
||||
|
||||
// Support remote VM names in --disk command-line argument
|
||||
if let remoteName = try? RemoteName(diskPath) {
|
||||
let vmDir = try VMStorageOCI().open(remoteName)
|
||||
|
||||
if vmDir.isStackedCachedImage {
|
||||
// A cached stacked image has no writable top overlay. Create one in a
|
||||
// disposable directory for this additional-disk attachment.
|
||||
let temporaryVMDir = try VMDirectory.temporary()
|
||||
let temporaryVMDirLock = try FileLock(lockURL: temporaryVMDir.baseURL)
|
||||
try temporaryVMDirLock.lock()
|
||||
try vmDir.cloneStacked(
|
||||
to: temporaryVMDir,
|
||||
copyWritableOverlay: false,
|
||||
generateMAC: false
|
||||
)
|
||||
let stack = try temporaryVMDir.diskImageStack()
|
||||
let attachment = try stack.makeAttachment(
|
||||
readOnly: diskReadOnly,
|
||||
cachingMode: try VZDiskImageCachingMode(cachingModeRaw) ?? .automatic,
|
||||
synchronizationMode: try VZDiskImageSynchronizationMode(syncModeRaw)
|
||||
)
|
||||
|
||||
return AdditionalDisk(
|
||||
configuration: VZVirtioBlockDeviceConfiguration(attachment: attachment),
|
||||
temporaryDiskLock: temporaryVMDirLock
|
||||
)
|
||||
}
|
||||
|
||||
// Unfortunately, VZDiskImageStorageDeviceAttachment does not support
|
||||
// FileHandle, so we can't easily clone the disk, open it and unlink(2)
|
||||
// to simplify the garbage collection, so use an intermediate directory.
|
||||
@@ -1077,7 +968,7 @@ struct AdditionalDisk {
|
||||
|
||||
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(url: clonedDiskURL, readOnly: diskReadOnly)
|
||||
|
||||
return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment), temporaryDiskLock: lock)
|
||||
return VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment)
|
||||
}
|
||||
|
||||
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
|
||||
@@ -1093,7 +984,7 @@ struct AdditionalDisk {
|
||||
synchronizationMode: try VZDiskImageSynchronizationMode(syncModeRaw)
|
||||
)
|
||||
|
||||
return AdditionalDisk(configuration: VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment))
|
||||
return VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment)
|
||||
}
|
||||
|
||||
static func parseOptions(_ parseFrom: String) -> (String, Bool, String, String) {
|
||||
@@ -1135,81 +1026,6 @@ struct DiskOptions {
|
||||
}
|
||||
}
|
||||
|
||||
struct GuestProvisioningOptions {
|
||||
var fullName: String?
|
||||
var username: String?
|
||||
var password: String?
|
||||
var logsInAutomatically: Bool?
|
||||
var enablesRemoteLogin: Bool?
|
||||
|
||||
init(_ parseFrom: String) throws {
|
||||
for pair in parseFrom.split(separator: ",") {
|
||||
let keyValue = pair.split(separator: "=", maxSplits: 1)
|
||||
guard keyValue.count == 2 else {
|
||||
throw RuntimeError.VMConfigurationError("invalid provisioning option \"\(pair)\", expected key=value")
|
||||
}
|
||||
|
||||
let key = String(keyValue[0])
|
||||
let value = String(keyValue[1])
|
||||
|
||||
switch key {
|
||||
case "fullName":
|
||||
self.fullName = value
|
||||
case "username":
|
||||
self.username = value
|
||||
case "password":
|
||||
self.password = value
|
||||
case "logsInAutomatically":
|
||||
self.logsInAutomatically = try Self.parseBool(key, value)
|
||||
case "enablesRemoteLogin":
|
||||
self.enablesRemoteLogin = try Self.parseBool(key, value)
|
||||
default:
|
||||
throw RuntimeError.VMConfigurationError("unsupported provisioning option \"\(key)\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseBool(_ key: String, _ value: String) throws -> Bool {
|
||||
switch value {
|
||||
case "true":
|
||||
return true
|
||||
case "false":
|
||||
return false
|
||||
default:
|
||||
throw RuntimeError.VMConfigurationError("invalid value \"\(value)\" for provisioning option \"\(key)\", expected \"true\" or \"false\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if arch(arm64) && compiler(>=6.4)
|
||||
@available(macOS 27, *)
|
||||
extension GuestProvisioningOptions {
|
||||
func toVZMacGuestProvisioningOptions() throws -> VZMacGuestProvisioningOptions {
|
||||
let options = VZMacGuestProvisioningOptions()
|
||||
|
||||
if let fullName = fullName {
|
||||
options.fullName = fullName
|
||||
}
|
||||
if let username = username {
|
||||
options.username = username
|
||||
}
|
||||
if let password = password {
|
||||
options.password = password
|
||||
}
|
||||
if let logsInAutomatically = logsInAutomatically {
|
||||
options.logsInAutomatically = logsInAutomatically
|
||||
}
|
||||
if let enablesRemoteLogin = enablesRemoteLogin {
|
||||
options.enablesRemoteLogin = enablesRemoteLogin
|
||||
}
|
||||
|
||||
try options.validate()
|
||||
|
||||
return options
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct DirectoryShare {
|
||||
let name: String?
|
||||
let path: URL
|
||||
|
||||
@@ -39,14 +39,6 @@ struct Set: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
|
||||
// Replacing disk.img would leave a stacked VM with both disk.img and
|
||||
// overlay.asif, which is not a supported local layout. Reject before
|
||||
// saving any other requested configuration changes.
|
||||
if disk != nil, vmDir.isStackedVM {
|
||||
throw ValidationError("--disk is not supported for VMs with a stacked disk")
|
||||
}
|
||||
|
||||
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
|
||||
if let cpu = cpu {
|
||||
|
||||
@@ -33,7 +33,7 @@ struct Config {
|
||||
continue
|
||||
}
|
||||
|
||||
try VMDirectory(baseURL: entry).removeFromDisk()
|
||||
try FileManager.default.removeItem(at: entry)
|
||||
|
||||
try lock.unlock()
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum ContentStoreError: Error, Equatable {
|
||||
case invalidContentDigest(String)
|
||||
case contentDigestMismatch(expected: String, actual: String)
|
||||
}
|
||||
|
||||
/// Opaque content-addressed storage for immutable reconstructed files.
|
||||
///
|
||||
/// Stacked disks currently use it for complete base disks and published ASIF
|
||||
/// overlays reconstructed from Tart disk chunks. OCI blob digests may differ
|
||||
/// across registries, so the key is the full reconstructed-file digest.
|
||||
struct ContentStore {
|
||||
private static let digestAlgorithm = "sha256"
|
||||
private static let digestPrefix = "\(digestAlgorithm):"
|
||||
|
||||
let baseURL: URL
|
||||
private let digestDirectoryURL: URL
|
||||
private let pruneLockURL: URL
|
||||
|
||||
init() throws {
|
||||
try self.init(baseURL: Config().tartCacheDir.appendingPathComponent("content", isDirectory: true))
|
||||
}
|
||||
|
||||
init(baseURL: URL) throws {
|
||||
self.baseURL = baseURL
|
||||
self.digestDirectoryURL = baseURL.appendingPathComponent(Self.digestAlgorithm, isDirectory: true)
|
||||
self.pruneLockURL = baseURL.appendingPathComponent(".gc.lock")
|
||||
try FileManager.default.createDirectory(at: digestDirectoryURL, withIntermediateDirectories: true)
|
||||
if !FileManager.default.fileExists(atPath: pruneLockURL.path) {
|
||||
_ = FileManager.default.createFile(atPath: pruneLockURL.path, contents: Data())
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes reference publication with the final reference check and
|
||||
/// deletion of immutable cache entries across Tart processes.
|
||||
func withPruneLock<T>(_ body: () throws -> T) throws -> T {
|
||||
let lock = try FileLock(lockURL: pruneLockURL)
|
||||
try lock.lock()
|
||||
defer { try? lock.unlock() }
|
||||
|
||||
return try body()
|
||||
}
|
||||
|
||||
/// Waits for any prune already scanning references to finish. After this
|
||||
/// returns, later prune runs can see a reference the caller already wrote.
|
||||
func synchronizePublishedReferences() throws {
|
||||
try withPruneLock {}
|
||||
}
|
||||
|
||||
func contentURL(for contentDigest: String) throws -> URL {
|
||||
try contentURL(for: contentDigest, under: baseURL)
|
||||
}
|
||||
|
||||
/// Returns the canonical path for a digest under an arbitrary content-store
|
||||
/// root without creating directories or lock files.
|
||||
func contentURL(for contentDigest: String, under baseURL: URL) throws -> URL {
|
||||
let digestHex = try validatedDigestHex(contentDigest)
|
||||
|
||||
return baseURL
|
||||
.appendingPathComponent(Self.digestAlgorithm, isDirectory: true)
|
||||
.appendingPathComponent(digestHex)
|
||||
}
|
||||
|
||||
func temporaryContentURL(for contentDigest: String) throws -> URL {
|
||||
let targetURL = try contentURL(for: contentDigest)
|
||||
|
||||
return targetURL.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).tmp")
|
||||
}
|
||||
|
||||
/// Returns a stable staging path so an interrupted registry pull can resume
|
||||
/// reconstructing this content entry on a later attempt.
|
||||
func resumableContentURL(for contentDigest: String) throws -> URL {
|
||||
let targetURL = try contentURL(for: contentDigest)
|
||||
|
||||
return targetURL.deletingLastPathComponent().appendingPathComponent(".\(targetURL.lastPathComponent).partial")
|
||||
}
|
||||
|
||||
/// Returns a stable lock file for serializing reconstruction of one content
|
||||
/// entry. The file is intentionally retained; flock state lives on the file
|
||||
/// descriptor and disappears when the owning process exits.
|
||||
func lockURL(for contentDigest: String) throws -> URL {
|
||||
let targetURL = try contentURL(for: contentDigest)
|
||||
|
||||
let lockURL = targetURL.deletingLastPathComponent().appendingPathComponent(".\(targetURL.lastPathComponent).lock")
|
||||
if !FileManager.default.fileExists(atPath: lockURL.path) {
|
||||
_ = FileManager.default.createFile(atPath: lockURL.path, contents: nil)
|
||||
}
|
||||
|
||||
return lockURL
|
||||
}
|
||||
|
||||
/// Returns an immutable digest-addressed entry without rereading it. Files
|
||||
/// are verified when installed and when deciding whether a pull is a cache
|
||||
/// hit; normal clone/run/push paths trust the store like Tart's disk.img.
|
||||
func contentURLIfPresent(for contentDigest: String) throws -> URL? {
|
||||
let url = try contentURL(for: contentDigest)
|
||||
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
try url.updateAccessDate()
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/// Returns a validated cache hit. Corrupt files are treated as misses so a
|
||||
/// later pull can safely rebuild them.
|
||||
func existingContentURL(for contentDigest: String) throws -> URL? {
|
||||
guard let url = try contentURLIfPresent(for: contentDigest) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard try Digest.hash(url) == contentDigest else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/// Returns immutable content files that no retained cached image or local VM
|
||||
/// references. Callers may prune these like other cache entries.
|
||||
func prunables(excluding referencedContentDigests: Swift.Set<String>) throws -> [URL] {
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: digestDirectoryURL,
|
||||
includingPropertiesForKeys: [.isRegularFileKey],
|
||||
options: [.skipsSubdirectoryDescendants]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return try enumerator.compactMap { element in
|
||||
guard let url = element as? URL,
|
||||
try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let contentDigest = "\(Self.digestPrefix)\(url.lastPathComponent)"
|
||||
guard (try? validatedDigestHex(contentDigest)) != nil,
|
||||
!referencedContentDigests.contains(contentDigest) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a fully reconstructed temporary file into the cache after verifying
|
||||
/// its semantic identity. The caller should create the temporary file with
|
||||
/// temporaryContentURL(for:) or resumableContentURL(for:) so rename stays on
|
||||
/// the same filesystem.
|
||||
func install(_ temporaryURL: URL, contentDigest: String) throws -> URL {
|
||||
let actualDigest = try Digest.hash(temporaryURL)
|
||||
guard actualDigest == contentDigest else {
|
||||
throw ContentStoreError.contentDigestMismatch(expected: contentDigest, actual: actualDigest)
|
||||
}
|
||||
|
||||
let targetURL = try contentURL(for: contentDigest)
|
||||
let lock = try FileLock(lockURL: baseURL)
|
||||
try lock.lock()
|
||||
defer { try? lock.unlock() }
|
||||
|
||||
if let existingURL = try existingContentURL(for: contentDigest) {
|
||||
try? FileManager.default.removeItem(at: temporaryURL)
|
||||
return existingURL
|
||||
}
|
||||
|
||||
if FileManager.default.fileExists(atPath: targetURL.path) {
|
||||
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: temporaryURL)
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: temporaryURL, to: targetURL)
|
||||
}
|
||||
|
||||
return targetURL
|
||||
}
|
||||
|
||||
private func validatedDigestHex(_ contentDigest: String) throws -> String {
|
||||
guard contentDigest.hasPrefix(Self.digestPrefix) else {
|
||||
throw ContentStoreError.invalidContentDigest(contentDigest)
|
||||
}
|
||||
|
||||
let digestHex = String(contentDigest.dropFirst(Self.digestPrefix.count))
|
||||
let isHex = digestHex.allSatisfy { $0.isHexDigit && !$0.isUppercase }
|
||||
|
||||
guard digestHex.count == 64, isHex else {
|
||||
throw ContentStoreError.invalidContentDigest(contentDigest)
|
||||
}
|
||||
|
||||
return digestHex
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import System
|
||||
import Virtualization
|
||||
import Network
|
||||
import os.log
|
||||
import NIO
|
||||
@@ -9,48 +6,38 @@ import NIOPosix
|
||||
|
||||
@available(macOS 14, *)
|
||||
class ControlSocket {
|
||||
typealias ServerChannel = NIOAsyncChannel<NIOAsyncChannel<ByteBuffer, ByteBuffer>, Never>
|
||||
|
||||
let controlSocketURL: URL
|
||||
let vmPort: UInt32
|
||||
let eventLoopGroup: MultiThreadedEventLoopGroup
|
||||
let serverChannel: ServerChannel
|
||||
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
let logger: os.Logger = os.Logger(subsystem: "org.cirruslabs.tart.control-socket", category: "network")
|
||||
|
||||
init(_ controlSocketURL: URL, vmPort: UInt32 = 8080) async throws {
|
||||
init(_ controlSocketURL: URL, vmPort: UInt32 = 8080) {
|
||||
self.controlSocketURL = controlSocketURL
|
||||
self.vmPort = vmPort
|
||||
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
self.eventLoopGroup = eventLoopGroup
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
// Remove control socket file from previous "tart run" invocations,
|
||||
// if any, otherwise we may get the "address already in use" error
|
||||
try? FileManager.default.removeItem(at: controlSocketURL)
|
||||
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.absoluteURL.path(percentEncoded: false))
|
||||
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
|
||||
}
|
||||
|
||||
do {
|
||||
self.serverChannel = try await ServerBootstrap(group: eventLoopGroup)
|
||||
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
|
||||
childChannel.eventLoop.makeCompletedFuture {
|
||||
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
|
||||
wrappingChannelSynchronously: childChannel
|
||||
)
|
||||
}
|
||||
let serverChannel = try await ServerBootstrap(group: eventLoopGroup)
|
||||
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
|
||||
childChannel.eventLoop.makeCompletedFuture {
|
||||
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
|
||||
wrappingChannelSynchronously: childChannel
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
try? await eventLoopGroup.shutdownGracefully()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
try await withThrowingDiscardingTaskGroup { group in
|
||||
try await serverChannel.executeThenClose { serverInbound in
|
||||
for try await clientChannel in serverInbound {
|
||||
@@ -75,13 +62,7 @@ class ControlSocket {
|
||||
|
||||
self.logger.info("running control socket proxy")
|
||||
|
||||
// Duplicate the connection's file descriptor
|
||||
//
|
||||
// This way VZVirtioSocketConnection and NIO won't race to close the same descriptor,
|
||||
// which may result in "tart run" crashing because of NIO's fatal assertion on EBADF.
|
||||
let vmSocket = try duplicateAndCloseConnection(vmConnection)
|
||||
|
||||
let vmChannel = try await ClientBootstrap(group: eventLoopGroup).withConnectedSocket(vmSocket) { childChannel in
|
||||
let vmChannel = try await ClientBootstrap(group: eventLoopGroup).withConnectedSocket(vmConnection.fileDescriptor) { childChannel in
|
||||
childChannel.eventLoop.makeCompletedFuture {
|
||||
try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
|
||||
wrappingChannelSynchronously: childChannel
|
||||
@@ -113,15 +94,4 @@ class ControlSocket {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func duplicateAndCloseConnection(_ connection: VZVirtioSocketConnection) throws -> CInt {
|
||||
defer { connection.close() }
|
||||
|
||||
let fd = fcntl(connection.fileDescriptor, F_DUPFD_CLOEXEC, 0)
|
||||
guard fd >= 0 else {
|
||||
throw Errno(rawValue: errno)
|
||||
}
|
||||
|
||||
return fd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
#if canImport(DiskImageKit)
|
||||
import DiskImageKit
|
||||
#endif
|
||||
|
||||
/// The logical block layout exposed by a disk image.
|
||||
struct DiskImageBlockLayout {
|
||||
let blockSize: UInt64
|
||||
let blockCount: UInt64
|
||||
}
|
||||
|
||||
enum DiskImageStackError: Error, Equatable, CustomStringConvertible {
|
||||
case unavailable
|
||||
case writableOverlayAlreadyExists(URL)
|
||||
case writableOverlayMissing(URL)
|
||||
case invalidBlockLayout(String)
|
||||
case invalidDiskImage(URL, String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .unavailable:
|
||||
"stacked disks require DiskImageKit on macOS 27 or newer"
|
||||
case .writableOverlayAlreadyExists(let url):
|
||||
"writable overlay already exists: \(url.path)"
|
||||
case .writableOverlayMissing(let url):
|
||||
"writable overlay is missing: \(url.path)"
|
||||
case .invalidBlockLayout(let reason):
|
||||
reason
|
||||
case .invalidDiskImage(let url, let reason):
|
||||
"\(reason): \(url.path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DiskImageStack {
|
||||
/// DiskImageKit-ready paths and block layout after Tart disk chunks have been
|
||||
/// reconstructed into complete immutable files. The writable overlay stays
|
||||
/// private to one VM.
|
||||
let baseURL: URL
|
||||
let baseFormat: DiskImageFormat
|
||||
let immutableOverlayURLs: [URL]
|
||||
let writableOverlayURL: URL
|
||||
let blockSize: UInt64
|
||||
let blockCount: UInt64
|
||||
|
||||
static var isSupported: Bool {
|
||||
#if canImport(DiskImageKit)
|
||||
if #available(macOS 27.0, *) {
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
static func requireSupport() throws {
|
||||
guard isSupported else {
|
||||
throw DiskImageStackError.unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a disk image's current block layout without resolving or validating a
|
||||
/// whole stack. This is used for the VM's private writable overlay, whose
|
||||
/// size may be newer than the pinned immutable parent manifest.
|
||||
static func diskImageBlockLayout(at url: URL) throws -> DiskImageBlockLayout {
|
||||
#if canImport(DiskImageKit)
|
||||
if #available(macOS 27.0, *) {
|
||||
let image = try DiskImage(opening: .open(url: url, mode: .readOnly))
|
||||
return DiskImageBlockLayout(
|
||||
blockSize: UInt64(image.blockSize.rawValue),
|
||||
blockCount: UInt64(image.blockCount)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
throw DiskImageStackError.unavailable
|
||||
}
|
||||
|
||||
static func baseBlockLayout(
|
||||
at url: URL,
|
||||
expectedFormat: DiskImageFormat
|
||||
) throws -> DiskImageBlockLayout {
|
||||
#if canImport(DiskImageKit)
|
||||
if #available(macOS 27.0, *) {
|
||||
let image = try DiskImage(opening: .open(url: url, mode: .readOnly))
|
||||
try validateBase(image, at: url, expectedFormat: expectedFormat)
|
||||
|
||||
return DiskImageBlockLayout(
|
||||
blockSize: UInt64(image.blockSize.rawValue),
|
||||
blockCount: UInt64(image.blockCount)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
throw DiskImageStackError.unavailable
|
||||
}
|
||||
|
||||
func createWritableOverlay() throws {
|
||||
#if canImport(DiskImageKit)
|
||||
if #available(macOS 27.0, *) {
|
||||
try createWritableOverlayWithDiskImageKit()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
throw DiskImageStackError.unavailable
|
||||
}
|
||||
|
||||
func copyWritableOverlay(to destinationURL: URL) throws {
|
||||
guard !FileManager.default.fileExists(atPath: destinationURL.path) else {
|
||||
throw DiskImageStackError.writableOverlayAlreadyExists(destinationURL)
|
||||
}
|
||||
|
||||
try FileManager.default.copyItem(at: writableOverlayURL, to: destinationURL)
|
||||
}
|
||||
|
||||
func makeAttachment(
|
||||
readOnly: Bool = false,
|
||||
cachingMode: VZDiskImageCachingMode = .automatic,
|
||||
synchronizationMode: VZDiskImageSynchronizationMode = .full
|
||||
) throws -> VZStorageDeviceAttachment {
|
||||
#if canImport(DiskImageKit)
|
||||
if #available(macOS 27.0, *) {
|
||||
return try attachmentWithDiskImageKit(
|
||||
readOnly: readOnly,
|
||||
cachingMode: cachingMode,
|
||||
synchronizationMode: synchronizationMode
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
throw DiskImageStackError.unavailable
|
||||
}
|
||||
|
||||
func growWritableOverlay(toBlockCount blockCount: UInt64) throws {
|
||||
#if canImport(DiskImageKit)
|
||||
if #available(macOS 27.0, *) {
|
||||
try growWritableOverlayWithDiskImageKit(toBlockCount: blockCount)
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
throw DiskImageStackError.unavailable
|
||||
}
|
||||
|
||||
#if canImport(DiskImageKit)
|
||||
@available(macOS 27.0, *)
|
||||
private func createWritableOverlayWithDiskImageKit() throws {
|
||||
guard !FileManager.default.fileExists(atPath: writableOverlayURL.path) else {
|
||||
throw DiskImageStackError.writableOverlayAlreadyExists(writableOverlayURL)
|
||||
}
|
||||
|
||||
let parent = try validatedParentImage()
|
||||
let stackedImage = try parent.appending(.asifLayer(url: writableOverlayURL, type: .overlay))
|
||||
try validateAppendedOverlay(stackedImage, at: writableOverlayURL)
|
||||
}
|
||||
|
||||
@available(macOS 27.0, *)
|
||||
private func attachmentWithDiskImageKit(
|
||||
readOnly: Bool,
|
||||
cachingMode: VZDiskImageCachingMode,
|
||||
synchronizationMode: VZDiskImageSynchronizationMode
|
||||
) throws -> VZDiskImageStorageDeviceAttachment {
|
||||
guard FileManager.default.fileExists(atPath: writableOverlayURL.path) else {
|
||||
throw DiskImageStackError.writableOverlayMissing(writableOverlayURL)
|
||||
}
|
||||
|
||||
let parent = try validatedParentImage()
|
||||
let writableOverlay = try openOverlay(
|
||||
at: writableOverlayURL,
|
||||
mode: readOnly ? .readOnly : .readWrite
|
||||
)
|
||||
let stackedImage = try append(writableOverlay, to: parent, at: writableOverlayURL)
|
||||
try validateAppendedOverlay(stackedImage, at: writableOverlayURL)
|
||||
|
||||
return try VZDiskImageStorageDeviceAttachment(
|
||||
diskImage: stackedImage,
|
||||
cachingMode: cachingMode,
|
||||
synchronizationMode: synchronizationMode
|
||||
)
|
||||
}
|
||||
|
||||
@available(macOS 27.0, *)
|
||||
private func growWritableOverlayWithDiskImageKit(toBlockCount blockCount: UInt64) throws {
|
||||
guard blockCount > 0, let desiredBlockCount = Int(exactly: blockCount) else {
|
||||
throw DiskImageStackError.invalidBlockLayout("invalid stacked disk block count \(blockCount)")
|
||||
}
|
||||
|
||||
let parent = try validatedParentImage()
|
||||
let overlay = try openOverlay(
|
||||
at: writableOverlayURL,
|
||||
mode: .readWrite
|
||||
)
|
||||
let currentBlockCount = overlay.blockCount
|
||||
let stackedImage = try append(overlay, to: parent, at: writableOverlayURL)
|
||||
try validateAppendedOverlay(stackedImage, at: writableOverlayURL)
|
||||
guard desiredBlockCount >= currentBlockCount else {
|
||||
throw DiskImageStackError.invalidDiskImage(writableOverlayURL, "ASIF overlay block count shrinks the stacked disk")
|
||||
}
|
||||
|
||||
guard let writableOverlay = stackedImage.layers.last else {
|
||||
throw DiskImageStackError.invalidDiskImage(writableOverlayURL, "disk image must be an ASIF overlay")
|
||||
}
|
||||
if desiredBlockCount > currentBlockCount {
|
||||
try writableOverlay.truncate(blockCount: desiredBlockCount)
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 27.0, *)
|
||||
private func validatedParentImage() throws -> DiskImage {
|
||||
let expectedBlockSize = try diskImageBlockSize(blockSize)
|
||||
guard blockCount > 0, let expectedBlockCount = Int(exactly: blockCount) else {
|
||||
throw DiskImageStackError.invalidBlockLayout("invalid stacked disk block count \(blockCount)")
|
||||
}
|
||||
|
||||
let baseImage = try DiskImage(opening: .open(url: baseURL, mode: .readOnly))
|
||||
try Self.validateBase(baseImage, at: baseURL, expectedFormat: baseFormat)
|
||||
|
||||
var image = baseImage
|
||||
|
||||
for overlayURL in immutableOverlayURLs {
|
||||
let openedOverlay = try openOverlay(
|
||||
at: overlayURL,
|
||||
mode: .readOnly
|
||||
)
|
||||
let stackedImage = try append(openedOverlay, to: image, at: overlayURL)
|
||||
try validateAppendedOverlay(stackedImage, at: overlayURL)
|
||||
image = stackedImage
|
||||
}
|
||||
|
||||
guard image.blockSize == expectedBlockSize else {
|
||||
throw DiskImageStackError.invalidBlockLayout("immutable disk stack does not match manifest block size")
|
||||
}
|
||||
guard image.blockCount == expectedBlockCount else {
|
||||
throw DiskImageStackError.invalidBlockLayout("immutable disk stack does not match manifest block count")
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
@available(macOS 27.0, *)
|
||||
private static func validateBase(
|
||||
_ image: DiskImage,
|
||||
at url: URL,
|
||||
expectedFormat: DiskImageFormat
|
||||
) throws {
|
||||
let matchesFormat = switch expectedFormat {
|
||||
case .raw:
|
||||
image.format == .raw
|
||||
case .asif:
|
||||
image.format == .asif
|
||||
}
|
||||
guard matchesFormat else {
|
||||
throw DiskImageStackError.invalidDiskImage(url, "base disk format does not match")
|
||||
}
|
||||
guard image.layerType == nil, image.parentUUID == nil else {
|
||||
throw DiskImageStackError.invalidDiskImage(url, "base disk must not be an overlay")
|
||||
}
|
||||
if expectedFormat == .asif && image.layerUUID == nil {
|
||||
throw DiskImageStackError.invalidDiskImage(url, "ASIF base disk is missing a UUID")
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 27.0, *)
|
||||
private func openOverlay(
|
||||
at url: URL,
|
||||
mode: OpenConfiguration.Mode
|
||||
) throws -> DiskImage {
|
||||
let image = try DiskImage(opening: .open(url: url, mode: mode))
|
||||
guard image.format == .asif else {
|
||||
throw DiskImageStackError.invalidDiskImage(url, "overlay must use ASIF format")
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
@available(macOS 27.0, *)
|
||||
private func append(_ overlay: DiskImage, to parent: DiskImage, at url: URL) throws -> any StackedImage {
|
||||
do {
|
||||
return try parent.appending(overlay)
|
||||
} catch is IncompatibleStackingError {
|
||||
throw DiskImageStackError.invalidDiskImage(url, "ASIF overlay is incompatible with its parent")
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 27.0, *)
|
||||
private func validateAppendedOverlay(_ image: any StackedImage, at url: URL) throws {
|
||||
guard image.layers.last?.layerType == .overlay else {
|
||||
throw DiskImageStackError.invalidDiskImage(url, "disk image must be an ASIF overlay")
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 27.0, *)
|
||||
private func diskImageBlockSize(_ value: UInt64) throws -> DiskImage.BlockSize {
|
||||
guard let intValue = Int(exactly: value), let blockSize = DiskImage.BlockSize(rawValue: intValue) else {
|
||||
throw DiskImageStackError.invalidBlockLayout("unsupported stacked disk block size \(value)")
|
||||
}
|
||||
|
||||
return blockSize
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct HumanReadableByteCount: Encodable, CustomStringConvertible {
|
||||
private let byteCount: Int?
|
||||
private let jsonValue: any Encodable
|
||||
|
||||
init<JSONValue: Encodable>(_ byteCount: Int?, encodedAs: (Int) -> JSONValue) {
|
||||
self.byteCount = byteCount
|
||||
self.jsonValue = byteCount.map(encodedAs)
|
||||
}
|
||||
|
||||
var description: String {
|
||||
guard let byteCount else {
|
||||
return "-"
|
||||
}
|
||||
|
||||
let formatter = MeasurementFormatter()
|
||||
formatter.unitOptions = .naturalScale
|
||||
formatter.unitStyle = .medium
|
||||
formatter.numberFormatter.maximumFractionDigits = 0
|
||||
|
||||
return formatter.string(
|
||||
from: Measurement(value: Double(byteCount), unit: UnitInformationStorage.bytes)
|
||||
)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
try jsonValue.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import GRPC
|
||||
import NIOPosix
|
||||
|
||||
/// Connects to a guest agent's gRPC endpoint over a VM's control socket, runs
|
||||
/// `body` with the resulting channel, and closes the channel afterwards on both
|
||||
/// the success and error paths.
|
||||
///
|
||||
/// The connection uses the process-wide singleton event loop group, which must
|
||||
/// not be shut down, so there is no group lifecycle to manage here.
|
||||
func withGuestAgentChannel<T>(
|
||||
unixDomainSocketPath socketPath: String,
|
||||
_ body: (GRPCChannel) async throws -> T
|
||||
) async throws -> T {
|
||||
let channel = try GRPCChannelPool.with(
|
||||
target: .unixDomainSocket(socketPath),
|
||||
transportSecurity: .plaintext,
|
||||
eventLoopGroup: .singletonMultiThreadedEventLoopGroup,
|
||||
)
|
||||
|
||||
do {
|
||||
let result = try await body(channel)
|
||||
try await channel.close().get()
|
||||
return result
|
||||
} catch {
|
||||
try? await channel.close().get()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import NIOPosix
|
||||
import GRPC
|
||||
import Cirruslabs_TartGuestAgent_Apple_Swift
|
||||
import Cirruslabs_TartGuestAgent_Grpc_Swift
|
||||
@@ -8,21 +9,34 @@ class AgentResolver {
|
||||
static func ResolveIP(_ controlSocketPath: String) async throws -> IPv4Address? {
|
||||
do {
|
||||
return try await resolveIP(controlSocketPath)
|
||||
} catch is GRPCConnectionPoolError {
|
||||
} catch let error as GRPCConnectionPoolError {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveIP(_ controlSocketPath: String) async throws -> IPv4Address? {
|
||||
try await withGuestAgentChannel(unixDomainSocketPath: controlSocketPath) { channel in
|
||||
// Invoke ResolveIP() gRPC method
|
||||
let callOptions = CallOptions(timeLimit: .timeout(.seconds(1)))
|
||||
let agentAsyncClient = AgentAsyncClient(channel: channel)
|
||||
let resolveIPCall = agentAsyncClient.makeResolveIpCall(ResolveIPRequest(), callOptions: callOptions)
|
||||
|
||||
let response = try await resolveIPCall.response
|
||||
|
||||
return IPv4Address(response.ip)
|
||||
// Create a gRPC channel connected to the VM's control socket
|
||||
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
defer {
|
||||
try! group.syncShutdownGracefully()
|
||||
}
|
||||
|
||||
let channel = try GRPCChannelPool.with(
|
||||
target: .unixDomainSocket(controlSocketPath),
|
||||
transportSecurity: .plaintext,
|
||||
eventLoopGroup: group,
|
||||
)
|
||||
defer {
|
||||
try! channel.close().wait()
|
||||
}
|
||||
|
||||
// Invoke ResolveIP() gRPC method
|
||||
let callOptions = CallOptions(timeLimit: .timeout(.seconds(1)))
|
||||
let agentAsyncClient = AgentAsyncClient(channel: channel)
|
||||
let resolveIPCall = agentAsyncClient.makeResolveIpCall(ResolveIPRequest(), callOptions: callOptions)
|
||||
|
||||
let response = try await resolveIPCall.response
|
||||
|
||||
return IPv4Address(response.ip)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,22 +11,12 @@ enum SoftnetError: Error {
|
||||
|
||||
class Softnet: Network {
|
||||
private let process = Process()
|
||||
private var controlFileHandle: FileHandle?
|
||||
private var monitorTask: Task<Void, Error>? = nil
|
||||
private let monitorTaskFinished = ManagedAtomic<Bool>(false)
|
||||
|
||||
let vmFD: Int32
|
||||
|
||||
init(vmMACAddress: String, extraArguments: [String] = [], controlFD: Int32? = nil) throws {
|
||||
if let controlFD = controlFD {
|
||||
guard controlFD > STDERR_FILENO else {
|
||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2")
|
||||
}
|
||||
|
||||
controlFileHandle = FileHandle(fileDescriptor: controlFD, closeOnDealloc: true)
|
||||
try Self.validateControlFD(controlFD)
|
||||
}
|
||||
|
||||
init(vmMACAddress: String, extraArguments: [String] = []) throws {
|
||||
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||
|
||||
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
||||
@@ -43,44 +33,6 @@ class Softnet: Network {
|
||||
process.executableURL = try Self.softnetExecutableURL()
|
||||
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress] + extraArguments
|
||||
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
||||
|
||||
if let controlFileHandle = controlFileHandle {
|
||||
process.arguments! += ["--control-fd", String(STDOUT_FILENO)]
|
||||
process.standardOutput = controlFileHandle
|
||||
}
|
||||
}
|
||||
|
||||
static func validateControlFD(_ fd: Int32) throws {
|
||||
guard fd > STDERR_FILENO else {
|
||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2")
|
||||
}
|
||||
|
||||
var socketType: Int32 = 0
|
||||
var socketTypeLength = socklen_t(MemoryLayout<Int32>.size)
|
||||
guard getsockopt(fd, SOL_SOCKET, SO_TYPE, &socketType, &socketTypeLength) == 0 else {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not a socket: \(details)")
|
||||
}
|
||||
|
||||
guard socketType == SOCK_STREAM else {
|
||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
|
||||
}
|
||||
|
||||
var peerAddress = sockaddr_storage()
|
||||
var peerAddressLength = socklen_t(MemoryLayout<sockaddr_storage>.size)
|
||||
let result = withUnsafeMutablePointer(to: &peerAddress) { pointer in
|
||||
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
getpeername(fd, $0, &peerAddressLength)
|
||||
}
|
||||
}
|
||||
guard result == 0 else {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not connected: \(details)")
|
||||
}
|
||||
|
||||
guard peerAddress.ss_family == sa_family_t(AF_UNIX) else {
|
||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
|
||||
}
|
||||
}
|
||||
|
||||
static func softnetExecutableURL() throws -> URL {
|
||||
@@ -94,8 +46,6 @@ class Softnet: Network {
|
||||
}
|
||||
|
||||
func run(_ sema: AsyncSemaphore) throws {
|
||||
defer { try? controlFileHandle?.close() }
|
||||
|
||||
try process.run()
|
||||
|
||||
monitorTask = Task {
|
||||
|
||||
@@ -7,8 +7,6 @@ enum DigestError: Error {
|
||||
}
|
||||
|
||||
class Digest {
|
||||
private static let fileBufferSize = 4 * 1024 * 1024
|
||||
|
||||
var hash: SHA256 = SHA256()
|
||||
|
||||
func update(_ data: Data) {
|
||||
@@ -24,10 +22,7 @@ class Digest {
|
||||
}
|
||||
|
||||
static func hash(_ url: URL) throws -> String {
|
||||
let file = try FileHandle(forReadingFrom: url)
|
||||
defer { try? file.close() }
|
||||
|
||||
return try hashContents(from: file)
|
||||
hash(try Data(contentsOf: url))
|
||||
}
|
||||
|
||||
static func hash(_ url: URL, offset: UInt64, size: UInt64) throws -> String {
|
||||
@@ -41,53 +36,20 @@ class Digest {
|
||||
throw DigestError.InvalidOffset
|
||||
}
|
||||
|
||||
if size > fileSize - offset {
|
||||
if (offset + size) > fileSize {
|
||||
throw DigestError.InvalidSize
|
||||
}
|
||||
|
||||
// Read the requested range incrementally and calculate its digest.
|
||||
// Read a chunk of size ``size`` at offset ``offset``
|
||||
// and calculate it's digest
|
||||
let fh = try FileHandle(forReadingFrom: url)
|
||||
defer { try? fh.close() }
|
||||
defer { try! fh.close() }
|
||||
|
||||
try fh.seek(toOffset: offset)
|
||||
|
||||
return try hashContents(from: fh, size: size)
|
||||
}
|
||||
let data = try fh.read(upToCount: Int(size))!
|
||||
|
||||
/// Streams a file into SHA-256 while keeping Foundation's temporary read
|
||||
/// buffers scoped to one chunk.
|
||||
private static func hashContents(from file: FileHandle, size: UInt64? = nil) throws -> String {
|
||||
let digest = Digest()
|
||||
var remaining = size
|
||||
|
||||
while remaining.map({ $0 > 0 }) ?? true {
|
||||
let didRead = try autoreleasepool { () throws -> Bool in
|
||||
let count = remaining.map {
|
||||
Int(min(UInt64(fileBufferSize), $0))
|
||||
} ?? fileBufferSize
|
||||
|
||||
guard let data = try file.read(upToCount: count), !data.isEmpty else {
|
||||
if remaining != nil {
|
||||
throw DigestError.InvalidSize
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
digest.update(data)
|
||||
if let bytesRemaining = remaining {
|
||||
remaining = bytesRemaining - UInt64(data.count)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if !didRead {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return digest.finalize()
|
||||
return hash(data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
protocol Disk {
|
||||
static func push(diskURL: URL, mediaType: String, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer]
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer]
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws
|
||||
}
|
||||
|
||||
@@ -22,14 +22,7 @@ class DiskV2: Disk {
|
||||
private static let holeGranularityBytes = 4 * 1024 * 1024
|
||||
private static let zeroChunk = Data(count: holeGranularityBytes)
|
||||
|
||||
static func push(
|
||||
diskURL: URL,
|
||||
mediaType: String,
|
||||
registry: Registry,
|
||||
chunkSizeMb: Int,
|
||||
concurrency: UInt,
|
||||
progress: Progress
|
||||
) async throws -> [OCIManifestLayer] {
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||
var pushedLayers: [(index: Int, pushedLayer: OCIManifestLayer)] = []
|
||||
|
||||
// Open the disk file
|
||||
@@ -70,7 +63,7 @@ class DiskV2: Disk {
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
|
||||
return (index, OCIManifestLayer(
|
||||
mediaType: mediaType,
|
||||
mediaType: diskV2MediaType,
|
||||
size: compressedData.count,
|
||||
digest: compressedDataDigest,
|
||||
uncompressedSize: UInt64(data.count),
|
||||
|
||||
@@ -7,13 +7,11 @@ let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
|
||||
// Layer media types
|
||||
let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
|
||||
let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2"
|
||||
let asifOverlayMediaType = "application/vnd.cirruslabs.tart.disk.asif.overlay.v1"
|
||||
let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
||||
|
||||
// Manifest annotations
|
||||
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
||||
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
|
||||
let diskBlockSizeAnnotation = "org.cirruslabs.tart.disk.block-size"
|
||||
|
||||
// Manifest labels
|
||||
let diskFormatLabel = "org.cirruslabs.tart.disk.format"
|
||||
@@ -21,51 +19,6 @@ let diskFormatLabel = "org.cirruslabs.tart.disk.format"
|
||||
// Layer annotations
|
||||
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
|
||||
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
|
||||
let diskFileContentDigestAnnotation = "org.cirruslabs.tart.disk-file-content-digest"
|
||||
let diskFileChunkCountAnnotation = "org.cirruslabs.tart.disk-file-chunk-count"
|
||||
|
||||
/// The OCI-layer descriptors whose Tart disk chunks reconstruct one complete
|
||||
/// base disk or ASIF overlay.
|
||||
struct TartDiskFileGroup: Equatable {
|
||||
enum Kind: Equatable {
|
||||
case base
|
||||
case asifOverlay
|
||||
}
|
||||
|
||||
var kind: Kind
|
||||
var chunks: [OCIManifestLayer]
|
||||
/// Whole reconstructed-file digest. Existing flat manifests do not have
|
||||
/// this until a macOS 27 clone normalizes its local manifest copy.
|
||||
var contentDigest: String?
|
||||
|
||||
/// Expected size of the complete disk file reconstructed from these chunks.
|
||||
func uncompressedSize() -> UInt64? {
|
||||
var result: UInt64 = 0
|
||||
for chunk in chunks {
|
||||
guard let size = chunk.uncompressedSize() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let addition = result.addingReportingOverflow(size)
|
||||
guard !addition.overflow else {
|
||||
return nil
|
||||
}
|
||||
result = addition.partialValue
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
enum TartDiskRepresentation: Equatable {
|
||||
case flat(base: TartDiskFileGroup)
|
||||
case stacked(base: TartDiskFileGroup, overlays: [TartDiskFileGroup])
|
||||
}
|
||||
|
||||
enum OCIManifestValidationError: Error, Equatable {
|
||||
case invalidLayout(String)
|
||||
case invalidDiskMetadata(String)
|
||||
}
|
||||
|
||||
struct OCIManifest: Codable, Equatable {
|
||||
var schemaVersion: Int = 2
|
||||
@@ -110,116 +63,6 @@ struct OCIManifest: Codable, Equatable {
|
||||
|
||||
return UInt64(value)
|
||||
}
|
||||
|
||||
/// Parse Tart's canonical `config -> disk descriptors -> NVRAM` order.
|
||||
/// A stacked image has a leading `disk.v2` base run followed by one or more
|
||||
/// contiguous ASIF overlay chunk groups.
|
||||
func tartDiskRepresentation() throws -> TartDiskRepresentation {
|
||||
guard layers.filter({ $0.mediaType == configMediaType }).count == 1 else {
|
||||
throw OCIManifestValidationError.invalidLayout("manifest must contain exactly one Tart config descriptor")
|
||||
}
|
||||
guard layers.filter({ $0.mediaType == nvramMediaType }).count == 1 else {
|
||||
throw OCIManifestValidationError.invalidLayout("manifest must contain exactly one NVRAM descriptor")
|
||||
}
|
||||
guard layers.first?.mediaType == configMediaType,
|
||||
layers.last?.mediaType == nvramMediaType else {
|
||||
throw OCIManifestValidationError.invalidLayout("descriptors must be ordered as config, disk chunks, then NVRAM")
|
||||
}
|
||||
|
||||
let diskDescriptors = Array(layers.dropFirst().dropLast())
|
||||
guard !diskDescriptors.isEmpty else {
|
||||
throw OCIManifestValidationError.invalidLayout("manifest has no disk chunks")
|
||||
}
|
||||
|
||||
let baseChunkCount = diskDescriptors.prefix { $0.mediaType == diskV2MediaType }.count
|
||||
guard baseChunkCount > 0 else {
|
||||
throw OCIManifestValidationError.invalidLayout("disk chunks must start with a disk.v2 base")
|
||||
}
|
||||
|
||||
let baseChunks = Array(diskDescriptors.prefix(baseChunkCount))
|
||||
try validateChunkMetadata(baseChunks)
|
||||
guard baseChunks.first?.diskFileChunkCount() == nil,
|
||||
baseChunks.dropFirst().allSatisfy({
|
||||
$0.diskFileContentDigest() == nil && $0.diskFileChunkCount() == nil
|
||||
}) else {
|
||||
throw OCIManifestValidationError.invalidDiskMetadata("base disk metadata must appear only on its first chunk")
|
||||
}
|
||||
let base = TartDiskFileGroup(
|
||||
kind: .base,
|
||||
chunks: baseChunks,
|
||||
contentDigest: baseChunks.first?.diskFileContentDigest()
|
||||
)
|
||||
|
||||
guard baseChunkCount < diskDescriptors.count else {
|
||||
return .flat(base: base)
|
||||
}
|
||||
|
||||
guard base.contentDigest != nil else {
|
||||
throw OCIManifestValidationError.invalidDiskMetadata("a stacked base disk needs a whole-file content digest")
|
||||
}
|
||||
|
||||
var overlays: [TartDiskFileGroup] = []
|
||||
var index = baseChunkCount
|
||||
|
||||
while index < diskDescriptors.count {
|
||||
let first = diskDescriptors[index]
|
||||
guard first.mediaType == asifOverlayMediaType else {
|
||||
throw OCIManifestValidationError.invalidLayout("unsupported disk chunk media type: \(first.mediaType)")
|
||||
}
|
||||
guard let contentDigest = first.diskFileContentDigest(),
|
||||
let chunkCount = first.diskFileChunkCount() else {
|
||||
throw OCIManifestValidationError.invalidDiskMetadata("an ASIF overlay needs a content digest and chunk count")
|
||||
}
|
||||
guard chunkCount > 0, index + chunkCount <= diskDescriptors.count else {
|
||||
throw OCIManifestValidationError.invalidDiskMetadata("ASIF overlay chunk count is invalid")
|
||||
}
|
||||
|
||||
let chunks = Array(diskDescriptors[index..<(index + chunkCount)])
|
||||
guard chunks.allSatisfy({ $0.mediaType == asifOverlayMediaType }) else {
|
||||
throw OCIManifestValidationError.invalidLayout("ASIF overlay chunks must be contiguous")
|
||||
}
|
||||
guard chunks.dropFirst().allSatisfy({ $0.diskFileContentDigest() == nil && $0.diskFileChunkCount() == nil }) else {
|
||||
throw OCIManifestValidationError.invalidDiskMetadata("ASIF overlay metadata must appear only on its first chunk")
|
||||
}
|
||||
try validateChunkMetadata(chunks)
|
||||
|
||||
overlays.append(TartDiskFileGroup(kind: .asifOverlay, chunks: chunks, contentDigest: contentDigest))
|
||||
index += chunkCount
|
||||
}
|
||||
|
||||
return .stacked(base: base, overlays: overlays)
|
||||
}
|
||||
|
||||
/// Returns content-store digests needed to reconstruct this disk stack.
|
||||
func diskContentDigests() throws -> [String] {
|
||||
switch try tartDiskRepresentation() {
|
||||
case .flat(let base):
|
||||
return base.contentDigest.map { [$0] } ?? []
|
||||
case .stacked(let base, let overlays):
|
||||
return ([base] + overlays).compactMap(\.contentDigest)
|
||||
}
|
||||
}
|
||||
|
||||
private func validateChunkMetadata(_ chunks: [OCIManifestLayer]) throws {
|
||||
guard chunks.allSatisfy({ $0.uncompressedSize() != nil && $0.uncompressedContentDigest() != nil }) else {
|
||||
throw OCIManifestValidationError.invalidDiskMetadata("disk chunks need uncompressed size and content digest")
|
||||
}
|
||||
}
|
||||
|
||||
func diskBlockSize() -> UInt64? {
|
||||
annotations?[diskBlockSizeAnnotation].flatMap(UInt64.init)
|
||||
}
|
||||
|
||||
func diskBlockCount() -> UInt64? {
|
||||
guard let diskSize = uncompressedDiskSize(),
|
||||
let blockSize = diskBlockSize(),
|
||||
blockSize > 0,
|
||||
diskSize.isMultiple(of: blockSize) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return diskSize / blockSize
|
||||
}
|
||||
}
|
||||
|
||||
struct OCIConfig: Codable {
|
||||
@@ -278,14 +121,6 @@ struct OCIManifestLayer: Codable, Equatable, Hashable {
|
||||
annotations?[uncompressedContentDigestAnnotation]
|
||||
}
|
||||
|
||||
func diskFileContentDigest() -> String? {
|
||||
annotations?[diskFileContentDigestAnnotation]
|
||||
}
|
||||
|
||||
func diskFileChunkCount() -> Int? {
|
||||
annotations?[diskFileChunkCountAnnotation].flatMap(Int.init)
|
||||
}
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
return lhs.digest == rhs.digest
|
||||
}
|
||||
|
||||
@@ -111,24 +111,27 @@ struct TokenResponse: Decodable, Authentication {
|
||||
}
|
||||
|
||||
class Registry {
|
||||
let baseURL: URL
|
||||
private let baseURL: URL
|
||||
let namespace: String
|
||||
let credentialsProviders: [CredentialsProvider]
|
||||
let authenticationKeeper = AuthenticationKeeper()
|
||||
|
||||
// Host with an optional port (e.g. "127.0.0.1:5000"), which is used for naming
|
||||
// and credentials lookup. For Docker Hub it stays "docker.io", while baseURL
|
||||
// points to registry-1.docker.io.
|
||||
let host: String?
|
||||
var host: String? {
|
||||
guard let host = baseURL.host else { return nil }
|
||||
|
||||
if let port = baseURL.port {
|
||||
return "\(host):\(port)"
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
init(baseURL: URL,
|
||||
namespace: String,
|
||||
host: String? = nil,
|
||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
self.baseURL = baseURL
|
||||
self.namespace = namespace
|
||||
self.host = host ?? Registry.hostWithPort(of: baseURL)
|
||||
self.credentialsProviders = credentialsProviders
|
||||
}
|
||||
|
||||
@@ -139,9 +142,9 @@ class Registry {
|
||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
let proto = insecure ? "http" : "https"
|
||||
var baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||
|
||||
guard var baseURL = baseURLComponents.url else {
|
||||
guard let baseURL = baseURLComponents.url else {
|
||||
var hint = ""
|
||||
|
||||
if host.hasPrefix("http://") || host.hasPrefix("https://") {
|
||||
@@ -151,33 +154,7 @@ class Registry {
|
||||
throw RuntimeError.ImproperlyFormattedHost(host, hint)
|
||||
}
|
||||
|
||||
// Naming and credentials lookup use the host and port of the original URL,
|
||||
// so it's "docker.io" for Docker Hub and "127.0.0.1:5000" for "127.0.0.1:05000"
|
||||
let normalizedHost = Registry.hostWithPort(of: baseURL)
|
||||
|
||||
// Docker Hub serves its registry API from registry-1.docker.io, while docker.io,
|
||||
// the host used in image names, redirects to Docker's website. URLSession follows
|
||||
// these redirects, so we'd get an HTML page with HTTP 200 instead of an API
|
||||
// response, which breaks pushing, pulling and "tart login" credentials validation.
|
||||
//
|
||||
// Host names are case insensitive, and only the host is replaced,
|
||||
// so an explicit port like in "Docker.IO:443" is kept.
|
||||
if baseURLComponents.host?.lowercased() == "docker.io" {
|
||||
baseURLComponents.host = "registry-1.docker.io"
|
||||
baseURL = baseURLComponents.url!
|
||||
}
|
||||
|
||||
try self.init(baseURL: baseURL, namespace: namespace, host: normalizedHost, credentialsProviders: credentialsProviders)
|
||||
}
|
||||
|
||||
private static func hostWithPort(of url: URL) -> String? {
|
||||
guard let host = url.host else { return nil }
|
||||
|
||||
if let port = url.port {
|
||||
return "\(host):\(port)"
|
||||
}
|
||||
|
||||
return host
|
||||
try self.init(baseURL: baseURL, namespace: namespace, credentialsProviders: credentialsProviders)
|
||||
}
|
||||
|
||||
func ping() async throws {
|
||||
@@ -444,8 +421,12 @@ class Registry {
|
||||
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
|
||||
}
|
||||
|
||||
func lookupCredentials() throws -> (String, String)? {
|
||||
let host = self.host!
|
||||
private func lookupCredentials() throws -> (String, String)? {
|
||||
var host = baseURL.host!
|
||||
|
||||
if let port = baseURL.port {
|
||||
host += ":\(port)"
|
||||
}
|
||||
|
||||
for provider in credentialsProviders {
|
||||
do {
|
||||
|
||||
@@ -104,42 +104,40 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
return result
|
||||
}
|
||||
|
||||
func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||
var devices: [VZKeyboardConfiguration] = noUSB ? [] : [VZUSBKeyboardConfiguration()]
|
||||
func keyboards() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||
devices.append(VZMacKeyboardConfiguration())
|
||||
return [VZUSBKeyboardConfiguration(), VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
return [VZUSBKeyboardConfiguration()]
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return keyboards(noUSB: noUSB)
|
||||
return keyboards()
|
||||
}
|
||||
}
|
||||
|
||||
func pointingDevices(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
// Trackpad is only supported by guests starting with macOS Ventura
|
||||
var devices: [VZPointingDeviceConfiguration] = noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
devices.append(VZMacTrackpadConfiguration())
|
||||
return devices
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSimplified(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] {
|
||||
// Only include the USB pointing device, not the trackpad
|
||||
return noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return pointingDevices(noUSB: noUSB)
|
||||
return pointingDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,16 +35,16 @@ struct Linux: Platform {
|
||||
return result
|
||||
}
|
||||
|
||||
func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||
noUSB ? [] : [VZUSBKeyboardConfiguration()]
|
||||
func keyboards() -> [VZKeyboardConfiguration] {
|
||||
[VZUSBKeyboardConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevices(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||
noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSimplified(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] {
|
||||
// Linux doesn't support trackpad, so just return the regular pointing devices
|
||||
return pointingDevices(noUSB: noUSB)
|
||||
return pointingDevices()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ protocol Platform: Codable {
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||
func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration]
|
||||
func pointingDevices(noUSB: Bool) -> [VZPointingDeviceConfiguration]
|
||||
func pointingDevicesSimplified(noUSB: Bool) -> [VZPointingDeviceConfiguration]
|
||||
func keyboards() -> [VZKeyboardConfiguration]
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration]
|
||||
}
|
||||
|
||||
protocol PlatformSuspendable: Platform {
|
||||
func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration]
|
||||
func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration]
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration]
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration]
|
||||
}
|
||||
|
||||
@@ -32,159 +32,88 @@ struct Root: AsyncParsableCommand {
|
||||
FQN.self,
|
||||
])
|
||||
|
||||
// Note: main() is intentionally synchronous. Swift's asynchronous main() entry
|
||||
// point implicitly starts an executor that owns the main thread — and since
|
||||
// Swift 6.4 that executor is no longer backed by the Dispatch main queue — so
|
||||
// running an AppKit/SwiftUI run loop nested inside it leaves the main run loop
|
||||
// unable to drain Tasks or DispatchQueue.main, and a VM started via "tart run"
|
||||
// never boots. Keeping main() synchronous lets a command that needs the main
|
||||
// run loop own it at the top level, exactly like a plain SwiftUI app.
|
||||
public static func main() {
|
||||
public static func main() async throws {
|
||||
// Add commands that are only available on specific macOS versions
|
||||
if #available(macOS 14, *) {
|
||||
configuration.subcommands.append(Suspend.self)
|
||||
}
|
||||
|
||||
// Ensure the default SIGINT handler is disabled, otherwise there's a race
|
||||
// between two handlers. We handle cancellation by Ctrl+C ourselves below.
|
||||
signal(SIGINT, SIG_IGN)
|
||||
// Ensure the default SIGINT handled is disabled,
|
||||
// otherwise there's a race between two handlers
|
||||
signal(SIGINT, SIG_IGN);
|
||||
// Handle cancellation by Ctrl+C ourselves
|
||||
let task = withUnsafeCurrentTask { $0 }!
|
||||
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||
sigintSrc.setEventHandler {
|
||||
task.cancel()
|
||||
}
|
||||
sigintSrc.activate()
|
||||
|
||||
// Set line-buffered output for stdout
|
||||
setlinebuf(stdout)
|
||||
|
||||
// Parse the command up-front, synchronously, so we can decide who gets to own
|
||||
// the main thread before any concurrency is involved.
|
||||
//
|
||||
// ParsableCommand isn't Sendable, but we only ever hand it to the single task
|
||||
// spawned below and never touch it again afterwards, so transferring it into
|
||||
// that task is safe.
|
||||
nonisolated(unsafe) let command: ParsableCommand
|
||||
do {
|
||||
command = try parseAsRoot()
|
||||
} catch {
|
||||
exit(withError: error)
|
||||
}
|
||||
|
||||
if let mainThreadCommand = command as? MainThreadCommand {
|
||||
// This command drives a run loop on the main thread, so run it right here,
|
||||
// letting it own the main thread at the top level.
|
||||
MainActor.assumeIsolated {
|
||||
runOnMainThread(mainThreadCommand)
|
||||
}
|
||||
} else {
|
||||
// Every other command is asynchronous and doesn't touch the main thread, so
|
||||
// drive it from a detached task and let the Dispatch main queue keep the
|
||||
// process alive until the command exits.
|
||||
let task = Task.detached {
|
||||
await runInBackground(command)
|
||||
}
|
||||
|
||||
// Handle cancellation by Ctrl+C ourselves
|
||||
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||
sigintSrc.setEventHandler {
|
||||
task.cancel()
|
||||
}
|
||||
sigintSrc.activate()
|
||||
|
||||
dispatchMain()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func runOnMainThread(_ command: MainThreadCommand) {
|
||||
let span = startCommandSpan(for: command)
|
||||
runGarbageCollection(for: command)
|
||||
defer { OTel.shared.flush() }
|
||||
|
||||
do {
|
||||
// Enters the run loop and only returns once the command exits via
|
||||
// Foundation.exit(), so the lines below are a best-effort fallback.
|
||||
try command.runOnMainThread()
|
||||
} catch {
|
||||
handleError(error, span: span)
|
||||
}
|
||||
// Parse command
|
||||
var command = try parseAsRoot()
|
||||
|
||||
span.end()
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(0)
|
||||
}
|
||||
// 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)
|
||||
|
||||
private static func runInBackground(_ command: ParsableCommand) async {
|
||||
let span = startCommandSpan(for: command)
|
||||
runGarbageCollection(for: command)
|
||||
// Enrich root command span with command's arguments
|
||||
let commandLineArguments = ProcessInfo.processInfo.arguments.map { argument in
|
||||
AttributeValue.string(argument)
|
||||
}
|
||||
span.setAttribute(key: "Command-line arguments", value: .array(AttributeArray(values: commandLineArguments)))
|
||||
|
||||
do {
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
// Run garbage-collection before each command (shouldn't take too long)
|
||||
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
|
||||
do {
|
||||
try Config().gc()
|
||||
} catch {
|
||||
fputs("Failed to perform garbage collection: \(error)\n", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// Run command
|
||||
if var asyncCommand = command as? AsyncParsableCommand {
|
||||
try await asyncCommand.run()
|
||||
} else {
|
||||
var command = command
|
||||
try command.run()
|
||||
}
|
||||
} catch {
|
||||
handleError(error, span: span)
|
||||
}
|
||||
|
||||
span.end()
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(0)
|
||||
}
|
||||
|
||||
// Create a root span for the command we're about to run.
|
||||
private static func startCommandSpan(for command: ParsableCommand) -> Span {
|
||||
let span = OTel.shared.tracer.spanBuilder(spanName: type(of: command)._commandName).startSpan()
|
||||
OpenTelemetry.instance.contextProvider.setActiveSpan(span)
|
||||
|
||||
// Enrich root command span with command's arguments
|
||||
let commandLineArguments = ProcessInfo.processInfo.arguments.map { argument in
|
||||
AttributeValue.string(argument)
|
||||
}
|
||||
span.setAttribute(key: "Command-line arguments", value: .array(AttributeArray(values: commandLineArguments)))
|
||||
|
||||
// Enrich root command span with Cirrus CI-specific tags
|
||||
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
||||
for (key, value) in tags.split(separator: ",").compactMap(splitEnvironmentVariable) {
|
||||
span.setAttribute(key: key, value: .string(value))
|
||||
// Not an error, just a custom exit code from "tart exec"
|
||||
if let execCustomExitCodeError = error as? ExecCustomExitCodeError {
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(execCustomExitCodeError.exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
return span
|
||||
}
|
||||
// Capture the error into OpenTelemetry
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error)
|
||||
|
||||
// Run garbage-collection before each command (shouldn't take too long).
|
||||
private static func runGarbageCollection(for command: ParsableCommand) {
|
||||
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()) {
|
||||
do {
|
||||
try Config().gc()
|
||||
} catch {
|
||||
fputs("Failed to perform garbage collection: \(error)\n", stderr)
|
||||
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
|
||||
if let errorWithExitCode = error as? HasExitCode {
|
||||
fputs("\(error)\n", stderr)
|
||||
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(errorWithExitCode.exitCode)
|
||||
}
|
||||
|
||||
// Handle any other exception, including ArgumentParser's ones
|
||||
exit(withError: error)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleError(_ error: Error, span: Span) -> Never {
|
||||
// Not an error, just a custom exit code from "tart exec"
|
||||
if let execCustomExitCodeError = error as? ExecCustomExitCodeError {
|
||||
span.end()
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(execCustomExitCodeError.exitCode)
|
||||
}
|
||||
|
||||
// Capture the error into OpenTelemetry
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error)
|
||||
span.end()
|
||||
|
||||
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
|
||||
if let errorWithExitCode = error as? HasExitCode {
|
||||
fputs("\(error)\n", stderr)
|
||||
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(errorWithExitCode.exitCode)
|
||||
}
|
||||
|
||||
// Handle any other exception, including ArgumentParser's ones
|
||||
OTel.shared.flush()
|
||||
exit(withError: error)
|
||||
}
|
||||
|
||||
private static func splitEnvironmentVariable(_ tag: String.SubSequence) -> (String, String)? {
|
||||
let splits = tag.split(separator: "=", maxSplits: 1)
|
||||
if splits.count != 2 {
|
||||
@@ -194,10 +123,3 @@ struct Root: AsyncParsableCommand {
|
||||
return (String(splits[0]), String(splits[1]))
|
||||
}
|
||||
}
|
||||
|
||||
// A command that drives an AppKit/SwiftUI run loop and therefore has to own the
|
||||
// main thread at the top level, rather than running inside Swift's asynchronous
|
||||
// main() executor. See Root.main() for the rationale.
|
||||
protocol MainThreadCommand: ParsableCommand {
|
||||
@MainActor func runOnMainThread() throws
|
||||
}
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
// A fire-and-forget task that reports any thrown error to stderr. An unstructured
|
||||
// Task spawned from a synchronous context (a signal handler, a SwiftUI action) has
|
||||
// no parent to propagate its error to, so we report it here instead of dropping it.
|
||||
struct ErrorReportingTask {
|
||||
let task: Task<Void, Never>
|
||||
|
||||
// Inherit the caller's actor context, exactly as Task.init does. Without this, an
|
||||
// operation written inside a @MainActor function runs on the cooperative pool
|
||||
// rather than the main queue, trapping in callees that assert their queue.
|
||||
@discardableResult
|
||||
init(_ context: String, @_inheritActorContext operation: @escaping @Sendable () async throws -> Void) {
|
||||
task = Task {
|
||||
do {
|
||||
try await operation()
|
||||
} catch {
|
||||
fputs("\(context): \(error)\n", stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection {
|
||||
subscript (safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
|
||||
@@ -49,7 +49,6 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
nested: Bool = false,
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true,
|
||||
noUSBAccessories: Bool = false,
|
||||
sync: VZDiskImageSynchronizationMode = .full,
|
||||
caching: VZDiskImageCachingMode? = nil,
|
||||
noTrackpad: Bool = false,
|
||||
@@ -65,7 +64,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
self.network = network
|
||||
configuration = try Self.craftConfiguration(vmDir: vmDir,
|
||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||
network: network, additionalStorageDevices: additionalStorageDevices,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
@@ -74,7 +73,6 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
nested: nested,
|
||||
audio: audio,
|
||||
clipboard: clipboard,
|
||||
noUSBAccessories: noUSBAccessories,
|
||||
sync: sync,
|
||||
caching: caching,
|
||||
noTrackpad: noTrackpad,
|
||||
@@ -198,8 +196,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
self.network = network
|
||||
configuration = try Self.craftConfiguration(vmDir: vmDir,
|
||||
nvramURL: vmDir.nvramURL,
|
||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, network: network,
|
||||
additionalStorageDevices: additionalStorageDevices,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
@@ -247,13 +244,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
return try VM(vmDir: vmDir)
|
||||
}
|
||||
|
||||
func start(recovery: Bool, resume shouldResume: Bool, provisioning: GuestProvisioningOptions? = nil) async throws {
|
||||
func start(recovery: Bool, resume shouldResume: Bool) async throws {
|
||||
try network.run(sema)
|
||||
|
||||
if shouldResume {
|
||||
try await resume()
|
||||
} else {
|
||||
try await start(recovery, provisioning: provisioning)
|
||||
try await start(recovery)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,15 +286,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func start(_ recovery: Bool, provisioning: GuestProvisioningOptions? = nil) async throws {
|
||||
private func start(_ recovery: Bool) async throws {
|
||||
#if arch(arm64)
|
||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||
startOptions.startUpFromMacOSRecovery = recovery
|
||||
#if compiler(>=6.4)
|
||||
if let provisioning = provisioning, #available(macOS 27, *) {
|
||||
try startOptions.setGuestProvisioning(provisioning.toVZMacGuestProvisioningOptions())
|
||||
}
|
||||
#endif
|
||||
try await virtualMachine.start(options: startOptions)
|
||||
#else
|
||||
try await virtualMachine.start()
|
||||
@@ -315,7 +307,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
static func craftConfiguration(
|
||||
vmDir: VMDirectory,
|
||||
diskURL: URL,
|
||||
nvramURL: URL,
|
||||
vmConfig: VMConfig,
|
||||
network: Network = NetworkShared(),
|
||||
@@ -326,7 +318,6 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
nested: Bool = false,
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true,
|
||||
noUSBAccessories: Bool = false,
|
||||
sync: VZDiskImageSynchronizationMode = .full,
|
||||
caching: VZDiskImageCachingMode? = nil,
|
||||
noTrackpad: Bool = false,
|
||||
@@ -367,15 +358,25 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.audioDevices = [soundDeviceConfiguration]
|
||||
|
||||
// Keyboard and mouse
|
||||
configureInputDevices(
|
||||
configuration,
|
||||
platform: vmConfig.platform,
|
||||
suspendable: suspendable,
|
||||
noUSBAccessories: noUSBAccessories,
|
||||
noTrackpad: noTrackpad,
|
||||
noPointer: noPointer,
|
||||
noKeyboard: noKeyboard
|
||||
)
|
||||
if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
|
||||
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
|
||||
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
|
||||
} else {
|
||||
|
||||
if noKeyboard {
|
||||
configuration.keyboards = []
|
||||
} else {
|
||||
configuration.keyboards = vmConfig.platform.keyboards()
|
||||
}
|
||||
|
||||
if noPointer {
|
||||
configuration.pointingDevices = []
|
||||
} else if noTrackpad {
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevicesSimplified()
|
||||
} else {
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
}
|
||||
}
|
||||
|
||||
// Networking
|
||||
configuration.networkDevices = network.attachments().map {
|
||||
@@ -398,25 +399,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
// Storage
|
||||
// When not specified, use "cached" caching mode for Linux VMs to prevent file-system corruption[1]
|
||||
//
|
||||
// [1]: https://github.com/cirruslabs/tart/pull/675
|
||||
let cachingMode = caching ?? (vmConfig.os == .linux ? .cached : .automatic)
|
||||
let attachment: VZStorageDeviceAttachment
|
||||
if vmDir.isStackedVM {
|
||||
attachment = try vmDir.diskImageStack().makeAttachment(
|
||||
readOnly: false,
|
||||
cachingMode: cachingMode,
|
||||
synchronizationMode: sync
|
||||
)
|
||||
} else {
|
||||
attachment = try VZDiskImageStorageDeviceAttachment(
|
||||
url: vmDir.diskURL,
|
||||
readOnly: false,
|
||||
cachingMode: cachingMode,
|
||||
synchronizationMode: sync
|
||||
)
|
||||
}
|
||||
var attachment = try VZDiskImageStorageDeviceAttachment(
|
||||
url: diskURL,
|
||||
readOnly: false,
|
||||
// When not specified, use "cached" caching mode for Linux VMs to prevent file-system corruption[1]
|
||||
//
|
||||
// [1]: https://github.com/cirruslabs/tart/pull/675
|
||||
cachingMode: caching ?? (vmConfig.os == .linux ? .cached : .automatic),
|
||||
synchronizationMode: sync
|
||||
)
|
||||
|
||||
var devices: [VZStorageDeviceConfiguration] = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]
|
||||
devices.append(contentsOf: additionalStorageDevices)
|
||||
@@ -453,31 +444,6 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
return configuration
|
||||
}
|
||||
|
||||
static func configureInputDevices(
|
||||
_ configuration: VZVirtualMachineConfiguration,
|
||||
platform: Platform,
|
||||
suspendable: Bool = false,
|
||||
noUSBAccessories: Bool = false,
|
||||
noTrackpad: Bool = false,
|
||||
noPointer: Bool = false,
|
||||
noKeyboard: Bool = false
|
||||
) {
|
||||
if suspendable, let platformSuspendable = platform as? PlatformSuspendable {
|
||||
configuration.keyboards = platformSuspendable.keyboardsSuspendable(noUSB: noUSBAccessories)
|
||||
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable(noUSB: noUSBAccessories)
|
||||
} else {
|
||||
configuration.keyboards = noKeyboard ? [] : platform.keyboards(noUSB: noUSBAccessories)
|
||||
|
||||
if noPointer {
|
||||
configuration.pointingDevices = []
|
||||
} else if noTrackpad {
|
||||
configuration.pointingDevices = platform.pointingDevicesSimplified(noUSB: noUSBAccessories)
|
||||
} else {
|
||||
configuration.pointingDevices = platform.pointingDevices(noUSB: noUSBAccessories)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func guestDidStop(_ virtualMachine: VZVirtualMachine) {
|
||||
print("guest has stopped the virtual machine")
|
||||
sema.signal()
|
||||
|
||||
@@ -99,7 +99,7 @@ struct VMConfig: Codable {
|
||||
|
||||
func save(toURL: URL) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
try encoder.encode(self).write(to: toURL)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Foundation
|
||||
import System
|
||||
import AppleArchive
|
||||
|
||||
@@ -11,16 +10,6 @@ fileprivate let permissions = FilePermissions(rawValue: 0o644)
|
||||
// [2]: https://developer.apple.com/documentation/compression/algorithm/lzfse
|
||||
extension VMDirectory {
|
||||
func exportToArchive(path: String) throws {
|
||||
let temporaryArchive = try stackedArchiveDirectoryIfNeeded()
|
||||
let archiveSourceURL = temporaryArchive?.vmDirectory.baseURL ?? baseURL
|
||||
|
||||
defer {
|
||||
if let temporaryArchive {
|
||||
try? temporaryArchive.lock.unlock()
|
||||
try? temporaryArchive.vmDirectory.removeFromDisk()
|
||||
}
|
||||
}
|
||||
|
||||
guard let fileStream = ArchiveByteStream.fileStream(
|
||||
path: FilePath(path),
|
||||
mode: .writeOnly,
|
||||
@@ -60,7 +49,7 @@ extension VMDirectory {
|
||||
return
|
||||
}
|
||||
|
||||
try encodeStream.writeDirectoryContents(archiveFrom: FilePath(archiveSourceURL.path), keySet: keySet)
|
||||
try encodeStream.writeDirectoryContents(archiveFrom: FilePath(baseURL.path), keySet: keySet)
|
||||
}
|
||||
|
||||
func importFromArchive(path: String) throws {
|
||||
@@ -103,145 +92,5 @@ extension VMDirectory {
|
||||
}
|
||||
|
||||
_ = try ArchiveStream.process(readingFrom: decodeStream, writingTo: extractStream)
|
||||
|
||||
if isStackedVM {
|
||||
try restoreStackedArchive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a self-contained staging directory for a stacked archive, if this
|
||||
/// directory currently resolves to a stacked VM or cached image.
|
||||
private func stackedArchiveDirectoryIfNeeded() throws -> (vmDirectory: VMDirectory, lock: FileLock)? {
|
||||
guard isStackedVM || isStackedCachedImage else {
|
||||
return nil
|
||||
}
|
||||
try DiskImageStack.requireSupport()
|
||||
|
||||
let contentStore = try ContentStore()
|
||||
let archiveVMDir = try VMDirectory.temporary()
|
||||
let archiveVMDirLock = try FileLock(lockURL: archiveVMDir.baseURL)
|
||||
try archiveVMDirLock.lock()
|
||||
|
||||
do {
|
||||
let stagedSource: (isStackedVM: Bool, contentDigests: [String])? = try contentStore.withPruneLock {
|
||||
() -> (isStackedVM: Bool, contentDigests: [String])? in
|
||||
// OCI tags are mutable symlinks. Resolve one digest record while tag
|
||||
// replacement and cached-image deletion are blocked, then copy every
|
||||
// source-owned file before releasing the lock.
|
||||
let sourceVMDir = VMDirectory(baseURL: baseURL.resolvingSymlinksInPath())
|
||||
guard sourceVMDir.isStackedVM || sourceVMDir.isStackedCachedImage else {
|
||||
throw RuntimeError.ExportFailed("VM changed while preparing export, retry the command")
|
||||
}
|
||||
|
||||
let sourceIsStackedVM = sourceVMDir.isStackedVM
|
||||
let sourceVMLock: PIDLock?
|
||||
if sourceIsStackedVM {
|
||||
let lock = try sourceVMDir.lock()
|
||||
guard try lock.trylock() else {
|
||||
throw RuntimeError.ExportFailed("VM \"\(sourceVMDir.name)\" must be stopped before export")
|
||||
}
|
||||
sourceVMLock = lock
|
||||
|
||||
// Holding the PID lock proves that the VM is not running. A saved
|
||||
// state file is the remaining suspended state that must reject export.
|
||||
guard !FileManager.default.fileExists(atPath: sourceVMDir.stateURL.path) else {
|
||||
try? lock.unlock()
|
||||
throw RuntimeError.ExportFailed("VM \"\(sourceVMDir.name)\" must be stopped before export")
|
||||
}
|
||||
} else {
|
||||
sourceVMLock = nil
|
||||
}
|
||||
defer { try? sourceVMLock?.unlock() }
|
||||
|
||||
try FileManager.default.copyItem(at: sourceVMDir.configURL, to: archiveVMDir.configURL)
|
||||
try FileManager.default.copyItem(at: sourceVMDir.nvramURL, to: archiveVMDir.nvramURL)
|
||||
try FileManager.default.copyItem(at: sourceVMDir.manifestURL, to: archiveVMDir.manifestURL)
|
||||
if sourceIsStackedVM {
|
||||
try FileManager.default.copyItem(at: sourceVMDir.overlayURL, to: archiveVMDir.overlayURL)
|
||||
}
|
||||
|
||||
return (sourceIsStackedVM, try archiveVMDir.diskContentDigests())
|
||||
}
|
||||
|
||||
guard let stagedSource else {
|
||||
try archiveVMDirLock.unlock()
|
||||
try archiveVMDir.removeFromDisk()
|
||||
return nil
|
||||
}
|
||||
|
||||
if !stagedSource.isStackedVM {
|
||||
try archiveVMDir.diskImageStack().createWritableOverlay()
|
||||
}
|
||||
|
||||
// The staged manifest is now an in-progress reference, so immutable
|
||||
// content remains protected while these potentially large copies run
|
||||
// without holding the global prune lock.
|
||||
for contentDigest in stagedSource.contentDigests {
|
||||
guard let sourceURL = try contentStore.existingContentURL(for: contentDigest) else {
|
||||
throw RuntimeError.ExportFailed("VM is missing cached disk content \(contentDigest)")
|
||||
}
|
||||
|
||||
let destinationURL = try contentStore.contentURL(
|
||||
for: contentDigest,
|
||||
under: archiveContentStoreURL(in: archiveVMDir)
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: destinationURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
|
||||
}
|
||||
|
||||
return (archiveVMDir, archiveVMDirLock)
|
||||
} catch {
|
||||
try? archiveVMDirLock.unlock()
|
||||
try? archiveVMDir.removeFromDisk()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores immutable files from an archive into the shared content store,
|
||||
/// removes the archive-only payload, then validates the resulting stack.
|
||||
private func restoreStackedArchive() throws {
|
||||
try DiskImageStack.requireSupport()
|
||||
|
||||
let contentStore = try ContentStore()
|
||||
// The extracted manifest is already a reference; synchronize publication
|
||||
// with a concurrent prune before installing its immutable content.
|
||||
try contentStore.synchronizePublishedReferences()
|
||||
for contentDigest in try diskContentDigests() {
|
||||
if try contentStore.existingContentURL(for: contentDigest) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
let archivedContentURL = try contentStore.contentURL(
|
||||
for: contentDigest,
|
||||
under: archiveContentStoreURL(in: self)
|
||||
)
|
||||
guard FileManager.default.fileExists(atPath: archivedContentURL.path) else {
|
||||
throw RuntimeError.ImportFailed("archive is missing disk content \(contentDigest)")
|
||||
}
|
||||
|
||||
let temporaryURL = try contentStore.temporaryContentURL(for: contentDigest)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: archivedContentURL, to: temporaryURL)
|
||||
_ = try contentStore.install(temporaryURL, contentDigest: contentDigest)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: temporaryURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
try FileManager.default.removeItem(at: archiveContentStoreURL(in: self))
|
||||
try? FileManager.default.removeItem(at: stateURL)
|
||||
|
||||
// Opening the attachment validates the reconstructed immutable stack and
|
||||
// imported writable overlay before the VM enters local storage.
|
||||
_ = try diskImageStack().makeAttachment()
|
||||
}
|
||||
|
||||
private func archiveContentStoreURL(in vmDir: VMDirectory) -> URL {
|
||||
vmDir.baseURL.appendingPathComponent("content", isDirectory: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension VMDirectory {
|
||||
/// Returns content-store digests needed to reconstruct this VM's disk stack.
|
||||
func diskContentDigests() throws -> [String] {
|
||||
let manifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL))
|
||||
return try manifest.diskContentDigests()
|
||||
}
|
||||
|
||||
func diskImageStack(contentStore providedStore: ContentStore? = nil) throws -> DiskImageStack {
|
||||
let manifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL))
|
||||
let base: TartDiskFileGroup
|
||||
let overlays: [TartDiskFileGroup]
|
||||
|
||||
switch try manifest.tartDiskRepresentation() {
|
||||
case .flat(let pinnedBase) where pinnedBase.contentDigest != nil:
|
||||
base = pinnedBase
|
||||
overlays = []
|
||||
case .stacked(let stackedBase, let stackedOverlays):
|
||||
base = stackedBase
|
||||
overlays = stackedOverlays
|
||||
default:
|
||||
throw RuntimeError.VMConfigurationError("VM is missing its disk image metadata")
|
||||
}
|
||||
guard let blockSize = manifest.diskBlockSize(),
|
||||
let blockCount = manifest.diskBlockCount() else {
|
||||
throw DiskImageStackError.invalidBlockLayout("disk image metadata is missing block layout")
|
||||
}
|
||||
|
||||
let contentStore = try providedStore ?? ContentStore()
|
||||
let baseURL = try diskImageURL(for: base, contentStore: contentStore)
|
||||
let immutableOverlayURLs = try overlays.map { try diskImageURL(for: $0, contentStore: contentStore) }
|
||||
let config = try VMConfig(fromURL: configURL)
|
||||
|
||||
return DiskImageStack(
|
||||
baseURL: baseURL,
|
||||
baseFormat: config.diskFormat,
|
||||
immutableOverlayURLs: immutableOverlayURLs,
|
||||
writableOverlayURL: overlayURL,
|
||||
blockSize: blockSize,
|
||||
blockCount: blockCount
|
||||
)
|
||||
}
|
||||
|
||||
func cloneStacked(
|
||||
to destination: VMDirectory,
|
||||
copyWritableOverlay: Bool,
|
||||
generateMAC: Bool,
|
||||
contentStore: ContentStore? = nil
|
||||
) throws {
|
||||
let contentStore = try contentStore ?? ContentStore()
|
||||
try contentStore.withPruneLock {
|
||||
try FileManager.default.copyItem(at: configURL, to: destination.configURL)
|
||||
try FileManager.default.copyItem(at: nvramURL, to: destination.nvramURL)
|
||||
try FileManager.default.copyItem(at: manifestURL, to: destination.manifestURL)
|
||||
|
||||
if copyWritableOverlay {
|
||||
try FileManager.default.copyItem(at: overlayURL, to: destination.overlayURL)
|
||||
}
|
||||
}
|
||||
|
||||
if !copyWritableOverlay {
|
||||
try destination.diskImageStack(contentStore: contentStore).createWritableOverlay()
|
||||
}
|
||||
|
||||
if generateMAC {
|
||||
try destination.regenerateMACAddress()
|
||||
}
|
||||
}
|
||||
|
||||
func cloneAsStackedBase(
|
||||
to destination: VMDirectory,
|
||||
generateMAC: Bool,
|
||||
contentStore providedStore: ContentStore? = nil
|
||||
) throws {
|
||||
let config = try VMConfig(fromURL: configURL)
|
||||
let blockLayout = try DiskImageStack.baseBlockLayout(at: diskURL, expectedFormat: config.diskFormat)
|
||||
let contentDigest = try Digest.hash(diskURL)
|
||||
let contentStore = try providedStore ?? ContentStore()
|
||||
|
||||
var manifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL))
|
||||
guard case .flat = try manifest.tartDiskRepresentation() else {
|
||||
throw RuntimeError.VMConfigurationError("--stacked cannot use an image that already has a stacked disk")
|
||||
}
|
||||
|
||||
guard let firstDiskIndex = manifest.layers.firstIndex(where: { $0.mediaType == diskV2MediaType }) else {
|
||||
throw OCIManifestValidationError.invalidLayout("manifest must contain at least one disk chunk")
|
||||
}
|
||||
|
||||
var baseAnnotations = manifest.layers[firstDiskIndex].annotations ?? [:]
|
||||
baseAnnotations[diskFileContentDigestAnnotation] = contentDigest
|
||||
manifest.layers[firstDiskIndex].annotations = baseAnnotations
|
||||
let diskSize = blockLayout.blockSize.multipliedReportingOverflow(by: blockLayout.blockCount)
|
||||
guard !diskSize.overflow else {
|
||||
throw DiskImageStackError.invalidBlockLayout("stacked disk block layout overflows UInt64")
|
||||
}
|
||||
var annotations = manifest.annotations ?? [:]
|
||||
annotations[diskBlockSizeAnnotation] = String(blockLayout.blockSize)
|
||||
annotations[uncompressedDiskSizeAnnotation] = String(diskSize.partialValue)
|
||||
manifest.annotations = annotations
|
||||
|
||||
try FileManager.default.copyItem(at: configURL, to: destination.configURL)
|
||||
try FileManager.default.copyItem(at: nvramURL, to: destination.nvramURL)
|
||||
try contentStore.withPruneLock {
|
||||
try manifest.toJSON().write(to: destination.manifestURL)
|
||||
}
|
||||
|
||||
// Publish the temporary VM's manifest before installing the shared base.
|
||||
// Reference-aware pruning includes in-progress manifests, so the content
|
||||
// cannot be collected in the window before this VM is moved into place.
|
||||
if try contentStore.contentURLIfPresent(for: contentDigest) == nil {
|
||||
let temporaryURL = try contentStore.temporaryContentURL(for: contentDigest)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: diskURL, to: temporaryURL)
|
||||
_ = try contentStore.install(temporaryURL, contentDigest: contentDigest)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: temporaryURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
try destination.diskImageStack(contentStore: contentStore).createWritableOverlay()
|
||||
|
||||
if generateMAC {
|
||||
try destination.regenerateMACAddress()
|
||||
}
|
||||
}
|
||||
|
||||
private func diskImageURL(for group: TartDiskFileGroup, contentStore: ContentStore) throws -> URL {
|
||||
guard let contentDigest = group.contentDigest else {
|
||||
throw OCIManifestValidationError.invalidDiskMetadata("stacked disk files need a whole-file content digest")
|
||||
}
|
||||
// Pull/install verifies immutable content before publishing it. Clone and
|
||||
// run use the trusted content-addressed entry without rereading a possibly
|
||||
// very large disk file, matching Tart's existing disk.img behavior.
|
||||
guard let url = try contentStore.contentURLIfPresent(for: contentDigest) else {
|
||||
throw RuntimeError.VMMissingFiles("VM is missing cached disk content \(contentDigest)")
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ let legacyDiskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
||||
|
||||
enum OCIError: Error {
|
||||
case ShouldBeExactlyOneLayer
|
||||
case ShouldBeAtLeastOneLayer
|
||||
case FailedToCreateVmFile
|
||||
case LayerIsMissingUncompressedSizeAnnotation
|
||||
case LayerIsMissingUncompressedDigestAnnotation
|
||||
@@ -13,7 +14,7 @@ enum OCIError: Error {
|
||||
|
||||
extension VMDirectory {
|
||||
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws {
|
||||
// Pull VM's config file layer and store it as the local config file.
|
||||
// Pull VM's config file layer and re-serialize it into a config file
|
||||
let configLayers = manifest.layers.filter {
|
||||
$0.mediaType == configMediaType
|
||||
}
|
||||
@@ -29,22 +30,17 @@ extension VMDirectory {
|
||||
}
|
||||
try configFile.close()
|
||||
|
||||
// Pull VM's disk chunks and decompress them into complete disk files.
|
||||
// Pull VM's disk layers and decompress them into a disk file
|
||||
if manifest.layers.contains(where: { $0.mediaType == legacyDiskV1MediaType }) {
|
||||
throw RuntimeError.Generic("Pulling OCI images with legacy disk media type \(legacyDiskV1MediaType) is no longer supported, please re-push the image using a current Tart version")
|
||||
}
|
||||
|
||||
let diskRepresentation = try manifest.tartDiskRepresentation()
|
||||
let diskChunks: [OCIManifestLayer]
|
||||
|
||||
switch diskRepresentation {
|
||||
case .flat(let base):
|
||||
diskChunks = base.chunks
|
||||
case .stacked(let base, let overlays):
|
||||
diskChunks = base.chunks + overlays.flatMap(\.chunks)
|
||||
let layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
|
||||
if layers.isEmpty {
|
||||
throw OCIError.ShouldBeAtLeastOneLayer
|
||||
}
|
||||
|
||||
let diskCompressedSize = diskChunks.map { Int64($0.size) }.reduce(0, +)
|
||||
let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +)
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
|
||||
key: "compressed_disk_size_bytes",
|
||||
value: .int(Int(diskCompressedSize))
|
||||
@@ -57,42 +53,19 @@ extension VMDirectory {
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
do {
|
||||
switch diskRepresentation {
|
||||
case .flat(let base):
|
||||
try await DiskV2.pull(registry: registry, diskLayers: base.chunks, diskURL: diskURL,
|
||||
concurrency: concurrency, progress: progress,
|
||||
localLayerCache: localLayerCache,
|
||||
deduplicate: deduplicate)
|
||||
|
||||
if deduplicate, let llc = localLayerCache {
|
||||
// set custom attribute to remember deduplicated bytes
|
||||
diskURL.setDeduplicatedBytes(llc.deduplicatedBytes)
|
||||
}
|
||||
case .stacked(let base, let overlays):
|
||||
// The deterministic resumable directory may contain a partial
|
||||
// disk.img from an interrupted pull while this tag was standalone. A
|
||||
// cached stacked image must not retain that file or it is mistaken for
|
||||
// a standalone VM after the pull is moved into cache.
|
||||
if FileManager.default.fileExists(atPath: diskURL.path) {
|
||||
try FileManager.default.removeItem(at: diskURL)
|
||||
}
|
||||
|
||||
let contentStore = try ContentStore()
|
||||
|
||||
for group in [base] + overlays {
|
||||
_ = try await pullDiskFile(
|
||||
registry: registry,
|
||||
group: group,
|
||||
contentStore: contentStore,
|
||||
concurrency: concurrency,
|
||||
progress: progress
|
||||
)
|
||||
}
|
||||
}
|
||||
try await DiskV2.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
|
||||
concurrency: concurrency, progress: progress,
|
||||
localLayerCache: localLayerCache,
|
||||
deduplicate: deduplicate)
|
||||
} catch let error where error is FilterError {
|
||||
throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
if deduplicate, let llc = localLayerCache {
|
||||
// set custom attribute to remember deduplicated bytes
|
||||
diskURL.setDeduplicatedBytes(llc.deduplicatedBytes)
|
||||
}
|
||||
|
||||
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
||||
defaultLogger.appendNewLine("pulling NVRAM...")
|
||||
|
||||
@@ -110,50 +83,12 @@ extension VMDirectory {
|
||||
try nvram.write(contentsOf: data)
|
||||
}
|
||||
try nvram.close()
|
||||
|
||||
// Serialize VM's manifest to enable better deduplication on subsequent "tart pull"'s
|
||||
try manifest.toJSON().write(to: manifestURL)
|
||||
}
|
||||
|
||||
/// Reconstructs one complete immutable base disk or published ASIF overlay
|
||||
/// from its Tart disk chunks, unless the shared content store already has a
|
||||
/// size-matching copy.
|
||||
private func pullDiskFile(
|
||||
registry: Registry,
|
||||
group: TartDiskFileGroup,
|
||||
contentStore: ContentStore,
|
||||
concurrency: UInt,
|
||||
progress: Progress
|
||||
) async throws -> URL {
|
||||
guard let contentDigest = group.contentDigest else {
|
||||
throw OCIManifestValidationError.invalidDiskMetadata("stacked disk files need a whole-file content digest")
|
||||
}
|
||||
|
||||
// Pulls for the same semantic disk file share a stable resumable path so
|
||||
// DiskV2 can resume after a transient failure. Serialize writers before
|
||||
// rechecking the final entry to avoid racing on that shared path.
|
||||
let lock = try FileLock(lockURL: contentStore.lockURL(for: contentDigest))
|
||||
try lock.lock()
|
||||
defer { try? lock.unlock() }
|
||||
|
||||
if let existingURL = try contentStore.contentURLIfPresent(for: contentDigest),
|
||||
let actualSize = UInt64(exactly: try existingURL.sizeBytes()),
|
||||
let expectedSize = group.uncompressedSize(),
|
||||
actualSize == expectedSize {
|
||||
progress.completedUnitCount += group.chunks.reduce(0) { $0 + Int64($1.size) }
|
||||
return existingURL
|
||||
}
|
||||
|
||||
let resumableURL = try contentStore.resumableContentURL(for: contentDigest)
|
||||
try await DiskV2.pull(
|
||||
registry: registry,
|
||||
diskLayers: group.chunks,
|
||||
diskURL: resumableURL,
|
||||
concurrency: concurrency,
|
||||
progress: progress
|
||||
)
|
||||
|
||||
return try contentStore.install(resumableURL, contentDigest: contentDigest)
|
||||
}
|
||||
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, concurrency: UInt, labels: [String: String] = [:]) async throws -> (name: RemoteName, manifest: OCIManifest) {
|
||||
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
|
||||
@@ -167,12 +102,14 @@ extension VMDirectory {
|
||||
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
||||
layers.append(OCIManifestLayer(mediaType: configMediaType, size: configJSON.count, digest: configDigest))
|
||||
|
||||
let (diskLayers, diskAnnotations) = try await pushDiskLayers(
|
||||
registry: registry,
|
||||
chunkSizeMb: chunkSizeMb,
|
||||
concurrency: concurrency
|
||||
)
|
||||
layers.append(contentsOf: diskLayers)
|
||||
// Compress the disk file as multiple chunks and push them as disk layers
|
||||
let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64
|
||||
|
||||
defaultLogger.appendNewLine("pushing disk... this will take a while...")
|
||||
let progress = Progress(totalUnitCount: diskSize)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
|
||||
|
||||
// Read VM's NVRAM and push it as blob
|
||||
defaultLogger.appendNewLine("pushing NVRAM...")
|
||||
@@ -185,13 +122,13 @@ extension VMDirectory {
|
||||
let ociConfigContainer = OCIConfig.ConfigContainer(Labels: labels)
|
||||
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os, config: ociConfigContainer).toJSON()
|
||||
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
|
||||
var manifest = OCIManifest(
|
||||
let manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||
layers: layers
|
||||
layers: layers,
|
||||
uncompressedDiskSize: UInt64(diskSize),
|
||||
uploadDate: Date()
|
||||
)
|
||||
var annotations = diskAnnotations
|
||||
annotations[uploadTimeAnnotation] = Date().toISO()
|
||||
manifest.annotations = annotations
|
||||
|
||||
// Manifest
|
||||
for reference in references {
|
||||
defaultLogger.appendNewLine("pushing manifest for \(reference)...")
|
||||
@@ -200,158 +137,7 @@ extension VMDirectory {
|
||||
}
|
||||
|
||||
let pushedReference = Reference(digest: try manifest.digest())
|
||||
let name = RemoteName(host: registry.host!, namespace: registry.namespace, reference: pushedReference)
|
||||
return (name, manifest)
|
||||
}
|
||||
|
||||
/// Builds the disk portion of the manifest. Registry transport is shared
|
||||
/// for standalone and stacked VMs; only their local disk representation
|
||||
/// determines which descriptors need to be uploaded or reused.
|
||||
private func pushDiskLayers(
|
||||
registry: Registry,
|
||||
chunkSizeMb: Int,
|
||||
concurrency: UInt
|
||||
) async throws -> ([OCIManifestLayer], [String: String]) {
|
||||
guard isStackedVM else {
|
||||
let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64
|
||||
defaultLogger.appendNewLine("pushing disk... this will take a while...")
|
||||
let progress = Progress(totalUnitCount: diskSize)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
let layers = try await DiskV2.push(
|
||||
diskURL: diskURL,
|
||||
mediaType: diskV2MediaType,
|
||||
registry: registry,
|
||||
chunkSizeMb: chunkSizeMb,
|
||||
concurrency: concurrency,
|
||||
progress: progress
|
||||
)
|
||||
return (layers, [uncompressedDiskSizeAnnotation: String(diskSize)])
|
||||
}
|
||||
|
||||
let localManifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL))
|
||||
// pushToRegistry() reads config.json before reaching this point. Closing
|
||||
// that read descriptor can release the caller's fcntl PID lock, so take a
|
||||
// fresh lock before hashing, uploading, and inspecting the writable overlay.
|
||||
let stackedDiskLock = try lock()
|
||||
guard try stackedDiskLock.trylock() else {
|
||||
throw RuntimeError.VMIsRunning(name)
|
||||
}
|
||||
defer { try? stackedDiskLock.unlock() }
|
||||
|
||||
let inheritedGroups: [TartDiskFileGroup]
|
||||
switch try localManifest.tartDiskRepresentation() {
|
||||
case .flat(let base) where base.contentDigest != nil:
|
||||
inheritedGroups = [base]
|
||||
case .stacked(let base, let overlays):
|
||||
inheritedGroups = [base] + overlays
|
||||
default:
|
||||
throw RuntimeError.VMConfigurationError("stacked VM is missing a pinned disk stack")
|
||||
}
|
||||
|
||||
let contentStore = try ContentStore()
|
||||
var layers: [OCIManifestLayer] = []
|
||||
for group in inheritedGroups {
|
||||
layers.append(contentsOf: try await descriptorsForCachedDiskFile(
|
||||
group,
|
||||
contentStore: contentStore,
|
||||
registry: registry,
|
||||
chunkSizeMb: chunkSizeMb,
|
||||
concurrency: concurrency
|
||||
))
|
||||
}
|
||||
|
||||
let overlaySize = try FileManager.default.attributesOfItem(atPath: overlayURL.path)[.size] as! Int64
|
||||
defaultLogger.appendNewLine("pushing overlay...")
|
||||
let progress = Progress(totalUnitCount: overlaySize)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
let contentDigest = try Digest.hash(overlayURL)
|
||||
let chunks = try await DiskV2.push(
|
||||
diskURL: overlayURL,
|
||||
mediaType: asifOverlayMediaType,
|
||||
registry: registry,
|
||||
chunkSizeMb: chunkSizeMb,
|
||||
concurrency: concurrency,
|
||||
progress: progress
|
||||
)
|
||||
layers.append(contentsOf: annotatedChunks(chunks, kind: .asifOverlay, contentDigest: contentDigest))
|
||||
|
||||
let blockLayout = try DiskImageStack.diskImageBlockLayout(at: overlayURL)
|
||||
let diskSize = blockLayout.blockSize.multipliedReportingOverflow(by: blockLayout.blockCount)
|
||||
guard !diskSize.overflow else {
|
||||
throw DiskImageStackError.invalidBlockLayout("stacked disk block layout overflows UInt64")
|
||||
}
|
||||
|
||||
var annotations = localManifest.annotations ?? [:]
|
||||
annotations[diskBlockSizeAnnotation] = String(blockLayout.blockSize)
|
||||
annotations[uncompressedDiskSizeAnnotation] = String(diskSize.partialValue)
|
||||
|
||||
return (layers, annotations)
|
||||
}
|
||||
|
||||
/// Returns transport descriptors for an immutable disk file. If the
|
||||
/// target registry lacks the original blobs, recreate them from the local
|
||||
/// content store.
|
||||
private func descriptorsForCachedDiskFile(
|
||||
_ group: TartDiskFileGroup,
|
||||
contentStore: ContentStore,
|
||||
registry: Registry,
|
||||
chunkSizeMb: Int,
|
||||
concurrency: UInt
|
||||
) async throws -> [OCIManifestLayer] {
|
||||
guard let contentDigest = group.contentDigest else {
|
||||
throw RuntimeError.VMConfigurationError("stacked VM is missing a pinned disk file digest")
|
||||
}
|
||||
|
||||
var allChunksExist = true
|
||||
for chunk in group.chunks {
|
||||
if try await !registry.blobExists(chunk.digest) {
|
||||
allChunksExist = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allChunksExist {
|
||||
return group.chunks
|
||||
}
|
||||
|
||||
// Rebuilding transport blobs republishes this file under the pinned
|
||||
// whole-file digest, so validate the cached bytes at this boundary.
|
||||
guard let contentURL = try contentStore.existingContentURL(for: contentDigest) else {
|
||||
throw RuntimeError.VMMissingFiles("stacked VM is missing cached disk content \(contentDigest)")
|
||||
}
|
||||
let contentSize = try FileManager.default.attributesOfItem(atPath: contentURL.path)[.size] as! Int64
|
||||
let progress = Progress(totalUnitCount: contentSize)
|
||||
let mediaType = group.kind == .base ? diskV2MediaType : asifOverlayMediaType
|
||||
let chunks = try await DiskV2.push(
|
||||
diskURL: contentURL,
|
||||
mediaType: mediaType,
|
||||
registry: registry,
|
||||
chunkSizeMb: chunkSizeMb,
|
||||
concurrency: concurrency,
|
||||
progress: progress
|
||||
)
|
||||
|
||||
return annotatedChunks(chunks, kind: group.kind, contentDigest: contentDigest)
|
||||
}
|
||||
|
||||
private func annotatedChunks(
|
||||
_ chunks: [OCIManifestLayer],
|
||||
kind: TartDiskFileGroup.Kind,
|
||||
contentDigest: String
|
||||
) -> [OCIManifestLayer] {
|
||||
guard !chunks.isEmpty else {
|
||||
return chunks
|
||||
}
|
||||
|
||||
var chunks = chunks
|
||||
var annotations = chunks[0].annotations ?? [:]
|
||||
annotations[diskFileContentDigestAnnotation] = contentDigest
|
||||
if kind == .asifOverlay {
|
||||
annotations[diskFileChunkCountAnnotation] = String(chunks.count)
|
||||
}
|
||||
chunks[0].annotations = annotations
|
||||
|
||||
return chunks
|
||||
return RemoteName(host: registry.host!, namespace: registry.namespace, reference: pushedReference)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,6 @@ struct VMDirectory: Prunable {
|
||||
var manifestURL: URL {
|
||||
baseURL.appendingPathComponent("manifest.json")
|
||||
}
|
||||
var overlayURL: URL {
|
||||
baseURL.appendingPathComponent("overlay.asif")
|
||||
}
|
||||
var controlSocketURL: URL {
|
||||
URL(fileURLWithPath: "control.sock", relativeTo: baseURL)
|
||||
}
|
||||
@@ -90,74 +87,10 @@ struct VMDirectory: Prunable {
|
||||
return VMDirectory(baseURL: tmpDir)
|
||||
}
|
||||
|
||||
private var hasRequiredMetadata: Bool {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
return fileManager.fileExists(atPath: configURL.path) &&
|
||||
fileManager.fileExists(atPath: nvramURL.path)
|
||||
}
|
||||
|
||||
enum Layout: Equatable {
|
||||
/// Existing Tart layout with one independently attachable `disk.img`.
|
||||
/// A pulled standalone OCI record may also carry `manifest.json`.
|
||||
case standalone
|
||||
|
||||
/// Runnable stacked VM with immutable disk files from `manifest.json` and
|
||||
/// a private writable `overlay.asif`.
|
||||
case stackedLocal
|
||||
|
||||
/// Pulled OCI record for a stacked image. It intentionally has no writable
|
||||
/// overlay and becomes runnable only after `tart clone` creates one.
|
||||
case stackedOCIRecord
|
||||
|
||||
var isRunnable: Bool {
|
||||
self != .stackedOCIRecord
|
||||
}
|
||||
}
|
||||
|
||||
var layout: Layout? {
|
||||
let fileManager = FileManager.default
|
||||
let hasDisk = fileManager.fileExists(atPath: diskURL.path)
|
||||
let hasManifest = fileManager.fileExists(atPath: manifestURL.path)
|
||||
let hasOverlay = fileManager.fileExists(atPath: overlayURL.path)
|
||||
|
||||
guard hasRequiredMetadata else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if hasDisk && !hasOverlay {
|
||||
return .standalone
|
||||
}
|
||||
if !hasDisk && hasManifest && hasOverlay {
|
||||
return .stackedLocal
|
||||
}
|
||||
if !hasDisk && hasManifest && !hasOverlay {
|
||||
return .stackedOCIRecord
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var initialized: Bool {
|
||||
layout?.isRunnable == true
|
||||
}
|
||||
|
||||
var isStandalone: Bool {
|
||||
layout == .standalone
|
||||
}
|
||||
|
||||
var isStackedVM: Bool {
|
||||
layout == .stackedLocal
|
||||
}
|
||||
|
||||
var isStackedCachedImage: Bool {
|
||||
layout == .stackedOCIRecord
|
||||
}
|
||||
|
||||
/// Shapes that may live in the remote-image cache. A cached stacked image
|
||||
/// has no writable overlay and is intentionally not runnable as a local VM.
|
||||
var isCachedImage: Bool {
|
||||
layout == .standalone || layout == .stackedOCIRecord
|
||||
FileManager.default.fileExists(atPath: configURL.path) &&
|
||||
FileManager.default.fileExists(atPath: diskURL.path) &&
|
||||
FileManager.default.fileExists(atPath: nvramURL.path)
|
||||
}
|
||||
|
||||
func initialize(overwrite: Bool = false) throws {
|
||||
@@ -170,9 +103,6 @@ struct VMDirectory: Prunable {
|
||||
try? FileManager.default.removeItem(at: configURL)
|
||||
try? FileManager.default.removeItem(at: diskURL)
|
||||
try? FileManager.default.removeItem(at: nvramURL)
|
||||
try? FileManager.default.removeItem(at: manifestURL)
|
||||
try? FileManager.default.removeItem(at: overlayURL)
|
||||
try? FileManager.default.removeItem(at: stateURL)
|
||||
}
|
||||
|
||||
func validate(userFriendlyName: String) throws {
|
||||
@@ -181,26 +111,8 @@ struct VMDirectory: Prunable {
|
||||
}
|
||||
|
||||
if !initialized {
|
||||
throw RuntimeError.VMMissingFiles(
|
||||
"VM is missing files for a supported layout: "
|
||||
+ "standalone requires \(configURL.lastPathComponent), \(diskURL.lastPathComponent) and \(nvramURL.lastPathComponent); "
|
||||
+ "stacked requires \(configURL.lastPathComponent), \(manifestURL.lastPathComponent), "
|
||||
+ "\(overlayURL.lastPathComponent) and \(nvramURL.lastPathComponent)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func validateCachedImage(userFriendlyName: String) throws {
|
||||
if !FileManager.default.fileExists(atPath: baseURL.path) {
|
||||
throw RuntimeError.VMDoesNotExist(name: userFriendlyName)
|
||||
}
|
||||
|
||||
if !isCachedImage {
|
||||
throw RuntimeError.VMMissingFiles(
|
||||
"cached image is missing files for a supported layout: "
|
||||
+ "standalone requires \(configURL.lastPathComponent), \(diskURL.lastPathComponent) and \(nvramURL.lastPathComponent); "
|
||||
+ "stacked requires \(configURL.lastPathComponent), \(manifestURL.lastPathComponent) and \(nvramURL.lastPathComponent)"
|
||||
)
|
||||
throw RuntimeError.VMMissingFiles("VM is missing some of its files (\(configURL.lastPathComponent),"
|
||||
+ " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,38 +142,7 @@ struct VMDirectory: Prunable {
|
||||
try vmConfig.save(toURL: configURL)
|
||||
}
|
||||
|
||||
func resizeDisk(
|
||||
_ sizeGB: UInt16,
|
||||
format: DiskImageFormat = .raw,
|
||||
contentStore: ContentStore? = nil
|
||||
) throws {
|
||||
if isStackedVM {
|
||||
// Resolve the stack before taking the config.json PID lock. Reading
|
||||
// config.json after acquiring an fcntl lock would release that lock
|
||||
// when the read file descriptor is closed.
|
||||
let stack = try diskImageStack(contentStore: contentStore)
|
||||
let lock = try lock()
|
||||
guard try lock.trylock() else {
|
||||
throw RuntimeError.VMConfigurationError("VM \"\(name)\" must be stopped before resizing its disk")
|
||||
}
|
||||
defer { try? lock.unlock() }
|
||||
|
||||
// Holding the PID lock proves that the VM is not running. A saved state
|
||||
// file is the remaining suspended state that must also reject resize.
|
||||
guard !FileManager.default.fileExists(atPath: stateURL.path) else {
|
||||
throw RuntimeError.VMConfigurationError("VM \"\(name)\" must be stopped before resizing its disk")
|
||||
}
|
||||
|
||||
let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||
guard desiredSizeBytes.isMultiple(of: stack.blockSize) else {
|
||||
throw RuntimeError.InvalidDiskSize("new disk size must align to the stacked disk block size")
|
||||
}
|
||||
|
||||
let desiredBlockCount = desiredSizeBytes / stack.blockSize
|
||||
try stack.growWritableOverlay(toBlockCount: desiredBlockCount)
|
||||
return
|
||||
}
|
||||
|
||||
func resizeDisk(_ sizeGB: UInt16, format: DiskImageFormat = .raw) throws {
|
||||
let diskExists = FileManager.default.fileExists(atPath: diskURL.path)
|
||||
|
||||
if diskExists {
|
||||
@@ -385,33 +266,17 @@ struct VMDirectory: Prunable {
|
||||
throw RuntimeError.VMIsRunning(name)
|
||||
}
|
||||
|
||||
// Standalone local VMs do not reference the shared content store. Delete
|
||||
// them directly so a full disk can still be recovered before the content
|
||||
// store has ever been initialized.
|
||||
if isStandalone {
|
||||
try FileManager.default.removeItem(at: baseURL)
|
||||
} else {
|
||||
try removeFromDisk()
|
||||
}
|
||||
try FileManager.default.removeItem(at: baseURL)
|
||||
|
||||
try lock.unlock()
|
||||
}
|
||||
|
||||
/// Removes a VM directory while preserving the content-store reference
|
||||
/// protocol for any complete or partially published manifest it contains.
|
||||
func removeFromDisk() throws {
|
||||
let contentStore = try ContentStore()
|
||||
try contentStore.withPruneLock {
|
||||
try FileManager.default.removeItem(at: baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func accessDate() throws -> Date {
|
||||
try baseURL.accessDate()
|
||||
}
|
||||
|
||||
func allocatedSizeBytes() throws -> Int {
|
||||
try configURL.allocatedSizeBytes() + localDiskStorageAllocatedSizeBytes() + nvramURL.allocatedSizeBytes()
|
||||
try configURL.allocatedSizeBytes() + diskURL.allocatedSizeBytes() + nvramURL.allocatedSizeBytes()
|
||||
}
|
||||
|
||||
func allocatedSizeGB() throws -> Int {
|
||||
@@ -419,7 +284,7 @@ struct VMDirectory: Prunable {
|
||||
}
|
||||
|
||||
func deduplicatedSizeBytes() throws -> Int {
|
||||
try configURL.deduplicatedSizeBytes() + localDiskStorageDeduplicatedSizeBytes() + nvramURL.deduplicatedSizeBytes()
|
||||
try configURL.deduplicatedSizeBytes() + diskURL.deduplicatedSizeBytes() + nvramURL.deduplicatedSizeBytes()
|
||||
}
|
||||
|
||||
func deduplicatedSizeGB() throws -> Int {
|
||||
@@ -427,7 +292,7 @@ struct VMDirectory: Prunable {
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try configURL.sizeBytes() + localDiskStorageSizeBytes() + nvramURL.sizeBytes()
|
||||
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||
}
|
||||
|
||||
func sizeGB() throws -> Int {
|
||||
@@ -435,30 +300,6 @@ struct VMDirectory: Prunable {
|
||||
}
|
||||
|
||||
func diskSizeBytes() throws -> Int {
|
||||
if isStackedVM {
|
||||
let blockLayout = try DiskImageStack.diskImageBlockLayout(at: overlayURL)
|
||||
let product = blockLayout.blockSize.multipliedReportingOverflow(by: blockLayout.blockCount)
|
||||
guard !product.overflow, let diskSizeBytes = Int(exactly: product.partialValue) else {
|
||||
throw RuntimeError.VMConfigurationError("VM has invalid stacked disk block layout")
|
||||
}
|
||||
|
||||
return diskSizeBytes
|
||||
}
|
||||
|
||||
if isStackedCachedImage {
|
||||
let manifest = try OCIManifest(fromJSON: Data(contentsOf: manifestURL))
|
||||
guard let blockSize = manifest.diskBlockSize(),
|
||||
let blockCount = manifest.diskBlockCount() else {
|
||||
throw RuntimeError.VMConfigurationError("VM has invalid stacked disk block layout")
|
||||
}
|
||||
let product = blockSize.multipliedReportingOverflow(by: blockCount)
|
||||
guard !product.overflow, let diskSizeBytes = Int(exactly: product.partialValue) else {
|
||||
throw RuntimeError.VMConfigurationError("VM has invalid stacked disk block layout")
|
||||
}
|
||||
|
||||
return diskSizeBytes
|
||||
}
|
||||
|
||||
let vmConfig = try VMConfig(fromURL: configURL)
|
||||
|
||||
return switch vmConfig.diskFormat {
|
||||
@@ -469,6 +310,10 @@ struct VMDirectory: Prunable {
|
||||
}
|
||||
}
|
||||
|
||||
func diskSizeGB() throws -> Int {
|
||||
try diskSizeBytes() / 1000 / 1000 / 1000
|
||||
}
|
||||
|
||||
func markExplicitlyPulled() {
|
||||
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
||||
}
|
||||
@@ -476,23 +321,4 @@ struct VMDirectory: Prunable {
|
||||
func isExplicitlyPulled() -> Bool {
|
||||
FileManager.default.fileExists(atPath: explicitlyPulledMark.path)
|
||||
}
|
||||
|
||||
private var localDiskStorageURL: URL {
|
||||
isStackedVM ? overlayURL : diskURL
|
||||
}
|
||||
|
||||
// Cached stacked images own no disk file in their VM directory. Their
|
||||
// immutable disk content lives in the shared content store and must not be
|
||||
// charged to every cached image that references it.
|
||||
private func localDiskStorageAllocatedSizeBytes() throws -> Int {
|
||||
isStackedCachedImage ? 0 : try localDiskStorageURL.allocatedSizeBytes()
|
||||
}
|
||||
|
||||
private func localDiskStorageDeduplicatedSizeBytes() throws -> Int {
|
||||
isStackedCachedImage ? 0 : try localDiskStorageURL.deduplicatedSizeBytes()
|
||||
}
|
||||
|
||||
private func localDiskStorageSizeBytes() throws -> Int {
|
||||
isStackedCachedImage ? 0 : try localDiskStorageURL.sizeBytes()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,36 +35,11 @@ class VMStorageLocal: PrunableStorage {
|
||||
|
||||
func move(_ name: String, from: VMDirectory) throws {
|
||||
_ = try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
||||
try replace(VMDirectory(baseURL: vmURL(name)), with: from)
|
||||
_ = try FileManager.default.replaceItemAt(vmURL(name), withItemAt: from.baseURL)
|
||||
}
|
||||
|
||||
func rename(_ name: String, _ newName: String) throws {
|
||||
let source = VMDirectory(baseURL: vmURL(name))
|
||||
let destination = VMDirectory(baseURL: vmURL(newName))
|
||||
try replace(destination, with: source)
|
||||
}
|
||||
|
||||
/// References in a manifest must not disappear while content GC is deciding
|
||||
/// whether their immutable disk files are still in use.
|
||||
private func replace(_ destination: VMDirectory, with source: VMDirectory) throws {
|
||||
// Replacing a running VM's directory unlinks its locked config and disks,
|
||||
// leaving a live VM that list and stop can no longer find by name.
|
||||
let destinationLock = FileManager.default.fileExists(atPath: destination.configURL.path)
|
||||
? try destination.lock() : nil
|
||||
if let destinationLock, try !destinationLock.trylock() {
|
||||
throw RuntimeError.VMIsRunning(destination.name)
|
||||
}
|
||||
defer { withExtendedLifetime(destinationLock) {} }
|
||||
|
||||
if FileManager.default.fileExists(atPath: source.manifestURL.path) ||
|
||||
FileManager.default.fileExists(atPath: destination.manifestURL.path) {
|
||||
let contentStore = try ContentStore()
|
||||
try contentStore.withPruneLock {
|
||||
_ = try FileManager.default.replaceItemAt(destination.baseURL, withItemAt: source.baseURL)
|
||||
}
|
||||
} else {
|
||||
_ = try FileManager.default.replaceItemAt(destination.baseURL, withItemAt: source.baseURL)
|
||||
}
|
||||
_ = try FileManager.default.replaceItemAt(vmURL(newName), withItemAt: vmURL(name))
|
||||
}
|
||||
|
||||
func delete(_ name: String) throws {
|
||||
|
||||
@@ -18,104 +18,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
func exists(_ name: RemoteName) -> Bool {
|
||||
VMDirectory(baseURL: vmURL(name)).isCachedImage
|
||||
}
|
||||
|
||||
/// Whether clone can use a cached image without pulling. Standalone images keep
|
||||
/// Tart's existing structural check. Stacked cached images require every
|
||||
/// immutable file with its expected length.
|
||||
func hasUsableCachedImageForClone(_ name: RemoteName, requireManifest: Bool = false) throws -> Bool {
|
||||
guard exists(name) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
if requireManifest && !FileManager.default.fileExists(atPath: vmDir.manifestURL.path) {
|
||||
return false
|
||||
}
|
||||
guard vmDir.isStackedCachedImage else {
|
||||
return true
|
||||
}
|
||||
|
||||
let manifest = try OCIManifest(fromJSON: Data(contentsOf: vmDir.manifestURL))
|
||||
guard case .stacked(let base, let overlays) = try manifest.tartDiskRepresentation() else {
|
||||
return true
|
||||
}
|
||||
|
||||
let contentStore = try ContentStore()
|
||||
for group in [base] + overlays {
|
||||
guard try hasUsableCachedDiskFile(group, contentStore: contentStore) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/// Whether a cached image is complete enough for `pull` to return without
|
||||
/// repairing it. Standalone images keep Tart's existing structural cache-hit
|
||||
/// behavior; stacked cached images additionally need every immutable disk file in
|
||||
/// the shared content store.
|
||||
func hasCompleteCachedImage(
|
||||
_ name: RemoteName,
|
||||
manifest: OCIManifest,
|
||||
requireManifest: Bool = false
|
||||
) throws -> Bool {
|
||||
guard exists(name) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
if requireManifest && !FileManager.default.fileExists(atPath: vmDir.manifestURL.path) {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let missingGroups = try missingStackedDiskFileGroups(for: manifest) else {
|
||||
return true
|
||||
}
|
||||
|
||||
return missingGroups.isEmpty
|
||||
}
|
||||
|
||||
/// The lock-free pull fast path is only useful for a tag that already
|
||||
/// points at this digest. New or retargeted tags validate once after taking
|
||||
/// the host lock instead of hashing a large stack twice.
|
||||
func hasCompleteLinkedImage(
|
||||
_ name: RemoteName,
|
||||
digestName: RemoteName,
|
||||
manifest: OCIManifest,
|
||||
requireManifest: Bool = false
|
||||
) throws -> Bool {
|
||||
guard exists(name), linked(from: name, to: digestName) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return try hasCompleteCachedImage(digestName, manifest: manifest, requireManifest: requireManifest)
|
||||
}
|
||||
|
||||
/// Bytes that this pull may need to materialize locally. For stacked images
|
||||
/// this is the sum of only the missing complete disk files, not the final
|
||||
/// guest-visible disk block layout.
|
||||
func requiredDiskStorageBytes(for manifest: OCIManifest) throws -> UInt64? {
|
||||
guard let missingGroups = try missingStackedDiskFileGroups(for: manifest) else {
|
||||
return manifest.uncompressedDiskSize()
|
||||
}
|
||||
|
||||
var total: UInt64 = 0
|
||||
for group in missingGroups {
|
||||
for chunk in group.chunks {
|
||||
guard let uncompressedSize = chunk.uncompressedSize() else {
|
||||
throw OCIManifestValidationError.invalidDiskMetadata("disk chunks need uncompressed size and content digest")
|
||||
}
|
||||
let addition = total.addingReportingOverflow(uncompressedSize)
|
||||
guard !addition.overflow else {
|
||||
throw RuntimeError.PullFailed("stacked disk storage size overflows UInt64")
|
||||
}
|
||||
total = addition.partialValue
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
VMDirectory(baseURL: vmURL(name)).initialized
|
||||
}
|
||||
|
||||
func digest(_ name: RemoteName) throws -> String {
|
||||
@@ -131,7 +34,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
func open(_ name: RemoteName, _ accessDate: Date = Date()) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
|
||||
try vmDir.validateCachedImage(userFriendlyName: name.description)
|
||||
try vmDir.validate(userFriendlyName: name.description)
|
||||
|
||||
try vmDir.baseURL.updateAccessDate(accessDate)
|
||||
|
||||
@@ -141,64 +44,11 @@ class VMStorageOCI: PrunableStorage {
|
||||
func create(_ name: RemoteName, overwrite: Bool = false) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
|
||||
if !overwrite && vmDir.isCachedImage {
|
||||
throw RuntimeError.VMDirectoryAlreadyInitialized("VM directory is already initialized, preventing overwrite")
|
||||
}
|
||||
|
||||
try vmDir.initialize(overwrite: overwrite)
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
/// Materialize the digest-addressed cached image for an image Tart just
|
||||
/// pushed, without routing its own local data back through the registry.
|
||||
func populate(_ name: RemoteName, from source: VMDirectory, manifest: OCIManifest) throws {
|
||||
if try hasCompleteCachedImage(name, manifest: manifest) {
|
||||
return
|
||||
}
|
||||
|
||||
let vmDir = try create(name, overwrite: exists(name))
|
||||
|
||||
do {
|
||||
if source.isStackedVM {
|
||||
guard case .stacked(_, let overlays) = try manifest.tartDiskRepresentation(),
|
||||
let contentDigest = overlays.last?.contentDigest else {
|
||||
throw RuntimeError.VMConfigurationError("pushed image is missing its writable ASIF overlay")
|
||||
}
|
||||
|
||||
// The pushed top overlay becomes immutable in the cached image. Keep a
|
||||
// semantic copy so later clones do not need to fetch it back.
|
||||
let contentStore = try ContentStore()
|
||||
try contentStore.withPruneLock {
|
||||
try FileManager.default.copyItem(at: source.configURL, to: vmDir.configURL)
|
||||
try FileManager.default.copyItem(at: source.nvramURL, to: vmDir.nvramURL)
|
||||
// Publish the reference before installing the immutable top overlay,
|
||||
// so reference-aware pruning cannot collect it in between.
|
||||
try manifest.toJSON().write(to: vmDir.manifestURL)
|
||||
}
|
||||
|
||||
if try contentStore.contentURLIfPresent(for: contentDigest) == nil {
|
||||
let temporaryURL = try contentStore.temporaryContentURL(for: contentDigest)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: source.overlayURL, to: temporaryURL)
|
||||
_ = try contentStore.install(temporaryURL, contentDigest: contentDigest)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: temporaryURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try source.clone(to: vmDir, generateMAC: false)
|
||||
// Keep the exact manifest Tart submitted so tag links and later pushes
|
||||
// refer to the same digest-addressed cached image.
|
||||
try manifest.toJSON().write(to: vmDir.manifestURL)
|
||||
}
|
||||
} catch {
|
||||
try? vmDir.removeFromDisk()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func move(_ name: RemoteName, from: VMDirectory) throws{
|
||||
let targetURL = vmURL(name)
|
||||
|
||||
@@ -207,20 +57,11 @@ class VMStorageOCI: PrunableStorage {
|
||||
try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
|
||||
let target = VMDirectory(baseURL: targetURL)
|
||||
if FileManager.default.fileExists(atPath: from.manifestURL.path) ||
|
||||
FileManager.default.fileExists(atPath: target.manifestURL.path) {
|
||||
let contentStore = try ContentStore()
|
||||
try contentStore.withPruneLock {
|
||||
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
|
||||
}
|
||||
} else {
|
||||
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
|
||||
}
|
||||
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
|
||||
}
|
||||
|
||||
func delete(_ name: RemoteName) throws {
|
||||
try removeRecord(at: vmURL(name))
|
||||
try FileManager.default.removeItem(at: vmURL(name))
|
||||
try gc()
|
||||
}
|
||||
|
||||
@@ -229,7 +70,6 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
|
||||
try gcContent()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -244,7 +84,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
let vmDir = VMDirectory(baseURL: foundURL.resolvingSymlinksInPath())
|
||||
if !vmDir.isCachedImage {
|
||||
if !vmDir.initialized {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -257,28 +97,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
let vmDir = VMDirectory(baseURL: baseURL)
|
||||
|
||||
if !vmDir.isExplicitlyPulled() && incRefCount == 0 {
|
||||
try removeRecord(at: baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
try gcContent()
|
||||
}
|
||||
|
||||
/// Cached images with a manifest publish references into the shared content
|
||||
/// store. Remove them through VMDirectory so reference removal is serialized
|
||||
/// with clone, export, pull, and content GC, even if a record is incomplete.
|
||||
private func removeRecord(at url: URL) throws {
|
||||
try VMDirectory(baseURL: url).removeFromDisk()
|
||||
}
|
||||
|
||||
/// Remove immutable files whose final published or in-progress reference
|
||||
/// has disappeared, without collecting unrelated cached images.
|
||||
fileprivate func gcContent() throws {
|
||||
let contentStore = try ContentStore()
|
||||
try contentStore.withPruneLock {
|
||||
let referencedContentDigests = try referencedContentDigests(includeCachedImages: true)
|
||||
for contentURL in try contentStore.prunables(excluding: referencedContentDigests) {
|
||||
try FileManager.default.removeItem(at: contentURL)
|
||||
try FileManager.default.removeItem(at: baseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,7 +113,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
for case let foundURL as URL in enumerator {
|
||||
let vmDir = VMDirectory(baseURL: foundURL)
|
||||
|
||||
if !vmDir.isCachedImage {
|
||||
if !vmDir.initialized {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -322,67 +141,10 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
func prunables() throws -> [Prunable] {
|
||||
let records = try list().filter { (_, _, isSymlink) in
|
||||
!isSymlink
|
||||
}.map { (_, vmDir, _) in vmDir }
|
||||
|
||||
// Attribute shared content to the newest cached image that references it.
|
||||
// This counts each file once while charging it to the last record that
|
||||
// normally needs to be removed before the file becomes reclaimable.
|
||||
let nonCacheContentDigests = try referencedContentDigests(includeCachedImages: false)
|
||||
var contentOwners = [String: VMDirectory]()
|
||||
for record in records where record.isStackedCachedImage {
|
||||
// Interrupted cache population can leave a truncated manifest in an
|
||||
// otherwise recognizable cached record. It has no reliable content
|
||||
// references, but it must not prevent pruning other cache entries.
|
||||
for contentDigest in (try? record.diskContentDigests()) ?? []
|
||||
where !nonCacheContentDigests.contains(contentDigest) {
|
||||
guard let currentOwner = contentOwners[contentDigest] else {
|
||||
contentOwners[contentDigest] = record
|
||||
continue
|
||||
}
|
||||
|
||||
let recordAccessDate = try record.accessDate()
|
||||
let currentAccessDate = try currentOwner.accessDate()
|
||||
if recordAccessDate > currentAccessDate ||
|
||||
(recordAccessDate == currentAccessDate && record.url.path > currentOwner.url.path) {
|
||||
contentOwners[contentDigest] = record
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let contentStore = try ContentStore()
|
||||
var ownedContentURLs = [URL: [URL]]()
|
||||
for (contentDigest, owner) in contentOwners {
|
||||
let contentURL = try contentStore.contentURL(for: contentDigest)
|
||||
guard FileManager.default.fileExists(atPath: contentURL.path) else {
|
||||
continue
|
||||
}
|
||||
|
||||
ownedContentURLs[owner.url, default: []].append(contentURL)
|
||||
}
|
||||
|
||||
var result: [Prunable] = records.map { record in
|
||||
CachedImagePrunable(
|
||||
vmDir: record,
|
||||
ownedContentURLs: ownedContentURLs[record.url] ?? []
|
||||
)
|
||||
}
|
||||
|
||||
result += try contentStore.prunables(excluding: referencedContentDigests(includeCachedImages: true))
|
||||
.map(ContentPrunable.init)
|
||||
|
||||
return result
|
||||
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
||||
}
|
||||
|
||||
func pull(
|
||||
_ name: RemoteName,
|
||||
registry: Registry,
|
||||
concurrency: UInt,
|
||||
deduplicate: Bool,
|
||||
requireManifest: Bool = false,
|
||||
resolvedManifest: (manifest: OCIManifest, data: Data)? = nil
|
||||
) async throws {
|
||||
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
|
||||
key: "oci.image-name",
|
||||
value: .string(name.description)
|
||||
@@ -390,23 +152,12 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
let (manifest, manifestData): (OCIManifest, Data)
|
||||
if let resolvedManifest {
|
||||
manifest = resolvedManifest.manifest
|
||||
manifestData = resolvedManifest.data
|
||||
} else {
|
||||
(manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
||||
}
|
||||
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
||||
|
||||
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||
reference: Reference(digest: Digest.hash(manifestData)))
|
||||
|
||||
if try hasCompleteLinkedImage(
|
||||
name,
|
||||
digestName: digestName,
|
||||
manifest: manifest,
|
||||
requireManifest: requireManifest
|
||||
) {
|
||||
if exists(name) && exists(digestName) && linked(from: name, to: digestName) {
|
||||
// optimistically check if we need to do anything at all before locking
|
||||
defaultLogger.appendNewLine("\(digestName) image is already cached and linked!")
|
||||
return
|
||||
@@ -430,22 +181,11 @@ class VMStorageOCI: PrunableStorage {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
let digestVMDir = VMDirectory(baseURL: vmURL(digestName))
|
||||
if requireManifest,
|
||||
!FileManager.default.fileExists(atPath: digestVMDir.manifestURL.path),
|
||||
try hasCompleteCachedImage(digestName, manifest: manifest) {
|
||||
// Old Tart versions cached standalone OCI images without manifest.json.
|
||||
// A stacked clone needs the manifest to describe its immutable base, but
|
||||
// the existing disk remains usable and must not be downloaded again.
|
||||
try manifestData.write(to: digestVMDir.manifestURL, options: .atomic)
|
||||
}
|
||||
|
||||
if try !hasCompleteCachedImage(digestName, manifest: manifest, requireManifest: requireManifest) {
|
||||
if !exists(digestName) {
|
||||
let span = OTel.shared.tracer.spanBuilder(spanName: "pull").setActive(true).startSpan()
|
||||
defer { span.end() }
|
||||
|
||||
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
|
||||
let preserveExplicitlyPulledMark = digestVMDir.isExplicitlyPulled()
|
||||
|
||||
// Open an existing VM directory corresponding to this name, if any,
|
||||
// marking it as outdated to speed up the garbage collection process
|
||||
@@ -455,47 +195,22 @@ class VMStorageOCI: PrunableStorage {
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
// Make in-progress stacked content references visible before reclaiming
|
||||
// space or reconstructing immutable files.
|
||||
try ContentStore().withPruneLock {
|
||||
try manifestData.write(to: tmpVMDir.manifestURL)
|
||||
}
|
||||
|
||||
// A previously pulled standalone image already has the complete base
|
||||
// disk locally as disk.img. Promote that file into the content store
|
||||
// before sizing or pulling so a stacked child only fetches overlays.
|
||||
try reuseStandaloneDiskForStackedBaseIfPossible(manifest)
|
||||
|
||||
// Try to reclaim some cache space if we know the VM size in advance
|
||||
if let requiredDiskStorageBytes = try requiredDiskStorageBytes(for: manifest) {
|
||||
if let telemetryValue = Int(exactly: requiredDiskStorageBytes) {
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
|
||||
key: "oci.image-required-disk-storage-bytes",
|
||||
value: .int(telemetryValue)
|
||||
)
|
||||
}
|
||||
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
||||
OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute(
|
||||
key: "oci.image-uncompressed-disk-size-bytes",
|
||||
value: .int(Int(uncompressedDiskSize))
|
||||
)
|
||||
|
||||
let otherVMFilesSize: UInt64 = 128 * 1024 * 1024
|
||||
let requiredStorage = requiredDiskStorageBytes.addingReportingOverflow(otherVMFilesSize)
|
||||
guard !requiredStorage.overflow else {
|
||||
throw RuntimeError.PullFailed("required pull storage size overflows UInt64")
|
||||
}
|
||||
|
||||
try Prune.reclaimIfNeeded(requiredStorage.partialValue)
|
||||
try Prune.reclaimIfNeeded(uncompressedDiskSize + otherVMFilesSize)
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await retry(maxAttempts: 5) {
|
||||
// Existing standalone images can still reuse another complete local disk.
|
||||
// Stacked images reconstruct their immutable files through the
|
||||
// shared content store instead of materializing disk.img.
|
||||
let localLayerCache: LocalLayerCache?
|
||||
switch try manifest.tartDiskRepresentation() {
|
||||
case .flat:
|
||||
localLayerCache = try await chooseLocalLayerCache(name, manifest, registry)
|
||||
case .stacked:
|
||||
localLayerCache = nil
|
||||
}
|
||||
// Choose the best base image which has the most deduplication ratio
|
||||
let localLayerCache = try await chooseLocalLayerCache(name, manifest, registry)
|
||||
|
||||
if let llc = localLayerCache {
|
||||
let deduplicatedHuman = ByteCountFormatter.string(fromByteCount: Int64(llc.deduplicatedBytes), countStyle: .file)
|
||||
@@ -517,14 +232,9 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
return .throw
|
||||
}
|
||||
|
||||
if preserveExplicitlyPulledMark {
|
||||
tmpVMDir.markExplicitlyPulled()
|
||||
}
|
||||
|
||||
try move(digestName, from: tmpVMDir)
|
||||
}, onCancel: {
|
||||
try? tmpVMDir.removeFromDisk()
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
} else {
|
||||
defaultLogger.appendNewLine("\(digestName) image is already cached! creating a symlink...")
|
||||
@@ -543,115 +253,6 @@ class VMStorageOCI: PrunableStorage {
|
||||
_ = try VMStorageOCI().open(name)
|
||||
}
|
||||
|
||||
/// Returns nil for standalone images and the missing immutable disk-file
|
||||
/// groups for stacked images. Like existing standalone cached images, cache hits trust
|
||||
/// already-installed files; checking size still repairs truncated entries
|
||||
/// without hashing a large prewarmed base on every pull.
|
||||
private func missingStackedDiskFileGroups(for manifest: OCIManifest) throws -> [TartDiskFileGroup]? {
|
||||
guard case .stacked(let base, let overlays) = try manifest.tartDiskRepresentation() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let contentStore = try ContentStore()
|
||||
var missingGroups: [TartDiskFileGroup] = []
|
||||
for group in [base] + overlays {
|
||||
if try !hasUsableCachedDiskFile(group, contentStore: contentStore) {
|
||||
missingGroups.append(group)
|
||||
}
|
||||
}
|
||||
|
||||
return missingGroups
|
||||
}
|
||||
|
||||
/// Seed a stacked image's immutable base from an already pulled standalone
|
||||
/// OCI record when both manifests describe the same transport chunks. The
|
||||
/// content store still verifies the whole-file digest before publishing it.
|
||||
func reuseStandaloneDiskForStackedBaseIfPossible(_ manifest: OCIManifest) throws {
|
||||
guard case .stacked(let base, _) = try manifest.tartDiskRepresentation(),
|
||||
let contentDigest = base.contentDigest else {
|
||||
return
|
||||
}
|
||||
|
||||
let contentStore = try ContentStore()
|
||||
var attemptedCandidates = Swift.Set<String>()
|
||||
while true {
|
||||
// Keep the source record alive only while cloning its disk. The pull's
|
||||
// in-progress manifest already protects the destination content digest,
|
||||
// so hashing and installing the staged clone need not hold the global
|
||||
// prune lock.
|
||||
let temporaryURL = try contentStore.withPruneLock { () -> URL? in
|
||||
// Content-store entries are verified when installed. Avoid hashing a
|
||||
// potentially large prewarmed base again on every stacked pull.
|
||||
guard try contentStore.contentURLIfPresent(for: contentDigest) == nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for (_, vmDir, isSymlink) in try list() where !isSymlink && vmDir.isStandalone {
|
||||
guard !attemptedCandidates.contains(vmDir.baseURL.path),
|
||||
let manifestData = try? Data(contentsOf: vmDir.manifestURL),
|
||||
let candidateManifest = try? OCIManifest(fromJSON: manifestData),
|
||||
case .flat(let candidateBase) = try? candidateManifest.tartDiskRepresentation(),
|
||||
diskChunksMatch(candidateBase.chunks, base.chunks) else {
|
||||
continue
|
||||
}
|
||||
|
||||
attemptedCandidates.insert(vmDir.baseURL.path)
|
||||
let temporaryURL = try contentStore.temporaryContentURL(for: contentDigest)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: vmDir.diskURL, to: temporaryURL)
|
||||
return temporaryURL
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: temporaryURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let temporaryURL else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try contentStore.install(temporaryURL, contentDigest: contentDigest)
|
||||
return
|
||||
} catch ContentStoreError.contentDigestMismatch {
|
||||
try? FileManager.default.removeItem(at: temporaryURL)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: temporaryURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare the OCI transport identity while ignoring stacked-only
|
||||
/// whole-file annotations added to the first base chunk.
|
||||
private func diskChunksMatch(_ left: [OCIManifestLayer], _ right: [OCIManifestLayer]) -> Bool {
|
||||
guard left.count == right.count else {
|
||||
return false
|
||||
}
|
||||
|
||||
return zip(left, right).allSatisfy { left, right in
|
||||
left.mediaType == right.mediaType &&
|
||||
left.size == right.size &&
|
||||
left.digest == right.digest &&
|
||||
left.uncompressedSize() == right.uncompressedSize() &&
|
||||
left.uncompressedContentDigest() == right.uncompressedContentDigest()
|
||||
}
|
||||
}
|
||||
|
||||
private func hasUsableCachedDiskFile(_ group: TartDiskFileGroup, contentStore: ContentStore) throws -> Bool {
|
||||
guard let contentDigest = group.contentDigest,
|
||||
let contentURL = try contentStore.contentURLIfPresent(for: contentDigest),
|
||||
let actualSize = UInt64(exactly: try contentURL.sizeBytes()),
|
||||
let expectedSize = group.uncompressedSize() else {
|
||||
return false
|
||||
}
|
||||
|
||||
return actualSize == expectedSize
|
||||
}
|
||||
|
||||
func linked(from: RemoteName, to: RemoteName) -> Bool {
|
||||
do {
|
||||
let resolvedFrom = try FileManager.default.destinationOfSymbolicLink(atPath: vmURL(from).path)
|
||||
@@ -662,13 +263,9 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
func link(from: RemoteName, to: RemoteName) throws {
|
||||
// Export resolves mutable tags while holding this same lock, so replace
|
||||
// the symlink atomically with respect to stacked archive staging.
|
||||
let contentStore = try ContentStore()
|
||||
try contentStore.withPruneLock {
|
||||
try? FileManager.default.removeItem(at: vmURL(from))
|
||||
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
||||
}
|
||||
try? FileManager.default.removeItem(at: vmURL(from))
|
||||
|
||||
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
||||
|
||||
try gc()
|
||||
}
|
||||
@@ -683,16 +280,10 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
// Load OCI VM images and their manifests (if present)
|
||||
var candidates: [(
|
||||
name: String,
|
||||
vmDir: VMDirectory,
|
||||
manifest: OCIManifest,
|
||||
manifestDigest: String,
|
||||
deduplicatedBytes: UInt64
|
||||
)] = []
|
||||
var candidates: [(name: String, vmDir: VMDirectory, manifest: OCIManifest, deduplicatedBytes: UInt64)] = []
|
||||
|
||||
for (name, vmDir, isSymlink) in try list() {
|
||||
if isSymlink || !vmDir.isStandalone {
|
||||
if isSymlink {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -704,13 +295,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
continue
|
||||
}
|
||||
|
||||
candidates.append((
|
||||
name,
|
||||
vmDir,
|
||||
manifest,
|
||||
Digest.hash(manifestJSON),
|
||||
calculateDeduplicatedBytes(manifest)
|
||||
))
|
||||
candidates.append((name, vmDir, manifest, calculateDeduplicatedBytes(manifest)))
|
||||
}
|
||||
|
||||
// Previously we haven't stored the OCI VM image manifests, but still fetched the VM image manifest if
|
||||
@@ -720,17 +305,10 @@ class VMStorageOCI: PrunableStorage {
|
||||
// with the registry if we haven't already retrieved the manifest for that OCI VM image.
|
||||
if name.reference.type == .Tag,
|
||||
let vmDir = try? open(name),
|
||||
vmDir.isStandalone,
|
||||
let digest = try? digest(name),
|
||||
!candidates.contains(where: { $0.manifestDigest == digest }),
|
||||
let (manifest, manifestData) = try? await registry.pullManifest(reference: digest) {
|
||||
candidates.append((
|
||||
name.description,
|
||||
vmDir,
|
||||
manifest,
|
||||
Digest.hash(manifestData),
|
||||
calculateDeduplicatedBytes(manifest)
|
||||
))
|
||||
try !candidates.contains(where: {try $0.manifest.digest() == digest}),
|
||||
let (manifest, _) = try? await registry.pullManifest(reference: digest) {
|
||||
candidates.append((name.description, vmDir, manifest, calculateDeduplicatedBytes(manifest)))
|
||||
}
|
||||
|
||||
// Now, find the best match based on how many bytes we'll deduplicate
|
||||
@@ -744,108 +322,6 @@ class VMStorageOCI: PrunableStorage {
|
||||
try LocalLayerCache(choosen.name, choosen.deduplicatedBytes, choosen.vmDir.diskURL, choosen.manifest)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns content referenced outside the OCI cache, optionally including
|
||||
/// references published by retained cached images.
|
||||
private func referencedContentDigests(includeCachedImages: Bool) throws -> Swift.Set<String> {
|
||||
var result = Swift.Set<String>()
|
||||
|
||||
for (_, vmDir) in try VMStorageLocal().list() where vmDir.isStackedVM {
|
||||
result.formUnion(try vmDir.diskContentDigests())
|
||||
}
|
||||
|
||||
// Clone, pull, and import publish their manifest before installing
|
||||
// immutable content. Include partially populated temporary directories so
|
||||
// pruning cannot race those operations.
|
||||
for url in try FileManager.default.contentsOfDirectory(
|
||||
at: Config().tartTmpDir,
|
||||
includingPropertiesForKeys: [],
|
||||
options: .skipsHiddenFiles
|
||||
) {
|
||||
let vmDir = VMDirectory(baseURL: url)
|
||||
guard FileManager.default.fileExists(atPath: vmDir.manifestURL.path),
|
||||
let contentDigests = try? vmDir.diskContentDigests() else {
|
||||
continue
|
||||
}
|
||||
|
||||
result.formUnion(contentDigests)
|
||||
}
|
||||
|
||||
if includeCachedImages {
|
||||
for (_, vmDir, isSymlink) in try list() where !isSymlink && vmDir.isStackedCachedImage {
|
||||
// Malformed cached records are invalid references. Keep scanning so
|
||||
// one interrupted population does not disable content GC globally.
|
||||
if let contentDigests = try? vmDir.diskContentDigests() {
|
||||
result.formUnion(contentDigests)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fileprivate func deleteContentIfUnused(_ url: URL) throws {
|
||||
let contentStore = try ContentStore()
|
||||
try contentStore.withPruneLock {
|
||||
let referencedContentDigests = try referencedContentDigests(includeCachedImages: true)
|
||||
let stillPrunable = try contentStore.prunables(excluding: referencedContentDigests).contains {
|
||||
$0.resolvingSymlinksInPath() == url.resolvingSymlinksInPath()
|
||||
}
|
||||
if stillPrunable {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContentPrunable: Prunable {
|
||||
let url: URL
|
||||
|
||||
func delete() throws {
|
||||
try VMStorageOCI().deleteContentIfUnused(url)
|
||||
}
|
||||
|
||||
func accessDate() throws -> Date {
|
||||
try url.accessDate()
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try url.sizeBytes()
|
||||
}
|
||||
|
||||
func allocatedSizeBytes() throws -> Int {
|
||||
try url.allocatedSizeBytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// A digest-addressed cached image plus immutable content attributed to the
|
||||
/// final remote reference that can release it.
|
||||
private struct CachedImagePrunable: Prunable {
|
||||
let vmDir: VMDirectory
|
||||
let ownedContentURLs: [URL]
|
||||
|
||||
var url: URL {
|
||||
vmDir.url
|
||||
}
|
||||
|
||||
func delete() throws {
|
||||
try vmDir.delete()
|
||||
// Deleting a record can make attributed content unreferenced. Run GC now
|
||||
// so one prune invocation reclaims those bytes.
|
||||
try VMStorageOCI().gcContent()
|
||||
}
|
||||
|
||||
func accessDate() throws -> Date {
|
||||
try vmDir.accessDate()
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try vmDir.sizeBytes() + ownedContentURLs.map { try $0.sizeBytes() }.reduce(0, +)
|
||||
}
|
||||
|
||||
func allocatedSizeBytes() throws -> Int {
|
||||
try vmDir.allocatedSizeBytes() + ownedContentURLs.map { try $0.allocatedSizeBytes() }.reduce(0, +)
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class CommandBehaviorTests: XCTestCase {
|
||||
func testListSurvivesUnavailableDiskCapacity() async throws {
|
||||
try await withTemporaryTartHome {
|
||||
let previousPath = try installUnavailableDiskutil()
|
||||
defer { restoreEnvironment("PATH", to: previousPath) }
|
||||
|
||||
let local = try VMStorageLocal()
|
||||
let oci = try VMStorageOCI()
|
||||
for (name, diskFormat) in [("unavailable", DiskImageFormat.asif), ("healthy", .raw)] {
|
||||
let remoteName = try RemoteName("example.com/org/\(name):latest")
|
||||
for vmDir in [try local.create(name), try oci.create(remoteName)] {
|
||||
var vmConfig = config()
|
||||
vmConfig.diskFormat = diskFormat
|
||||
try vmConfig.save(toURL: vmDir.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
|
||||
// The diskutil stub simulates a locked ASIF disk without needing a running VM.
|
||||
XCTAssertTrue(FileManager.default.createFile(
|
||||
atPath: vmDir.diskURL.path,
|
||||
contents: Data(repeating: 0, count: 4096)
|
||||
))
|
||||
if diskFormat == .asif {
|
||||
XCTAssertThrowsError(try vmDir.diskSizeBytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for sourceArguments in [[], ["--source", "local"], ["--source", "oci"]] {
|
||||
let json = try await commandOutput(List.self, sourceArguments + ["--format", "json"])
|
||||
let rows = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [[String: Any]])
|
||||
XCTAssertEqual(rows.count, sourceArguments.isEmpty ? 4 : 2)
|
||||
for row in rows {
|
||||
let name = try XCTUnwrap(row["Name"] as? String)
|
||||
if name.contains("unavailable") {
|
||||
XCTAssertTrue(row["Disk"] is NSNull)
|
||||
} else {
|
||||
XCTAssertEqual(row["Disk"] as? Int, 0)
|
||||
}
|
||||
XCTAssertEqual(row["State"] as? String, "stopped")
|
||||
XCTAssertEqual(row["Running"] as? Bool, false)
|
||||
}
|
||||
|
||||
let text = try await commandOutput(List.self, sourceArguments)
|
||||
XCTAssertTrue(text.contains("unavailable"))
|
||||
XCTAssertTrue(text.contains("healthy"))
|
||||
XCTAssertTrue(text.contains("-"))
|
||||
|
||||
let quiet = try await commandOutput(List.self, sourceArguments + ["--quiet"])
|
||||
XCTAssertEqual(quiet.split(separator: "\n").map(String.init), rows.compactMap { $0["Name"] as? String })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testGetSurvivesUnavailableDiskCapacity() async throws {
|
||||
try await withTemporaryTartHome {
|
||||
let previousPath = try installUnavailableDiskutil()
|
||||
defer { restoreEnvironment("PATH", to: previousPath) }
|
||||
|
||||
let vmDir = try VMStorageLocal().create("unavailable")
|
||||
var vmConfig = config()
|
||||
vmConfig.diskFormat = .asif
|
||||
try vmConfig.save(toURL: vmDir.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
|
||||
XCTAssertTrue(FileManager.default.createFile(
|
||||
atPath: vmDir.diskURL.path,
|
||||
contents: Data(repeating: 0, count: 4096)
|
||||
))
|
||||
XCTAssertThrowsError(try vmDir.diskSizeBytes())
|
||||
|
||||
let json = try await commandOutput(Get.self, ["unavailable", "--format", "json"])
|
||||
let info = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any])
|
||||
XCTAssertTrue(info["Disk"] is NSNull)
|
||||
XCTAssertEqual(info["DiskFormat"] as? String, "asif")
|
||||
XCTAssertEqual(info["State"] as? String, "stopped")
|
||||
|
||||
let text = try await commandOutput(Get.self, ["unavailable"])
|
||||
XCTAssertTrue(text.contains("asif"))
|
||||
XCTAssertTrue(text.contains("-"))
|
||||
}
|
||||
}
|
||||
|
||||
func testNoUSBAccessoriesDoesNotEnableSuspendable() throws {
|
||||
try withTemporaryTartHome {
|
||||
let vmDir = try VMStorageLocal().create("no-usb-accessories")
|
||||
try config().save(toURL: vmDir.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.diskURL.path, contents: Data()))
|
||||
|
||||
let command = try Run.parseAsRoot(["no-usb-accessories", "--no-usb-accessories"]) as! Run
|
||||
|
||||
XCTAssertTrue(command.noUSBAccessories)
|
||||
XCTAssertFalse(command.suspendable)
|
||||
XCTAssertFalse(command.noAudio)
|
||||
XCTAssertFalse(command.noGraphics)
|
||||
}
|
||||
}
|
||||
|
||||
func testStandaloneDeleteDoesNotInitializeContentStore() throws {
|
||||
try withTemporaryTartHome {
|
||||
let vmDir = try VMStorageLocal().create("standalone")
|
||||
try config().save(toURL: vmDir.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.diskURL.path, contents: Data()))
|
||||
|
||||
let contentStoreURL = try Config().tartCacheDir.appendingPathComponent("content", isDirectory: true)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: contentStoreURL.path))
|
||||
|
||||
try vmDir.delete()
|
||||
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: vmDir.baseURL.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: contentStoreURL.path))
|
||||
}
|
||||
}
|
||||
|
||||
func testSetDiskRejectsStackedVMBeforeSavingConfig() async throws {
|
||||
try await withTemporaryTartHome {
|
||||
let vmDir = try VMStorageLocal().create("stacked")
|
||||
let originalConfig = config()
|
||||
try originalConfig.save(toURL: vmDir.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.manifestURL.path, contents: Data()))
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.overlayURL.path, contents: Data()))
|
||||
|
||||
let replacementURL = try temporaryDirectory().appendingPathComponent("replacement.img")
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: replacementURL.path, contents: Data("replacement".utf8)))
|
||||
|
||||
let command = try Set.parseAsRoot([
|
||||
"stacked",
|
||||
"--cpu", "4",
|
||||
"--disk", replacementURL.path,
|
||||
]) as! Set
|
||||
|
||||
do {
|
||||
try await command.run()
|
||||
XCTFail("expected stacked disk replacement to be rejected")
|
||||
} catch let error as ValidationError {
|
||||
XCTAssertEqual(error.message, "--disk is not supported for VMs with a stacked disk")
|
||||
}
|
||||
|
||||
XCTAssertEqual(try VMConfig(fromURL: vmDir.configURL).cpuCount, originalConfig.cpuCount)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: vmDir.diskURL.path))
|
||||
}
|
||||
}
|
||||
|
||||
func testRemoteAdditionalDiskRetainsTemporaryBackingFileLock() throws {
|
||||
try withTemporaryTartHome {
|
||||
let storage = try VMStorageOCI()
|
||||
let name = try RemoteName("example.com/org/image:latest")
|
||||
let cachedImage = try storage.create(name)
|
||||
try config().save(toURL: cachedImage.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: cachedImage.nvramURL.path, contents: Data()))
|
||||
XCTAssertTrue(FileManager.default.createFile(
|
||||
atPath: cachedImage.diskURL.path,
|
||||
contents: Data(repeating: 0, count: 4096)
|
||||
))
|
||||
|
||||
do {
|
||||
let additionalDisk = try AdditionalDisk(parseFrom: name.description)
|
||||
let entriesBeforeGC = try temporaryEntries()
|
||||
XCTAssertEqual(entriesBeforeGC.count, 1)
|
||||
|
||||
try Config().gc()
|
||||
XCTAssertEqual(try temporaryEntries(), entriesBeforeGC)
|
||||
|
||||
withExtendedLifetime(additionalDisk) {}
|
||||
}
|
||||
|
||||
try Config().gc()
|
||||
XCTAssertTrue(try temporaryEntries().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
func testGarbageCollectionPreservesLockedTemporaryDirectory() throws {
|
||||
try withTemporaryTartHome {
|
||||
let temporaryVMDir = try VMDirectory.temporary()
|
||||
let lock = try FileLock(lockURL: temporaryVMDir.baseURL)
|
||||
try lock.lock()
|
||||
XCTAssertTrue(FileManager.default.createFile(
|
||||
atPath: temporaryVMDir.overlayURL.path,
|
||||
contents: Data("overlay".utf8)
|
||||
))
|
||||
|
||||
try Config().gc()
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: temporaryVMDir.overlayURL.path))
|
||||
|
||||
try lock.unlock()
|
||||
try Config().gc()
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: temporaryVMDir.baseURL.path))
|
||||
}
|
||||
}
|
||||
|
||||
private func config() -> VMConfig {
|
||||
VMConfig(
|
||||
platform: Linux(),
|
||||
cpuCountMin: 2,
|
||||
memorySizeMin: 512 * 1024 * 1024,
|
||||
diskFormat: .raw
|
||||
)
|
||||
}
|
||||
|
||||
private func temporaryEntries() throws -> [URL] {
|
||||
try FileManager.default.contentsOfDirectory(
|
||||
at: Config().tartTmpDir,
|
||||
includingPropertiesForKeys: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func installUnavailableDiskutil() throws -> String? {
|
||||
let binDirectory = try temporaryDirectory()
|
||||
let diskutilURL = binDirectory.appendingPathComponent("diskutil")
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
echo 'Resource temporarily unavailable' >&2
|
||||
exit 1
|
||||
"""
|
||||
try script.write(to: diskutilURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: diskutilURL.path)
|
||||
let previousPath = ProcessInfo.processInfo.environment["PATH"]
|
||||
setenv("PATH", binDirectory.path, 1)
|
||||
return previousPath
|
||||
}
|
||||
|
||||
private func commandOutput<Command: AsyncParsableCommand>(
|
||||
_ commandType: Command.Type,
|
||||
_ arguments: [String]
|
||||
) async throws -> String {
|
||||
let outputURL = try temporaryDirectory().appendingPathComponent("stdout")
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: outputURL.path, contents: nil))
|
||||
let output = try FileHandle(forWritingTo: outputURL)
|
||||
defer { try? output.close() }
|
||||
|
||||
fflush(stdout)
|
||||
let savedStdout = dup(STDOUT_FILENO)
|
||||
defer {
|
||||
fflush(stdout)
|
||||
dup2(savedStdout, STDOUT_FILENO)
|
||||
close(savedStdout)
|
||||
}
|
||||
dup2(output.fileDescriptor, STDOUT_FILENO)
|
||||
var command = try Command.parseAsRoot(arguments) as! Command
|
||||
try await command.run()
|
||||
fflush(stdout)
|
||||
return try String(contentsOf: outputURL, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func withTemporaryTartHome(_ body: () throws -> Void) throws {
|
||||
let home = try temporaryDirectory()
|
||||
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
|
||||
setenv("TART_HOME", home.path, 1)
|
||||
defer { restoreEnvironment("TART_HOME", to: previousHome) }
|
||||
|
||||
try body()
|
||||
}
|
||||
|
||||
private func withTemporaryTartHome(_ body: () async throws -> Void) async throws {
|
||||
let home = try temporaryDirectory()
|
||||
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
|
||||
setenv("TART_HOME", home.path, 1)
|
||||
defer { restoreEnvironment("TART_HOME", to: previousHome) }
|
||||
|
||||
try await body()
|
||||
}
|
||||
|
||||
private func temporaryDirectory() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false)
|
||||
addTeardownBlock {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
private func restoreEnvironment(_ name: String, to value: String?) {
|
||||
if let value {
|
||||
setenv(name, value, 1)
|
||||
} else {
|
||||
unsetenv(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class ContentStoreTests: XCTestCase {
|
||||
func testCreatesDigestDirectoryDuringInitialization() throws {
|
||||
let store = try temporaryStore()
|
||||
let contentURL = try store.contentURL(for: Digest.hash(Data()))
|
||||
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: contentURL.deletingLastPathComponent().path))
|
||||
}
|
||||
|
||||
func testInstallAndValidatedLookup() throws {
|
||||
let store = try temporaryStore()
|
||||
let data = Data("base disk".utf8)
|
||||
let digest = Digest.hash(data)
|
||||
let temporaryURL = try store.temporaryContentURL(for: digest)
|
||||
try data.write(to: temporaryURL)
|
||||
|
||||
let installedURL = try store.install(temporaryURL, contentDigest: digest)
|
||||
|
||||
XCTAssertEqual(installedURL, try store.contentURL(for: digest))
|
||||
XCTAssertEqual(try store.existingContentURL(for: digest), installedURL)
|
||||
}
|
||||
|
||||
func testCorruptCacheEntryIsMiss() throws {
|
||||
let store = try temporaryStore()
|
||||
let expectedDigest = Digest.hash(Data("expected".utf8))
|
||||
let contentURL = try store.contentURL(for: expectedDigest)
|
||||
try FileManager.default.createDirectory(at: contentURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try Data("corrupt".utf8).write(to: contentURL)
|
||||
|
||||
XCTAssertNil(try store.existingContentURL(for: expectedDigest))
|
||||
XCTAssertEqual(try store.contentURLIfPresent(for: expectedDigest), contentURL)
|
||||
}
|
||||
|
||||
func testResumableAndLockURLsAreStablePerDigest() throws {
|
||||
let store = try temporaryStore()
|
||||
let firstDigest = Digest.hash(Data("first".utf8))
|
||||
let secondDigest = Digest.hash(Data("second".utf8))
|
||||
|
||||
XCTAssertEqual(
|
||||
try store.resumableContentURL(for: firstDigest),
|
||||
try store.resumableContentURL(for: firstDigest)
|
||||
)
|
||||
XCTAssertNotEqual(
|
||||
try store.resumableContentURL(for: firstDigest),
|
||||
try store.resumableContentURL(for: secondDigest)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
try store.lockURL(for: firstDigest),
|
||||
try store.lockURL(for: firstDigest)
|
||||
)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: try store.lockURL(for: firstDigest).path))
|
||||
}
|
||||
|
||||
func testInstallReplacesCorruptEntry() throws {
|
||||
let store = try temporaryStore()
|
||||
let data = Data("expected".utf8)
|
||||
let digest = Digest.hash(data)
|
||||
let contentURL = try store.contentURL(for: digest)
|
||||
try FileManager.default.createDirectory(at: contentURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try Data("corrupt".utf8).write(to: contentURL)
|
||||
let temporaryURL = try store.temporaryContentURL(for: digest)
|
||||
try data.write(to: temporaryURL)
|
||||
|
||||
XCTAssertEqual(try store.install(temporaryURL, contentDigest: digest), contentURL)
|
||||
XCTAssertEqual(try Digest.hash(contentURL), digest)
|
||||
}
|
||||
|
||||
func testInstallPreservesExistingValidEntry() throws {
|
||||
let store = try temporaryStore()
|
||||
let data = Data("expected".utf8)
|
||||
let digest = Digest.hash(data)
|
||||
let firstTemporaryURL = try store.temporaryContentURL(for: digest)
|
||||
try data.write(to: firstTemporaryURL)
|
||||
let installedURL = try store.install(firstTemporaryURL, contentDigest: digest)
|
||||
let secondTemporaryURL = try store.temporaryContentURL(for: digest)
|
||||
try data.write(to: secondTemporaryURL)
|
||||
|
||||
XCTAssertEqual(try store.install(secondTemporaryURL, contentDigest: digest), installedURL)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: secondTemporaryURL.path))
|
||||
XCTAssertEqual(try Digest.hash(installedURL), digest)
|
||||
}
|
||||
|
||||
func testConcurrentInstallsAcceptDigestValidWinner() throws {
|
||||
try assertConcurrentInstalls(seedCorruptEntry: false)
|
||||
}
|
||||
|
||||
func testConcurrentInstallsRepairCorruptEntry() throws {
|
||||
try assertConcurrentInstalls(seedCorruptEntry: true)
|
||||
}
|
||||
|
||||
func testInstallRejectsWrongContentDigest() throws {
|
||||
let store = try temporaryStore()
|
||||
let expectedDigest = Digest.hash(Data("expected".utf8))
|
||||
let temporaryURL = try store.temporaryContentURL(for: expectedDigest)
|
||||
try Data("actual".utf8).write(to: temporaryURL)
|
||||
|
||||
XCTAssertThrowsError(try store.install(temporaryURL, contentDigest: expectedDigest)) { error in
|
||||
guard case ContentStoreError.contentDigestMismatch(let expected, _) = error else {
|
||||
return XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
|
||||
XCTAssertEqual(expected, expectedDigest)
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsNonCanonicalDigest() throws {
|
||||
let store = try temporaryStore()
|
||||
|
||||
XCTAssertThrowsError(try store.contentURL(for: "sha256:ABC")) { error in
|
||||
XCTAssertEqual(error as? ContentStoreError, .invalidContentDigest("sha256:ABC"))
|
||||
}
|
||||
}
|
||||
|
||||
func testContentURLUnderArbitraryRootHasNoSideEffects() throws {
|
||||
let rootURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
addTeardownBlock {
|
||||
try? FileManager.default.removeItem(at: rootURL)
|
||||
}
|
||||
let digest = Digest.hash(Data("content".utf8))
|
||||
|
||||
let contentStore = try temporaryStore()
|
||||
let contentURL = try contentStore.contentURL(for: digest, under: rootURL)
|
||||
|
||||
XCTAssertEqual(
|
||||
contentURL,
|
||||
rootURL.appendingPathComponent("sha256", isDirectory: true)
|
||||
.appendingPathComponent(String(digest.dropFirst("sha256:".count)))
|
||||
)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: rootURL.path))
|
||||
}
|
||||
|
||||
private func temporaryStore() throws -> ContentStore {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
addTeardownBlock {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
return try ContentStore(baseURL: url)
|
||||
}
|
||||
|
||||
private func assertConcurrentInstalls(seedCorruptEntry: Bool) throws {
|
||||
let store = try temporaryStore()
|
||||
let data = Data("expected".utf8)
|
||||
let digest = Digest.hash(data)
|
||||
let contentURL = try store.contentURL(for: digest)
|
||||
try FileManager.default.createDirectory(at: contentURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
if seedCorruptEntry {
|
||||
try Data("corrupt".utf8).write(to: contentURL)
|
||||
}
|
||||
|
||||
let temporaryURLs = try (0..<16).map { _ in
|
||||
let url = try store.temporaryContentURL(for: digest)
|
||||
try data.write(to: url)
|
||||
return url
|
||||
}
|
||||
let errors = ErrorCollector()
|
||||
|
||||
DispatchQueue.concurrentPerform(iterations: temporaryURLs.count) { index in
|
||||
do {
|
||||
_ = try store.install(temporaryURLs[index], contentDigest: digest)
|
||||
} catch {
|
||||
errors.append(error)
|
||||
}
|
||||
}
|
||||
|
||||
XCTAssertTrue(errors.values.isEmpty, "unexpected install errors: \(errors.values)")
|
||||
XCTAssertEqual(try Digest.hash(contentURL), digest)
|
||||
XCTAssertTrue(temporaryURLs.allSatisfy { !FileManager.default.fileExists(atPath: $0.path) })
|
||||
}
|
||||
|
||||
private final class ErrorCollector: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var errors: [Error] = []
|
||||
|
||||
var values: [Error] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return errors
|
||||
}
|
||||
|
||||
func append(_ error: Error) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
errors.append(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import NIO
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
// Avoid NSObject.bind and Tart's Darwin type shadowing the system function.
|
||||
private let bindTestSocket = bind
|
||||
|
||||
@available(macOS 14, *)
|
||||
final class ControlSocketTests: XCTestCase {
|
||||
func testInitializerCreatesControlSocketBeforeReturning() async throws {
|
||||
let temporaryDirectory = try makeTemporaryDirectory()
|
||||
let originalDirectory = FileManager.default.currentDirectoryPath
|
||||
defer {
|
||||
FileManager.default.changeCurrentDirectoryPath(originalDirectory)
|
||||
try? FileManager.default.removeItem(at: temporaryDirectory)
|
||||
}
|
||||
|
||||
let socketURL = URL(fileURLWithPath: "control.sock", relativeTo: temporaryDirectory)
|
||||
var controlSocket: ControlSocket? = try await ControlSocket(socketURL)
|
||||
let eventLoopGroup = try XCTUnwrap(controlSocket?.eventLoopGroup)
|
||||
|
||||
do {
|
||||
let serverChannel = try XCTUnwrap(controlSocket?.serverChannel)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: socketURL.path))
|
||||
|
||||
try await serverChannel.executeThenClose { _ in }
|
||||
}
|
||||
|
||||
controlSocket = nil
|
||||
try await eventLoopGroup.shutdownGracefully()
|
||||
}
|
||||
|
||||
func testInitializerPropagatesControlSocketCreationFailure() async throws {
|
||||
let temporaryDirectory = try makeTemporaryDirectory()
|
||||
let originalDirectory = FileManager.default.currentDirectoryPath
|
||||
defer {
|
||||
FileManager.default.changeCurrentDirectoryPath(originalDirectory)
|
||||
try? FileManager.default.removeItem(at: temporaryDirectory)
|
||||
}
|
||||
|
||||
let socketURL = URL(fileURLWithPath: "missing/control.sock", relativeTo: temporaryDirectory)
|
||||
|
||||
do {
|
||||
_ = try await ControlSocket(socketURL)
|
||||
XCTFail("Binding should fail when the socket's parent directory does not exist")
|
||||
} catch {
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: socketURL.path))
|
||||
}
|
||||
}
|
||||
|
||||
func testInitializerReplacesStaleSocketInLongEncodedPath() async throws {
|
||||
let temporaryDirectory = try makeTemporaryDirectory()
|
||||
let originalDirectory = FileManager.default.currentDirectoryPath
|
||||
defer {
|
||||
FileManager.default.changeCurrentDirectoryPath(originalDirectory)
|
||||
try? FileManager.default.removeItem(at: temporaryDirectory)
|
||||
}
|
||||
|
||||
let vmDirectory = temporaryDirectory.appendingPathComponent(
|
||||
"Tart Home %# 虚拟机 " + String(repeating: "v", count: 104), isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(at: vmDirectory, withIntermediateDirectories: false)
|
||||
let socketURL = URL(fileURLWithPath: "control.sock", relativeTo: vmDirectory)
|
||||
XCTAssertGreaterThan(socketURL.path.utf8.count, 104)
|
||||
|
||||
// Closing a POSIX socket leaves its path behind, as exiting "tart run" does.
|
||||
XCTAssertTrue(FileManager.default.changeCurrentDirectoryPath(vmDirectory.path))
|
||||
let expectedDirectory = FileManager.default.currentDirectoryPath
|
||||
let address = try SocketAddress(unixDomainSocketPath: "control.sock")
|
||||
let descriptor = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
XCTAssertGreaterThanOrEqual(descriptor, 0)
|
||||
XCTAssertEqual(address.withSockAddr { bindTestSocket(descriptor, $0, socklen_t($1)) }, 0)
|
||||
XCTAssertEqual(close(descriptor), 0)
|
||||
XCTAssertTrue(FileManager.default.changeCurrentDirectoryPath(originalDirectory))
|
||||
|
||||
var controlSocket: ControlSocket? = try await ControlSocket(socketURL)
|
||||
let eventLoopGroup = try XCTUnwrap(controlSocket?.eventLoopGroup)
|
||||
do {
|
||||
let serverChannel = try XCTUnwrap(controlSocket?.serverChannel)
|
||||
XCTAssertEqual(FileManager.default.currentDirectoryPath, expectedDirectory)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: socketURL.path))
|
||||
try await serverChannel.executeThenClose { _ in }
|
||||
}
|
||||
controlSocket = nil
|
||||
try await eventLoopGroup.shutdownGracefully()
|
||||
}
|
||||
|
||||
private func makeTemporaryDirectory() throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
UUID().uuidString,
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
|
||||
return directory
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
@@ -22,34 +21,4 @@ final class DigestTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(Digest.hash(data), "sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592")
|
||||
}
|
||||
|
||||
func testFileAndRangeHashingMatchDataHashing() throws {
|
||||
let prefix = Data(repeating: 0x61, count: 4 * 1024 * 1024 + 17)
|
||||
let range = Data("range".utf8)
|
||||
let suffix = Data(repeating: 0x62, count: 23)
|
||||
let data = prefix + range + suffix
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try data.write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
XCTAssertEqual(try Digest.hash(url), Digest.hash(data))
|
||||
XCTAssertEqual(try Digest.hash(url, offset: UInt64(prefix.count), size: UInt64(range.count)), Digest.hash(range))
|
||||
}
|
||||
|
||||
func testRangeHashingRejectsOutOfBoundsRanges() throws {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try Data("range".utf8).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
XCTAssertThrowsError(try Digest.hash(url, offset: 6, size: 0)) { error in
|
||||
guard case DigestError.InvalidOffset = error else {
|
||||
return XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
XCTAssertThrowsError(try Digest.hash(url, offset: 1, size: UInt64.max)) { error in
|
||||
guard case DigestError.InvalidSize = error else {
|
||||
return XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
#if canImport(DiskImageKit)
|
||||
import DiskImageKit
|
||||
|
||||
@available(macOS 27.0, *)
|
||||
final class DiskImageStackTests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
|
||||
if #unavailable(macOS 27.0) {
|
||||
throw XCTSkip("DiskImageKit tests require macOS 27 or newer")
|
||||
}
|
||||
}
|
||||
|
||||
func testCreatesAndAttachesRawBaseWithWritableOverlay() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
|
||||
try fixture.disk.createWritableOverlay()
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: fixture.disk.writableOverlayURL.path))
|
||||
|
||||
_ = try fixture.disk.makeAttachment()
|
||||
}
|
||||
|
||||
func testCreatesAndAttachesASIFBaseWithPublishedOverlay() throws {
|
||||
let fixture = try Fixture(baseFormat: .asif, publishedOverlayCount: 1)
|
||||
|
||||
try fixture.disk.createWritableOverlay()
|
||||
_ = try fixture.disk.makeAttachment()
|
||||
}
|
||||
|
||||
func testCreatesAndAttachesMultiplePublishedOverlays() throws {
|
||||
let fixture = try Fixture(baseFormat: .asif, publishedOverlayCount: 2)
|
||||
|
||||
try fixture.disk.createWritableOverlay()
|
||||
_ = try fixture.disk.makeAttachment()
|
||||
}
|
||||
|
||||
func testCreatesAndAttachesLongPublishedOverlayChain() throws {
|
||||
let fixture = try Fixture(baseFormat: .asif, publishedOverlayCount: 8)
|
||||
|
||||
try fixture.disk.createWritableOverlay()
|
||||
_ = try fixture.disk.makeAttachment()
|
||||
}
|
||||
|
||||
func testAttachesStackReadOnly() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
try fixture.disk.createWritableOverlay()
|
||||
|
||||
_ = try fixture.disk.makeAttachment(readOnly: true)
|
||||
}
|
||||
|
||||
func testRejectsMissingWritableOverlayWhenAttaching() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
|
||||
assertThrows(.writableOverlayMissing(fixture.disk.writableOverlayURL)) {
|
||||
try fixture.disk.makeAttachment()
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsExistingWritableOverlayWhenCreating() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: fixture.disk.writableOverlayURL.path, contents: nil))
|
||||
|
||||
assertThrows(.writableOverlayAlreadyExists(fixture.disk.writableOverlayURL)) {
|
||||
try fixture.disk.createWritableOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsExistingCopyDestination() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
try fixture.disk.createWritableOverlay()
|
||||
|
||||
let destinationURL = fixture.directory.appendingPathComponent("existing-overlay.asif")
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: destinationURL.path, contents: nil))
|
||||
|
||||
assertThrows(.writableOverlayAlreadyExists(destinationURL)) {
|
||||
try fixture.disk.copyWritableOverlay(to: destinationURL)
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsNonASIFPublishedOverlay() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
let overlayURL = fixture.directory.appendingPathComponent("published-raw.img")
|
||||
_ = try DiskImage(creating: .raw(url: overlayURL, blockCount: 8))
|
||||
fixture.disk = DiskImageStack(
|
||||
baseURL: fixture.disk.baseURL,
|
||||
baseFormat: fixture.disk.baseFormat,
|
||||
immutableOverlayURLs: [
|
||||
overlayURL,
|
||||
],
|
||||
writableOverlayURL: fixture.disk.writableOverlayURL,
|
||||
blockSize: fixture.disk.blockSize,
|
||||
blockCount: fixture.disk.blockCount
|
||||
)
|
||||
|
||||
assertThrows(.invalidDiskImage(overlayURL, "overlay must use ASIF format")) {
|
||||
try fixture.disk.createWritableOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsWrongBaseFormat() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
fixture.disk = DiskImageStack(
|
||||
baseURL: fixture.disk.baseURL,
|
||||
baseFormat: .asif,
|
||||
immutableOverlayURLs: fixture.disk.immutableOverlayURLs,
|
||||
writableOverlayURL: fixture.disk.writableOverlayURL,
|
||||
blockSize: fixture.disk.blockSize,
|
||||
blockCount: fixture.disk.blockCount
|
||||
)
|
||||
|
||||
assertThrows(.invalidDiskImage(fixture.disk.baseURL, "base disk format does not match")) {
|
||||
try fixture.disk.createWritableOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsBlockSizeMismatch() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
fixture.disk = DiskImageStack(
|
||||
baseURL: fixture.disk.baseURL,
|
||||
baseFormat: fixture.disk.baseFormat,
|
||||
immutableOverlayURLs: fixture.disk.immutableOverlayURLs,
|
||||
writableOverlayURL: fixture.disk.writableOverlayURL,
|
||||
blockSize: 4096,
|
||||
blockCount: fixture.disk.blockCount
|
||||
)
|
||||
|
||||
assertThrows(.invalidBlockLayout("immutable disk stack does not match manifest block size")) {
|
||||
try fixture.disk.createWritableOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsUnsupportedBlockSize() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
fixture.disk = DiskImageStack(
|
||||
baseURL: fixture.disk.baseURL,
|
||||
baseFormat: fixture.disk.baseFormat,
|
||||
immutableOverlayURLs: fixture.disk.immutableOverlayURLs,
|
||||
writableOverlayURL: fixture.disk.writableOverlayURL,
|
||||
blockSize: 123,
|
||||
blockCount: fixture.disk.blockCount
|
||||
)
|
||||
|
||||
assertThrows(.invalidBlockLayout("unsupported stacked disk block size 123")) {
|
||||
try fixture.disk.createWritableOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsManifestBlockCountMismatch() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
fixture.disk = DiskImageStack(
|
||||
baseURL: fixture.disk.baseURL,
|
||||
baseFormat: fixture.disk.baseFormat,
|
||||
immutableOverlayURLs: fixture.disk.immutableOverlayURLs,
|
||||
writableOverlayURL: fixture.disk.writableOverlayURL,
|
||||
blockSize: fixture.disk.blockSize,
|
||||
blockCount: fixture.disk.blockCount + 1
|
||||
)
|
||||
|
||||
assertThrows(.invalidBlockLayout("immutable disk stack does not match manifest block count")) {
|
||||
try fixture.disk.createWritableOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
func testCopiesAndGrowsWritableOverlay() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
try fixture.disk.createWritableOverlay()
|
||||
|
||||
let copiedURL = fixture.directory.appendingPathComponent("copied-overlay.asif")
|
||||
try fixture.disk.copyWritableOverlay(to: copiedURL)
|
||||
fixture.disk = DiskImageStack(
|
||||
baseURL: fixture.disk.baseURL,
|
||||
baseFormat: fixture.disk.baseFormat,
|
||||
immutableOverlayURLs: fixture.disk.immutableOverlayURLs,
|
||||
writableOverlayURL: copiedURL,
|
||||
blockSize: fixture.disk.blockSize,
|
||||
blockCount: fixture.disk.blockCount
|
||||
)
|
||||
try fixture.disk.growWritableOverlay(toBlockCount: 16)
|
||||
|
||||
let copied = try DiskImage(opening: .open(url: copiedURL, mode: .readOnly))
|
||||
XCTAssertEqual(copied.blockCount, 16)
|
||||
}
|
||||
|
||||
func testRejectsOverlayFromDifferentASIFParent() throws {
|
||||
let fixture = try Fixture(baseFormat: .asif)
|
||||
let other = try Fixture(baseFormat: .asif, publishedOverlayCount: 1)
|
||||
fixture.disk = DiskImageStack(
|
||||
baseURL: fixture.disk.baseURL,
|
||||
baseFormat: fixture.disk.baseFormat,
|
||||
immutableOverlayURLs: other.disk.immutableOverlayURLs,
|
||||
writableOverlayURL: fixture.disk.writableOverlayURL,
|
||||
blockSize: fixture.disk.blockSize,
|
||||
blockCount: fixture.disk.blockCount
|
||||
)
|
||||
|
||||
assertThrows(.invalidDiskImage(other.disk.immutableOverlayURLs[0], "ASIF overlay is incompatible with its parent")) {
|
||||
try fixture.disk.createWritableOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsWritableOverlayShrink() throws {
|
||||
let fixture = try Fixture(baseFormat: .raw)
|
||||
try fixture.disk.createWritableOverlay()
|
||||
|
||||
assertThrows(.invalidDiskImage(fixture.disk.writableOverlayURL, "ASIF overlay block count shrinks the stacked disk")) {
|
||||
try fixture.disk.growWritableOverlay(toBlockCount: 4)
|
||||
}
|
||||
}
|
||||
|
||||
private func assertThrows<T>(
|
||||
_ expected: DiskImageStackError,
|
||||
operation: () throws -> T
|
||||
) {
|
||||
XCTAssertThrowsError(try operation()) { error in
|
||||
XCTAssertEqual(error as? DiskImageStackError, expected)
|
||||
}
|
||||
}
|
||||
|
||||
private final class Fixture {
|
||||
let directory: URL
|
||||
var disk: DiskImageStack
|
||||
|
||||
init(baseFormat: DiskImageFormat, publishedOverlayCount: Int = 0) throws {
|
||||
directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
|
||||
|
||||
let baseURL = directory.appendingPathComponent("base.img")
|
||||
switch baseFormat {
|
||||
case .raw:
|
||||
_ = try DiskImage(creating: .raw(url: baseURL, blockCount: 8))
|
||||
case .asif:
|
||||
_ = try DiskImage(creating: .asif(url: baseURL, blockCount: 8, blockSize: .bytes512))
|
||||
}
|
||||
|
||||
var immutableOverlayURLs: [URL] = []
|
||||
var image = try DiskImage(opening: .open(url: baseURL, mode: .readOnly))
|
||||
for index in 0..<publishedOverlayCount {
|
||||
let overlayURL = directory.appendingPathComponent("published-\(index).asif")
|
||||
let stack = try image.appending(.asifLayer(url: overlayURL, type: .overlay))
|
||||
immutableOverlayURLs.append(overlayURL)
|
||||
image = stack
|
||||
}
|
||||
|
||||
disk = DiskImageStack(
|
||||
baseURL: baseURL,
|
||||
baseFormat: baseFormat,
|
||||
immutableOverlayURLs: immutableOverlayURLs,
|
||||
writableOverlayURL: directory.appendingPathComponent("overlay.asif"),
|
||||
blockSize: 512,
|
||||
blockCount: 8
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,22 +0,0 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class HumanReadableByteCountTests: XCTestCase {
|
||||
func testUnknownByteCount() throws {
|
||||
let unknown = HumanReadableByteCount(nil) { $0 / 1000 / 1000 / 1000 }
|
||||
|
||||
XCTAssertEqual(unknown.description, "-")
|
||||
XCTAssertEqual(String(data: try JSONEncoder().encode(unknown), encoding: .utf8), "null")
|
||||
}
|
||||
|
||||
func testTextAndJSONRepresentations() throws {
|
||||
let integer = HumanReadableByteCount(51_400_000_000) { _ in 51 }
|
||||
let string = HumanReadableByteCount(17_234_000_000) { _ in "17.234" }
|
||||
let encoder = JSONEncoder()
|
||||
|
||||
XCTAssertEqual(string.description.compactMap(\.wholeNumberValue), [1, 7])
|
||||
XCTAssertEqual(try JSONDecoder().decode(Int.self, from: encoder.encode(integer)), 51)
|
||||
XCTAssertEqual(try JSONDecoder().decode(String.self, from: encoder.encode(string)), "17.234")
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import Virtualization
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class InputDeviceConfigurationTests: XCTestCase {
|
||||
func testLinuxUSBInputsCanBeDisabled() {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
VM.configureInputDevices(configuration, platform: Linux())
|
||||
XCTAssertEqual(configuration.keyboards.count, 1)
|
||||
XCTAssertTrue(configuration.keyboards.contains { $0 is VZUSBKeyboardConfiguration })
|
||||
XCTAssertEqual(configuration.pointingDevices.count, 1)
|
||||
XCTAssertTrue(configuration.pointingDevices.contains { $0 is VZUSBScreenCoordinatePointingDeviceConfiguration })
|
||||
|
||||
VM.configureInputDevices(configuration, platform: Linux(), noUSBAccessories: true)
|
||||
XCTAssertTrue(configuration.keyboards.isEmpty)
|
||||
XCTAssertTrue(configuration.pointingDevices.isEmpty)
|
||||
|
||||
VM.configureInputDevices(configuration, platform: Linux(), noUSBAccessories: true, noTrackpad: true)
|
||||
XCTAssertTrue(configuration.keyboards.isEmpty)
|
||||
XCTAssertTrue(configuration.pointingDevices.isEmpty)
|
||||
}
|
||||
|
||||
#if arch(arm64)
|
||||
func testMacOS13RetainsItsNativeTrackpad() {
|
||||
let platform = MacInputPlatform(nativeKeyboard: false)
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
VM.configureInputDevices(configuration, platform: platform)
|
||||
XCTAssertEqual(configuration.keyboards.count, 1)
|
||||
XCTAssertEqual(configuration.pointingDevices.count, 2)
|
||||
|
||||
VM.configureInputDevices(configuration, platform: platform, noUSBAccessories: true)
|
||||
XCTAssertTrue(configuration.keyboards.isEmpty)
|
||||
XCTAssertEqual(configuration.pointingDevices.count, 1)
|
||||
XCTAssertTrue(configuration.pointingDevices.contains { $0 is VZMacTrackpadConfiguration })
|
||||
}
|
||||
|
||||
func testMacOS14RetainsBothNativeInputs() throws {
|
||||
guard #available(macOS 14, *) else {
|
||||
throw XCTSkip("Mac keyboards require macOS 14")
|
||||
}
|
||||
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
VM.configureInputDevices(configuration, platform: MacInputPlatform(nativeKeyboard: true), noUSBAccessories: true)
|
||||
|
||||
XCTAssertEqual(configuration.keyboards.count, 1)
|
||||
XCTAssertTrue(configuration.keyboards.contains { $0 is VZMacKeyboardConfiguration })
|
||||
XCTAssertEqual(configuration.pointingDevices.count, 1)
|
||||
XCTAssertTrue(configuration.pointingDevices.contains { $0 is VZMacTrackpadConfiguration })
|
||||
}
|
||||
|
||||
func testInputFlagsStillSelectTheExpectedDevices() throws {
|
||||
guard #available(macOS 14, *) else {
|
||||
throw XCTSkip("Mac keyboards require macOS 14")
|
||||
}
|
||||
|
||||
let platform = MacInputPlatform(nativeKeyboard: true)
|
||||
for noUSBAccessories in [false, true] {
|
||||
for noKeyboard in [false, true] {
|
||||
for noPointer in [false, true] {
|
||||
for noTrackpad in [false, true] {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
VM.configureInputDevices(
|
||||
configuration,
|
||||
platform: platform,
|
||||
noUSBAccessories: noUSBAccessories,
|
||||
noTrackpad: noTrackpad,
|
||||
noPointer: noPointer,
|
||||
noKeyboard: noKeyboard
|
||||
)
|
||||
|
||||
XCTAssertEqual(configuration.keyboards.contains { $0 is VZUSBKeyboardConfiguration }, !noUSBAccessories && !noKeyboard)
|
||||
XCTAssertEqual(configuration.keyboards.contains { $0 is VZMacKeyboardConfiguration }, !noKeyboard)
|
||||
XCTAssertEqual(configuration.pointingDevices.contains { $0 is VZUSBScreenCoordinatePointingDeviceConfiguration }, !noUSBAccessories && !noPointer)
|
||||
XCTAssertEqual(configuration.pointingDevices.contains { $0 is VZMacTrackpadConfiguration }, !noPointer && !noTrackpad)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testSuspendableFallbackCannotReintroduceUSBInputs() {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
let platform = MacInputPlatform(nativeKeyboard: false)
|
||||
VM.configureInputDevices(configuration, platform: platform, suspendable: true)
|
||||
XCTAssertEqual(configuration.keyboards.count, 1)
|
||||
XCTAssertEqual(configuration.pointingDevices.count, 2)
|
||||
|
||||
VM.configureInputDevices(
|
||||
configuration,
|
||||
platform: platform,
|
||||
suspendable: true,
|
||||
noUSBAccessories: true
|
||||
)
|
||||
|
||||
XCTAssertTrue(configuration.keyboards.isEmpty)
|
||||
XCTAssertEqual(configuration.pointingDevices.count, 1)
|
||||
XCTAssertTrue(configuration.pointingDevices.contains { $0 is VZMacTrackpadConfiguration })
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if arch(arm64)
|
||||
// Model macOS 13 and 14 input availability without requiring a second host.
|
||||
private struct MacInputPlatform: PlatformSuspendable {
|
||||
var nativeKeyboard: Bool
|
||||
|
||||
func os() -> OS { .darwin }
|
||||
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||
try Linux().bootLoader(nvramURL: nvramURL)
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
|
||||
try Linux().platform(nvramURL: nvramURL, needsNestedVirtualization: needsNestedVirtualization)
|
||||
}
|
||||
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
Linux().graphicsDevice(vmConfig: vmConfig)
|
||||
}
|
||||
|
||||
func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||
var devices: [VZKeyboardConfiguration] = noUSB ? [] : [VZUSBKeyboardConfiguration()]
|
||||
if nativeKeyboard, #available(macOS 14, *) {
|
||||
devices.append(VZMacKeyboardConfiguration())
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
func pointingDevices(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||
var devices: [VZPointingDeviceConfiguration] = noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
devices.append(VZMacTrackpadConfiguration())
|
||||
return devices
|
||||
}
|
||||
|
||||
func pointingDevicesSimplified(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||
noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
|
||||
func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||
if nativeKeyboard, #available(macOS 14, *) {
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
}
|
||||
return keyboards(noUSB: noUSB)
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||
nativeKeyboard ? [VZMacTrackpadConfiguration()] : pointingDevices(noUSB: noUSB)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -14,7 +14,7 @@ final class LayerizerTests: XCTestCase {
|
||||
do {
|
||||
registryRunner = try await RegistryRunner()
|
||||
} catch {
|
||||
throw XCTSkip("Registry is unavailable: \(error)")
|
||||
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ final class LayerizerTests: XCTestCase {
|
||||
|
||||
func testDiskV2() async throws {
|
||||
// Original disk file to be pushed to the registry
|
||||
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 1 * 1024 * 1024 * 1024)
|
||||
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
|
||||
addTeardownBlock {
|
||||
try FileManager.default.removeItem(at: originalDiskFileURL)
|
||||
}
|
||||
@@ -36,14 +36,7 @@ final class LayerizerTests: XCTestCase {
|
||||
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
|
||||
print("pushing disk...")
|
||||
let diskLayers = try await DiskV2.push(
|
||||
diskURL: originalDiskFileURL,
|
||||
mediaType: diskV2MediaType,
|
||||
registry: registry,
|
||||
chunkSizeMb: 0,
|
||||
concurrency: 4,
|
||||
progress: Progress()
|
||||
)
|
||||
let diskLayers = try await DiskV2.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, concurrency: 4, progress: Progress())
|
||||
|
||||
print("pulling disk...")
|
||||
try await DiskV2.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class OCIManifestTests: XCTestCase {
|
||||
func testFlatDiskRepresentation() throws {
|
||||
let chunks = [chunk(mediaType: diskV2MediaType, suffix: "base-0")]
|
||||
let representation = try manifest(diskDescriptors: chunks).tartDiskRepresentation()
|
||||
|
||||
XCTAssertEqual(representation, .flat(base: TartDiskFileGroup(kind: .base, chunks: chunks, contentDigest: nil)))
|
||||
}
|
||||
|
||||
func testStackedDiskRepresentation() throws {
|
||||
let base = chunk(mediaType: diskV2MediaType, suffix: "base-0", diskFileDigest: "sha256:base")
|
||||
let overlay0 = chunk(mediaType: asifOverlayMediaType, suffix: "overlay-0", diskFileDigest: "sha256:overlay", chunkCount: 2)
|
||||
let overlay1 = chunk(mediaType: asifOverlayMediaType, suffix: "overlay-1")
|
||||
let representation = try manifest(diskDescriptors: [base, overlay0, overlay1]).tartDiskRepresentation()
|
||||
|
||||
XCTAssertEqual(representation, .stacked(
|
||||
base: TartDiskFileGroup(kind: .base, chunks: [base], contentDigest: "sha256:base"),
|
||||
overlays: [TartDiskFileGroup(kind: .asifOverlay, chunks: [overlay0, overlay1], contentDigest: "sha256:overlay")]
|
||||
))
|
||||
}
|
||||
|
||||
func testDiskFileGroupUncompressedSize() {
|
||||
let first = chunk(mediaType: diskV2MediaType, suffix: "base-0")
|
||||
let second = chunk(mediaType: diskV2MediaType, suffix: "base-1")
|
||||
XCTAssertEqual(TartDiskFileGroup(kind: .base, chunks: [first, second], contentDigest: nil).uncompressedSize(), 2)
|
||||
|
||||
var overflowing = first
|
||||
overflowing.annotations?[uncompressedSizeAnnotation] = String(UInt64.max)
|
||||
XCTAssertNil(TartDiskFileGroup(kind: .base, chunks: [overflowing, second], contentDigest: nil).uncompressedSize())
|
||||
}
|
||||
|
||||
func testDiskContentDigests() throws {
|
||||
let flatBase = chunk(mediaType: diskV2MediaType, suffix: "flat", diskFileDigest: "sha256:flat")
|
||||
XCTAssertEqual(try manifest(diskDescriptors: [flatBase]).diskContentDigests(), ["sha256:flat"])
|
||||
|
||||
let stackedBase = chunk(mediaType: diskV2MediaType, suffix: "base", diskFileDigest: "sha256:base")
|
||||
let overlay = chunk(
|
||||
mediaType: asifOverlayMediaType,
|
||||
suffix: "overlay",
|
||||
diskFileDigest: "sha256:overlay",
|
||||
chunkCount: 1
|
||||
)
|
||||
XCTAssertEqual(
|
||||
try manifest(diskDescriptors: [stackedBase, overlay]).diskContentDigests(),
|
||||
["sha256:base", "sha256:overlay"]
|
||||
)
|
||||
}
|
||||
|
||||
func testStackedRepresentationRequiresBaseDigest() throws {
|
||||
let base = chunk(mediaType: diskV2MediaType, suffix: "base-0")
|
||||
let overlay = chunk(mediaType: asifOverlayMediaType, suffix: "overlay-0", diskFileDigest: "sha256:overlay", chunkCount: 1)
|
||||
|
||||
assertManifestError(
|
||||
.invalidDiskMetadata("a stacked base disk needs a whole-file content digest"),
|
||||
diskDescriptors: [base, overlay]
|
||||
)
|
||||
}
|
||||
|
||||
func testBaseGroupRejectsMetadataAfterFirstChunk() throws {
|
||||
let base0 = chunk(mediaType: diskV2MediaType, suffix: "base-0", diskFileDigest: "sha256:base")
|
||||
let base1 = chunk(mediaType: diskV2MediaType, suffix: "base-1", diskFileDigest: "sha256:other-base")
|
||||
|
||||
assertManifestError(
|
||||
.invalidDiskMetadata("base disk metadata must appear only on its first chunk"),
|
||||
diskDescriptors: [base0, base1]
|
||||
)
|
||||
}
|
||||
|
||||
func testBaseGroupRejectsOverlayChunkCount() throws {
|
||||
let base = chunk(mediaType: diskV2MediaType, suffix: "base-0", diskFileDigest: "sha256:base", chunkCount: 1)
|
||||
|
||||
assertManifestError(
|
||||
.invalidDiskMetadata("base disk metadata must appear only on its first chunk"),
|
||||
diskDescriptors: [base]
|
||||
)
|
||||
}
|
||||
|
||||
func testOverlayGroupRequiresDigestAndChunkCount() throws {
|
||||
let base = chunk(mediaType: diskV2MediaType, suffix: "base-0", diskFileDigest: "sha256:base")
|
||||
let overlay = chunk(mediaType: asifOverlayMediaType, suffix: "overlay-0")
|
||||
|
||||
assertManifestError(
|
||||
.invalidDiskMetadata("an ASIF overlay needs a content digest and chunk count"),
|
||||
diskDescriptors: [base, overlay]
|
||||
)
|
||||
}
|
||||
|
||||
func testOverlayGroupRejectsInconsistentChunkCount() throws {
|
||||
let base = chunk(mediaType: diskV2MediaType, suffix: "base-0", diskFileDigest: "sha256:base")
|
||||
let overlay = chunk(mediaType: asifOverlayMediaType, suffix: "overlay-0", diskFileDigest: "sha256:overlay", chunkCount: 2)
|
||||
|
||||
assertManifestError(
|
||||
.invalidDiskMetadata("ASIF overlay chunk count is invalid"),
|
||||
diskDescriptors: [base, overlay]
|
||||
)
|
||||
}
|
||||
|
||||
func testDiskV2AfterOverlayIsRejected() throws {
|
||||
let base = chunk(mediaType: diskV2MediaType, suffix: "base-0", diskFileDigest: "sha256:base")
|
||||
let overlay = chunk(mediaType: asifOverlayMediaType, suffix: "overlay-0", diskFileDigest: "sha256:overlay", chunkCount: 2)
|
||||
let lateBase = chunk(mediaType: diskV2MediaType, suffix: "base-1")
|
||||
|
||||
assertManifestError(
|
||||
.invalidLayout("ASIF overlay chunks must be contiguous"),
|
||||
diskDescriptors: [base, overlay, lateBase]
|
||||
)
|
||||
}
|
||||
|
||||
func testChunkMetadataIsRequired() throws {
|
||||
var base = chunk(mediaType: diskV2MediaType, suffix: "base-0")
|
||||
base.annotations = nil
|
||||
|
||||
assertManifestError(
|
||||
.invalidDiskMetadata("disk chunks need uncompressed size and content digest"),
|
||||
diskDescriptors: [base]
|
||||
)
|
||||
}
|
||||
|
||||
func testCanonicalConfigAndNVRAMOrderIsRequired() throws {
|
||||
let disk = chunk(mediaType: diskV2MediaType, suffix: "base-0")
|
||||
let manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: 1, digest: "sha256:oci-config"),
|
||||
layers: [disk, configLayer(), nvramLayer()]
|
||||
)
|
||||
|
||||
XCTAssertThrowsError(try manifest.tartDiskRepresentation()) { error in
|
||||
XCTAssertEqual(
|
||||
error as? OCIManifestValidationError,
|
||||
.invalidLayout("descriptors must be ordered as config, disk chunks, then NVRAM")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testManifestBlockLayout() throws {
|
||||
var manifest = manifest(diskDescriptors: [chunk(mediaType: diskV2MediaType, suffix: "base-0")])
|
||||
manifest.annotations = [
|
||||
uncompressedDiskSizeAnnotation: "100000000000",
|
||||
diskBlockSizeAnnotation: "512",
|
||||
]
|
||||
|
||||
XCTAssertEqual(manifest.diskBlockSize(), 512)
|
||||
XCTAssertEqual(manifest.diskBlockCount(), 195312500)
|
||||
}
|
||||
|
||||
func testManifestRejectsInexactDerivedBlockCount() throws {
|
||||
var manifest = manifest(diskDescriptors: [chunk(mediaType: diskV2MediaType, suffix: "base-0")])
|
||||
manifest.annotations = [
|
||||
uncompressedDiskSizeAnnotation: "513",
|
||||
diskBlockSizeAnnotation: "512",
|
||||
]
|
||||
|
||||
XCTAssertNil(manifest.diskBlockCount())
|
||||
}
|
||||
|
||||
private func assertManifestError(_ expected: OCIManifestValidationError, diskDescriptors: [OCIManifestLayer]) {
|
||||
XCTAssertThrowsError(try manifest(diskDescriptors: diskDescriptors).tartDiskRepresentation()) { error in
|
||||
XCTAssertEqual(error as? OCIManifestValidationError, expected)
|
||||
}
|
||||
}
|
||||
|
||||
private func manifest(diskDescriptors: [OCIManifestLayer]) -> OCIManifest {
|
||||
OCIManifest(
|
||||
config: OCIManifestConfig(size: 1, digest: "sha256:oci-config"),
|
||||
layers: [configLayer()] + diskDescriptors + [nvramLayer()]
|
||||
)
|
||||
}
|
||||
|
||||
private func configLayer() -> OCIManifestLayer {
|
||||
OCIManifestLayer(mediaType: configMediaType, size: 1, digest: "sha256:tart-config")
|
||||
}
|
||||
|
||||
private func nvramLayer() -> OCIManifestLayer {
|
||||
OCIManifestLayer(mediaType: nvramMediaType, size: 1, digest: "sha256:nvram")
|
||||
}
|
||||
|
||||
private func chunk(mediaType: String, suffix: String, diskFileDigest: String? = nil, chunkCount: Int? = nil) -> OCIManifestLayer {
|
||||
var descriptor = OCIManifestLayer(
|
||||
mediaType: mediaType,
|
||||
size: 1,
|
||||
digest: "sha256:\(suffix)",
|
||||
uncompressedSize: 1,
|
||||
uncompressedContentDigest: "sha256:uncompressed-\(suffix)"
|
||||
)
|
||||
|
||||
if let diskFileDigest {
|
||||
descriptor.annotations?[diskFileContentDigestAnnotation] = diskFileDigest
|
||||
}
|
||||
if let chunkCount {
|
||||
descriptor.annotations?[diskFileChunkCountAnnotation] = String(chunkCount)
|
||||
}
|
||||
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class RegistryHostTests: XCTestCase {
|
||||
func testDockerHub() throws {
|
||||
let credentialsProvider = RecordingCredentialsProvider()
|
||||
let registry = try Registry(host: "docker.io", namespace: "org/repo",
|
||||
credentialsProviders: [credentialsProvider])
|
||||
|
||||
// docker.io redirects to Docker's website, so the API
|
||||
// requests should go to registry-1.docker.io instead
|
||||
XCTAssertEqual(registry.baseURL, URL(string: "https://registry-1.docker.io/v2/"))
|
||||
|
||||
// ...while naming and credentials lookup should still use the host specified by the user
|
||||
XCTAssertEqual(registry.host, "docker.io")
|
||||
XCTAssertNil(try registry.lookupCredentials())
|
||||
XCTAssertEqual(credentialsProvider.requestedHosts, ["docker.io"])
|
||||
}
|
||||
|
||||
func testDockerHubIsMatchedCaseInsensitively() throws {
|
||||
let credentialsProvider = RecordingCredentialsProvider()
|
||||
let registry = try Registry(host: "Docker.IO", namespace: "org/repo",
|
||||
credentialsProviders: [credentialsProvider])
|
||||
|
||||
XCTAssertEqual(registry.baseURL, URL(string: "https://registry-1.docker.io/v2/"))
|
||||
XCTAssertEqual(registry.host, "Docker.IO")
|
||||
XCTAssertNil(try registry.lookupCredentials())
|
||||
XCTAssertEqual(credentialsProvider.requestedHosts, ["Docker.IO"])
|
||||
}
|
||||
|
||||
func testDockerHubWithExplicitPort() throws {
|
||||
for host in ["docker.io:443", "DOCKER.IO:443"] {
|
||||
let registry = try Registry(host: host, namespace: "org/repo")
|
||||
|
||||
XCTAssertEqual(registry.baseURL, URL(string: "https://registry-1.docker.io:443/v2/"))
|
||||
XCTAssertEqual(registry.host, host)
|
||||
}
|
||||
}
|
||||
|
||||
func testOtherHostsAreUnchanged() throws {
|
||||
for host in ["ghcr.io", "index.docker.io", "registry-1.docker.io", "registry.hub.docker.com", "127.0.0.1:8080"] {
|
||||
let registry = try Registry(host: host, namespace: "org/repo")
|
||||
|
||||
XCTAssertEqual(registry.baseURL, URL(string: "https://\(host)/v2/"))
|
||||
XCTAssertEqual(registry.host, host)
|
||||
}
|
||||
|
||||
let registry = try Registry(host: "127.0.0.1:5000", namespace: "org/repo", insecure: true)
|
||||
XCTAssertEqual(registry.baseURL, URL(string: "http://127.0.0.1:5000/v2/"))
|
||||
XCTAssertEqual(registry.host, "127.0.0.1:5000")
|
||||
}
|
||||
|
||||
func testHostPortIsNormalized() throws {
|
||||
// Credentials stored for "127.0.0.1:5000" should still be found
|
||||
// when the port is written with a leading zero
|
||||
let credentialsProvider = RecordingCredentialsProvider(credentials: ["127.0.0.1:5000": ("user", "password")])
|
||||
let registry = try Registry(host: "127.0.0.1:05000", namespace: "org/repo", insecure: true,
|
||||
credentialsProviders: [credentialsProvider])
|
||||
|
||||
XCTAssertEqual(registry.baseURL, URL(string: "http://127.0.0.1:05000/v2/"))
|
||||
XCTAssertEqual(registry.host, "127.0.0.1:5000")
|
||||
|
||||
let (user, password) = try XCTUnwrap(registry.lookupCredentials())
|
||||
XCTAssertEqual(user, "user")
|
||||
XCTAssertEqual(password, "password")
|
||||
XCTAssertEqual(credentialsProvider.requestedHosts, ["127.0.0.1:5000"])
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate class RecordingCredentialsProvider: CredentialsProvider {
|
||||
let userFriendlyName = "recording credentials provider"
|
||||
|
||||
let credentials: [String: (String, String)]
|
||||
var requestedHosts: [String] = []
|
||||
|
||||
init(credentials: [String: (String, String)] = [:]) {
|
||||
self.credentials = credentials
|
||||
}
|
||||
|
||||
func retrieve(host: String) throws -> (String, String)? {
|
||||
requestedHosts.append(host)
|
||||
|
||||
return credentials[host]
|
||||
}
|
||||
|
||||
func store(host: String, user: String, password: String) throws {
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ final class RegistryTests: XCTestCase {
|
||||
do {
|
||||
registryRunner = try await RegistryRunner()
|
||||
} catch {
|
||||
throw XCTSkip("Registry is unavailable: \(error)")
|
||||
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
import Semaphore
|
||||
|
||||
final class SoftnetControlFDTests: XCTestCase {
|
||||
func testConnectedUnixStreamSocketIsAccepted() throws {
|
||||
var fds: [Int32] = [-1, -1]
|
||||
XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0)
|
||||
defer {
|
||||
close(fds[0])
|
||||
close(fds[1])
|
||||
}
|
||||
|
||||
XCTAssertNoThrow(try Softnet.validateControlFD(fds[0]))
|
||||
}
|
||||
|
||||
func testUnixDatagramSocketIsRejected() throws {
|
||||
var fds: [Int32] = [-1, -1]
|
||||
XCTAssertEqual(socketpair(AF_UNIX, SOCK_DGRAM, 0, &fds), 0)
|
||||
defer {
|
||||
close(fds[0])
|
||||
close(fds[1])
|
||||
}
|
||||
|
||||
XCTAssertThrowsError(try Softnet.validateControlFD(fds[0]))
|
||||
}
|
||||
|
||||
func testUnconnectedUnixStreamSocketIsRejected() throws {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
XCTAssertGreaterThan(fd, STDERR_FILENO)
|
||||
defer { close(fd) }
|
||||
|
||||
XCTAssertThrowsError(try Softnet.validateControlFD(fd))
|
||||
}
|
||||
|
||||
func testPipeIsRejected() throws {
|
||||
var fds: [Int32] = [-1, -1]
|
||||
XCTAssertEqual(pipe(&fds), 0)
|
||||
defer {
|
||||
close(fds[0])
|
||||
close(fds[1])
|
||||
}
|
||||
|
||||
XCTAssertThrowsError(try Softnet.validateControlFD(fds[0]))
|
||||
}
|
||||
|
||||
func testStandardDescriptorsAreRejected() throws {
|
||||
XCTAssertThrowsError(try Softnet.validateControlFD(STDIN_FILENO))
|
||||
XCTAssertThrowsError(try Softnet.validateControlFD(STDOUT_FILENO))
|
||||
XCTAssertThrowsError(try Softnet.validateControlFD(STDERR_FILENO))
|
||||
}
|
||||
|
||||
func testStandardDescriptorsRemainOpenWhenInitializationFails() throws {
|
||||
for fd in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
|
||||
let flags = fcntl(fd, F_GETFD)
|
||||
XCTAssertNotEqual(flags, -1)
|
||||
|
||||
XCTAssertThrowsError(try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fd))
|
||||
XCTAssertEqual(fcntl(fd, F_GETFD), flags)
|
||||
}
|
||||
}
|
||||
|
||||
func testControlChannelIsPassedToSoftnetAndVMFDRemainsDatagram() async throws {
|
||||
let temporaryDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: false)
|
||||
defer { try? FileManager.default.removeItem(at: temporaryDirectory) }
|
||||
|
||||
let executable = temporaryDirectory.appendingPathComponent("softnet")
|
||||
let script = """
|
||||
#!/usr/bin/env python3
|
||||
import socket
|
||||
import sys
|
||||
|
||||
assert sys.argv[1:] == ["--vm-fd", "0", "--vm-mac-address", "02:00:00:00:00:01", "--control-fd", "1"]
|
||||
vm = socket.socket(fileno=0)
|
||||
control = socket.socket(fileno=1)
|
||||
assert vm.family == socket.AF_UNIX and vm.type == socket.SOCK_DGRAM
|
||||
assert control.family == socket.AF_UNIX and control.type == socket.SOCK_STREAM
|
||||
assert control.recv(4096) == b"softnet.policy.set\\n"
|
||||
control.sendall(b"ok\\n")
|
||||
"""
|
||||
try script.write(to: executable, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
|
||||
let previousPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
|
||||
setenv("PATH", "\(temporaryDirectory.path):\(previousPath)", 1)
|
||||
defer { setenv("PATH", previousPath, 1) }
|
||||
|
||||
var fds: [Int32] = [-1, -1]
|
||||
XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0)
|
||||
defer { close(fds[1]) }
|
||||
|
||||
var timeout = timeval(tv_sec: 5, tv_usec: 0)
|
||||
XCTAssertEqual(setsockopt(fds[1], SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size)), 0)
|
||||
|
||||
let semaphore = AsyncSemaphore(value: 0)
|
||||
let softnet = try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fds[0])
|
||||
try softnet.run(semaphore)
|
||||
|
||||
XCTAssertEqual(fcntl(fds[0], F_GETFD), -1)
|
||||
XCTAssertEqual(errno, EBADF)
|
||||
|
||||
let request = Array("softnet.policy.set\n".utf8)
|
||||
XCTAssertEqual(request.withUnsafeBytes { send(fds[1], $0.baseAddress, $0.count, 0) }, request.count)
|
||||
|
||||
var response = [UInt8](repeating: 0, count: 128)
|
||||
let received = recv(fds[1], &response, response.count, 0)
|
||||
XCTAssertGreaterThan(received, 0)
|
||||
XCTAssertEqual(String(decoding: response.prefix(Int(max(received, 0))), as: UTF8.self), "ok\n")
|
||||
|
||||
await semaphore.wait()
|
||||
}
|
||||
|
||||
func testControlFDIsClosedWhenSoftnetInitializationFails() throws {
|
||||
let previousPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
|
||||
setenv("PATH", "/this/path/does/not/exist", 1)
|
||||
defer { setenv("PATH", previousPath, 1) }
|
||||
|
||||
var fds: [Int32] = [-1, -1]
|
||||
XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0)
|
||||
defer { close(fds[1]) }
|
||||
|
||||
XCTAssertThrowsError(try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fds[0]))
|
||||
XCTAssertEqual(fcntl(fds[0], F_GETFD), -1)
|
||||
XCTAssertEqual(errno, EBADF)
|
||||
}
|
||||
|
||||
func testControlFDIsClosedWhenSoftnetValidationFails() throws {
|
||||
var fds: [Int32] = [-1, -1]
|
||||
XCTAssertEqual(socketpair(AF_UNIX, SOCK_DGRAM, 0, &fds), 0)
|
||||
defer { close(fds[1]) }
|
||||
|
||||
XCTAssertThrowsError(try Softnet(vmMACAddress: "02:00:00:00:00:01", controlFD: fds[0]))
|
||||
XCTAssertEqual(fcntl(fds[0], F_GETFD), -1)
|
||||
XCTAssertEqual(errno, EBADF)
|
||||
}
|
||||
|
||||
func testControlFDImpliesSoftnet() throws {
|
||||
let temporaryHome = try createTemporaryTartHome()
|
||||
defer { try? FileManager.default.removeItem(at: temporaryHome) }
|
||||
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
|
||||
setenv("TART_HOME", temporaryHome.path, 1)
|
||||
defer { restoreEnvironment("TART_HOME", value: previousHome) }
|
||||
|
||||
let command = try Run.parse(["vm", "--net-softnet-control-fd", "3"])
|
||||
|
||||
XCTAssertTrue(command.netSoftnet)
|
||||
XCTAssertEqual(command.netSoftnetControlFd, 3)
|
||||
}
|
||||
|
||||
func testControlFDIsRejectedWithHostNetworking() throws {
|
||||
let temporaryHome = try createTemporaryTartHome()
|
||||
defer { try? FileManager.default.removeItem(at: temporaryHome) }
|
||||
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
|
||||
setenv("TART_HOME", temporaryHome.path, 1)
|
||||
defer { restoreEnvironment("TART_HOME", value: previousHome) }
|
||||
|
||||
XCTAssertThrowsError(
|
||||
try Run.parse(["vm", "--net-host", "--net-softnet-control-fd", "3"])
|
||||
)
|
||||
}
|
||||
|
||||
private func createTemporaryTartHome() throws -> URL {
|
||||
let temporaryHome = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
|
||||
let vm = temporaryHome.appendingPathComponent("vms/vm")
|
||||
try FileManager.default.createDirectory(at: vm, withIntermediateDirectories: true)
|
||||
|
||||
for name in ["config.json", "disk.img", "nvram.bin"] {
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: vm.appendingPathComponent(name).path, contents: nil))
|
||||
}
|
||||
|
||||
return temporaryHome
|
||||
}
|
||||
|
||||
private func restoreEnvironment(_ name: String, value: String?) {
|
||||
if let value = value {
|
||||
setenv(name, value, 1)
|
||||
} else {
|
||||
unsetenv(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class RegistryRunner {
|
||||
let stdoutPipe = Pipe()
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/docker")
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
|
||||
proc.arguments = arguments
|
||||
proc.standardOutput = stdoutPipe
|
||||
try proc.run()
|
||||
@@ -31,7 +31,7 @@ class RegistryRunner {
|
||||
|
||||
init() async throws {
|
||||
// Start container
|
||||
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "127.0.0.1::5000", "registry:2")
|
||||
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "127.0.0.1:0:5000", "registry:2")
|
||||
.trimmingCharacters(in: CharacterSet.newlines)
|
||||
containerID = container
|
||||
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
#if canImport(DiskImageKit)
|
||||
import DiskImageKit
|
||||
|
||||
@available(macOS 27.0, *)
|
||||
final class VMDirectoryDiskImageStackTests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
|
||||
if #unavailable(macOS 27.0) {
|
||||
throw XCTSkip("DiskImageKit tests require macOS 27 or newer")
|
||||
}
|
||||
}
|
||||
|
||||
func testBaseBlockLayoutReadsRawAndASIFImages() throws {
|
||||
let directory = try temporaryDirectory()
|
||||
let rawURL = directory.appendingPathComponent("base.raw")
|
||||
let asifURL = directory.appendingPathComponent("base.asif")
|
||||
_ = try DiskImage(creating: .raw(url: rawURL, blockCount: 8))
|
||||
_ = try DiskImage(creating: .asif(url: asifURL, blockCount: 16, blockSize: .bytes512))
|
||||
|
||||
XCTAssertEqual(try DiskImageStack.baseBlockLayout(at: rawURL, expectedFormat: .raw).blockCount, 8)
|
||||
XCTAssertEqual(try DiskImageStack.baseBlockLayout(at: asifURL, expectedFormat: .asif).blockCount, 16)
|
||||
}
|
||||
|
||||
func testCloneAsStackedBasePinsFlatManifestAndCreatesOverlay() throws {
|
||||
let contentStore = try temporaryContentStore()
|
||||
let source = try flatSource()
|
||||
let destination = try temporaryVMDirectory()
|
||||
|
||||
try source.cloneAsStackedBase(to: destination, generateMAC: false, contentStore: contentStore)
|
||||
|
||||
XCTAssertTrue(destination.isStackedVM)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: destination.diskURL.path))
|
||||
|
||||
let contentDigest = try Digest.hash(source.diskURL)
|
||||
let manifest = try OCIManifest(fromJSON: Data(contentsOf: destination.manifestURL))
|
||||
guard case .flat(let base) = try manifest.tartDiskRepresentation() else {
|
||||
return XCTFail("expected a pinned base-only manifest")
|
||||
}
|
||||
XCTAssertEqual(base.contentDigest, contentDigest)
|
||||
XCTAssertEqual(manifest.diskBlockSize(), 512)
|
||||
XCTAssertEqual(manifest.diskBlockCount(), 8)
|
||||
|
||||
let stack = try destination.diskImageStack(contentStore: contentStore)
|
||||
XCTAssertEqual(stack.baseURL, try contentStore.contentURL(for: contentDigest))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: destination.overlayURL.path))
|
||||
}
|
||||
|
||||
func testCloneAsStackedBaseSupportsASIFDisk() throws {
|
||||
let contentStore = try temporaryContentStore()
|
||||
let source = try flatSource(diskFormat: .asif)
|
||||
let destination = try temporaryVMDirectory()
|
||||
|
||||
try source.cloneAsStackedBase(to: destination, generateMAC: false, contentStore: contentStore)
|
||||
|
||||
let stack = try destination.diskImageStack(contentStore: contentStore)
|
||||
XCTAssertEqual(stack.baseFormat, .asif)
|
||||
XCTAssertTrue(destination.isStackedVM)
|
||||
_ = try stack.makeAttachment()
|
||||
}
|
||||
|
||||
func testStackedCloneCanCopyOrCreateWritableOverlay() throws {
|
||||
let contentStore = try temporaryContentStore()
|
||||
let source = try flatSource()
|
||||
let stacked = try temporaryVMDirectory()
|
||||
try source.cloneAsStackedBase(to: stacked, generateMAC: false, contentStore: contentStore)
|
||||
|
||||
let copied = try temporaryVMDirectory()
|
||||
try stacked.cloneStacked(to: copied, copyWritableOverlay: true, generateMAC: false, contentStore: contentStore)
|
||||
XCTAssertEqual(try Digest.hash(copied.overlayURL), try Digest.hash(stacked.overlayURL))
|
||||
|
||||
let fresh = try temporaryVMDirectory()
|
||||
try stacked.cloneStacked(to: fresh, copyWritableOverlay: false, generateMAC: false, contentStore: contentStore)
|
||||
XCTAssertTrue(fresh.isStackedVM)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: fresh.overlayURL.path))
|
||||
}
|
||||
|
||||
func testStackedRemoteAdditionalDiskRetainsTemporaryVM() throws {
|
||||
try withTemporaryTartHome {
|
||||
let source = try flatSource()
|
||||
let stacked = try temporaryVMDirectory()
|
||||
try source.cloneAsStackedBase(to: stacked, generateMAC: false)
|
||||
|
||||
let storage = try VMStorageOCI()
|
||||
let name = try RemoteName("example.com/org/image:latest")
|
||||
let cachedImage = try storage.create(name)
|
||||
try FileManager.default.copyItem(at: stacked.configURL, to: cachedImage.configURL)
|
||||
try FileManager.default.copyItem(at: stacked.nvramURL, to: cachedImage.nvramURL)
|
||||
try FileManager.default.copyItem(at: stacked.manifestURL, to: cachedImage.manifestURL)
|
||||
|
||||
do {
|
||||
let additionalDisk = try AdditionalDisk(parseFrom: name.description)
|
||||
let entries = try temporaryEntries()
|
||||
XCTAssertEqual(entries.count, 1)
|
||||
XCTAssertTrue(VMDirectory(baseURL: entries[0]).isStackedVM)
|
||||
|
||||
try Config().gc()
|
||||
XCTAssertEqual(try temporaryEntries(), entries)
|
||||
|
||||
withExtendedLifetime(additionalDisk) {}
|
||||
}
|
||||
|
||||
try Config().gc()
|
||||
XCTAssertTrue(try temporaryEntries().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
func testResizeDiskGrowsWritableOverlayAndPreservesParentGeometry() throws {
|
||||
let contentStore = try temporaryContentStore()
|
||||
let source = try flatSource()
|
||||
let stacked = try temporaryVMDirectory()
|
||||
try source.cloneAsStackedBase(to: stacked, generateMAC: false, contentStore: contentStore)
|
||||
|
||||
try stacked.resizeDisk(1, contentStore: contentStore)
|
||||
|
||||
let image = try DiskImage(opening: .open(url: stacked.overlayURL, mode: .readOnly))
|
||||
XCTAssertEqual(image.blockCount, 1_000_000_000 / 512)
|
||||
XCTAssertEqual(try stacked.diskSizeBytes(), 1_000_000_000)
|
||||
|
||||
let manifest = try OCIManifest(fromJSON: Data(contentsOf: stacked.manifestURL))
|
||||
XCTAssertEqual(manifest.diskBlockSize(), 512)
|
||||
XCTAssertEqual(manifest.diskBlockCount(), 8)
|
||||
|
||||
_ = try stacked.diskImageStack(contentStore: contentStore).makeAttachment()
|
||||
}
|
||||
|
||||
func testStackedArchiveRoundTripsImmutableContentAndOverlay() throws {
|
||||
try withTemporaryTartHome {
|
||||
let source = try flatSource()
|
||||
let stacked = try temporaryVMDirectory()
|
||||
try source.cloneAsStackedBase(to: stacked, generateMAC: false)
|
||||
|
||||
let contentDigest = try Digest.hash(source.diskURL)
|
||||
let contentStore = try ContentStore()
|
||||
let archivedOverlayDigest = try Digest.hash(stacked.overlayURL)
|
||||
let archiveURL = try temporaryDirectory().appendingPathComponent("stacked.tvm")
|
||||
try stacked.exportToArchive(path: archiveURL.path)
|
||||
|
||||
let cachedBaseURL = try XCTUnwrap(try contentStore.existingContentURL(for: contentDigest))
|
||||
// Import must repair a corrupt cache entry from the valid archive
|
||||
// instead of discarding the archive copy as an apparent cache hit.
|
||||
try Data("corrupt".utf8).write(to: cachedBaseURL)
|
||||
XCTAssertNil(try contentStore.existingContentURL(for: contentDigest))
|
||||
|
||||
let imported = try temporaryVMDirectory()
|
||||
try imported.importFromArchive(path: archiveURL.path)
|
||||
|
||||
XCTAssertTrue(imported.isStackedVM)
|
||||
XCTAssertEqual(try Digest.hash(imported.overlayURL), archivedOverlayDigest)
|
||||
XCTAssertNotNil(try contentStore.existingContentURL(for: contentDigest))
|
||||
_ = try imported.diskImageStack().makeAttachment()
|
||||
}
|
||||
}
|
||||
|
||||
func testStackedArchiveRejectsCorruptImmutableContent() throws {
|
||||
try withTemporaryTartHome {
|
||||
let source = try flatSource()
|
||||
let stacked = try temporaryVMDirectory()
|
||||
try source.cloneAsStackedBase(to: stacked, generateMAC: false)
|
||||
|
||||
let contentDigest = try Digest.hash(source.diskURL)
|
||||
let contentStore = try ContentStore()
|
||||
let cachedBaseURL = try XCTUnwrap(try contentStore.contentURLIfPresent(for: contentDigest))
|
||||
try Data("corrupt".utf8).write(to: cachedBaseURL)
|
||||
|
||||
let archiveURL = try temporaryDirectory().appendingPathComponent("stacked.tvm")
|
||||
XCTAssertThrowsError(try stacked.exportToArchive(path: archiveURL.path)) { error in
|
||||
guard case RuntimeError.ExportFailed(let message) = error else {
|
||||
return XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
XCTAssertEqual(message, "VM is missing cached disk content \(contentDigest)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testStackedOCIArchiveSurvivesConcurrentRecordDeletion() throws {
|
||||
try withTemporaryTartHome {
|
||||
let source = try flatSource()
|
||||
let stacked = try temporaryVMDirectory()
|
||||
try source.cloneAsStackedBase(to: stacked, generateMAC: false)
|
||||
|
||||
let manifest = try OCIManifest(fromJSON: Data(contentsOf: stacked.manifestURL))
|
||||
let storage = try VMStorageOCI()
|
||||
let record = try storage.create(RemoteName(
|
||||
host: "example.com",
|
||||
namespace: "org/image",
|
||||
reference: Reference(digest: try manifest.digest())
|
||||
))
|
||||
try FileManager.default.copyItem(at: stacked.configURL, to: record.configURL)
|
||||
try FileManager.default.copyItem(at: stacked.nvramURL, to: record.nvramURL)
|
||||
try FileManager.default.copyItem(at: stacked.manifestURL, to: record.manifestURL)
|
||||
XCTAssertTrue(record.isStackedCachedImage)
|
||||
|
||||
let archiveURL = try temporaryDirectory().appendingPathComponent("stacked-race.tvm")
|
||||
let contentStore = try ContentStore()
|
||||
let lockHeld = DispatchSemaphore(value: 0)
|
||||
let releaseLock = DispatchSemaphore(value: 0)
|
||||
let exportStarted = DispatchSemaphore(value: 0)
|
||||
let exportFinished = DispatchSemaphore(value: 0)
|
||||
let deletionStarted = DispatchSemaphore(value: 0)
|
||||
let deletionFinished = DispatchSemaphore(value: 0)
|
||||
|
||||
DispatchQueue.global().async {
|
||||
try? contentStore.withPruneLock {
|
||||
lockHeld.signal()
|
||||
releaseLock.wait()
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(lockHeld.wait(timeout: .now() + 1), .success)
|
||||
|
||||
// Queue export first so it is the next prune-lock waiter, then queue
|
||||
// deletion behind it. Export must finish staging everything it needs
|
||||
// before deletion can remove the source cached image.
|
||||
DispatchQueue.global().async {
|
||||
exportStarted.signal()
|
||||
try? record.exportToArchive(path: archiveURL.path)
|
||||
exportFinished.signal()
|
||||
}
|
||||
XCTAssertEqual(exportStarted.wait(timeout: .now() + 1), .success)
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
|
||||
DispatchQueue.global().async {
|
||||
deletionStarted.signal()
|
||||
try? record.delete()
|
||||
deletionFinished.signal()
|
||||
}
|
||||
XCTAssertEqual(deletionStarted.wait(timeout: .now() + 1), .success)
|
||||
XCTAssertEqual(exportFinished.wait(timeout: .now() + 0.1), .timedOut)
|
||||
XCTAssertEqual(deletionFinished.wait(timeout: .now() + 0.1), .timedOut)
|
||||
|
||||
releaseLock.signal()
|
||||
XCTAssertEqual(exportFinished.wait(timeout: .now() + 5), .success)
|
||||
XCTAssertEqual(deletionFinished.wait(timeout: .now() + 5), .success)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: record.baseURL.path))
|
||||
|
||||
let imported = try temporaryVMDirectory()
|
||||
try imported.importFromArchive(path: archiveURL.path)
|
||||
XCTAssertTrue(imported.isStackedVM)
|
||||
_ = try imported.diskImageStack().makeAttachment()
|
||||
}
|
||||
}
|
||||
|
||||
func testResolvesPublishedOverlayFromManifestAndCache() throws {
|
||||
let contentStore = try temporaryContentStore()
|
||||
let source = try flatSource()
|
||||
let baseOnly = try temporaryVMDirectory()
|
||||
try source.cloneAsStackedBase(to: baseOnly, generateMAC: false, contentStore: contentStore)
|
||||
|
||||
let contentDigest = try Digest.hash(baseOnly.overlayURL)
|
||||
let temporaryContentURL = try contentStore.temporaryContentURL(for: contentDigest)
|
||||
try FileManager.default.copyItem(at: baseOnly.overlayURL, to: temporaryContentURL)
|
||||
_ = try contentStore.install(temporaryContentURL, contentDigest: contentDigest)
|
||||
|
||||
var manifest = try OCIManifest(fromJSON: Data(contentsOf: baseOnly.manifestURL))
|
||||
var overlay = OCIManifestLayer(
|
||||
mediaType: asifOverlayMediaType,
|
||||
size: 1,
|
||||
digest: "sha256:overlay-transport",
|
||||
uncompressedSize: 1,
|
||||
uncompressedContentDigest: "sha256:overlay-chunk"
|
||||
)
|
||||
overlay.annotations?[diskFileContentDigestAnnotation] = contentDigest
|
||||
overlay.annotations?[diskFileChunkCountAnnotation] = "1"
|
||||
manifest.layers.insert(overlay, at: manifest.layers.count - 1)
|
||||
|
||||
let destination = try temporaryVMDirectory()
|
||||
try FileManager.default.copyItem(at: baseOnly.configURL, to: destination.configURL)
|
||||
try FileManager.default.copyItem(at: baseOnly.nvramURL, to: destination.nvramURL)
|
||||
try manifest.toJSON().write(to: destination.manifestURL)
|
||||
|
||||
let stack = try destination.diskImageStack(contentStore: contentStore)
|
||||
XCTAssertEqual(stack.immutableOverlayURLs, [try contentStore.contentURL(for: contentDigest)])
|
||||
try stack.createWritableOverlay()
|
||||
_ = try stack.makeAttachment()
|
||||
}
|
||||
|
||||
private func flatSource(diskFormat: DiskImageFormat = .raw) throws -> VMDirectory {
|
||||
let vmDir = try temporaryVMDirectory()
|
||||
let config = VMConfig(
|
||||
platform: Linux(),
|
||||
cpuCountMin: 2,
|
||||
memorySizeMin: 512 * 1024 * 1024,
|
||||
diskFormat: diskFormat
|
||||
)
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
|
||||
switch diskFormat {
|
||||
case .raw:
|
||||
_ = try DiskImage(creating: .raw(url: vmDir.diskURL, blockCount: 8))
|
||||
case .asif:
|
||||
_ = try DiskImage(creating: .asif(url: vmDir.diskURL, blockCount: 8, blockSize: .bytes512))
|
||||
}
|
||||
|
||||
let diskChunk = OCIManifestLayer(
|
||||
mediaType: diskV2MediaType,
|
||||
size: 1,
|
||||
digest: "sha256:transport",
|
||||
uncompressedSize: 4096,
|
||||
uncompressedContentDigest: "sha256:chunk"
|
||||
)
|
||||
let manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: 1, digest: "sha256:oci-config"),
|
||||
layers: [
|
||||
OCIManifestLayer(mediaType: configMediaType, size: 1, digest: "sha256:config"),
|
||||
diskChunk,
|
||||
OCIManifestLayer(mediaType: nvramMediaType, size: 1, digest: "sha256:nvram"),
|
||||
]
|
||||
)
|
||||
try manifest.toJSON().write(to: vmDir.manifestURL)
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
private func temporaryContentStore() throws -> ContentStore {
|
||||
let url = try temporaryDirectory()
|
||||
return try ContentStore(baseURL: url)
|
||||
}
|
||||
|
||||
private func temporaryEntries() throws -> [URL] {
|
||||
try FileManager.default.contentsOfDirectory(
|
||||
at: Config().tartTmpDir,
|
||||
includingPropertiesForKeys: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func withTemporaryTartHome(_ body: () throws -> Void) throws {
|
||||
let home = try temporaryDirectory()
|
||||
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
|
||||
setenv("TART_HOME", home.path, 1)
|
||||
defer {
|
||||
if let previousHome {
|
||||
setenv("TART_HOME", previousHome, 1)
|
||||
} else {
|
||||
unsetenv("TART_HOME")
|
||||
}
|
||||
}
|
||||
|
||||
try body()
|
||||
}
|
||||
|
||||
private func temporaryVMDirectory() throws -> VMDirectory {
|
||||
VMDirectory(baseURL: try temporaryDirectory())
|
||||
}
|
||||
|
||||
private func temporaryDirectory() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false)
|
||||
addTeardownBlock {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,154 +0,0 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class VMDirectoryLayoutTests: XCTestCase {
|
||||
func testStandaloneLayoutWithPinnedManifest() throws {
|
||||
let vmDir = try temporaryVMDirectory()
|
||||
|
||||
try touch(vmDir.configURL)
|
||||
try touch(vmDir.nvramURL)
|
||||
try touch(vmDir.diskURL)
|
||||
try touch(vmDir.manifestURL)
|
||||
|
||||
XCTAssertEqual(vmDir.layout, .standalone)
|
||||
XCTAssertTrue(vmDir.initialized)
|
||||
XCTAssertTrue(vmDir.isCachedImage)
|
||||
XCTAssertNoThrow(try vmDir.validateCachedImage(userFriendlyName: "standalone"))
|
||||
}
|
||||
|
||||
func testStackedVMLayout() throws {
|
||||
let vmDir = try temporaryVMDirectory()
|
||||
|
||||
try touch(vmDir.configURL)
|
||||
try touch(vmDir.nvramURL)
|
||||
try touch(vmDir.manifestURL)
|
||||
try touch(vmDir.overlayURL)
|
||||
|
||||
XCTAssertEqual(vmDir.layout, .stackedLocal)
|
||||
XCTAssertTrue(vmDir.initialized)
|
||||
XCTAssertFalse(vmDir.isCachedImage)
|
||||
}
|
||||
|
||||
func testStackedCachedImageLayout() throws {
|
||||
let vmDir = try temporaryVMDirectory()
|
||||
|
||||
try touch(vmDir.configURL)
|
||||
try touch(vmDir.nvramURL)
|
||||
try touch(vmDir.manifestURL)
|
||||
|
||||
XCTAssertEqual(vmDir.layout, .stackedOCIRecord)
|
||||
XCTAssertFalse(vmDir.initialized)
|
||||
XCTAssertTrue(vmDir.isCachedImage)
|
||||
XCTAssertNoThrow(try vmDir.validateCachedImage(userFriendlyName: "stacked"))
|
||||
}
|
||||
|
||||
func testAmbiguousDiskAndOverlayIsNotInitialized() throws {
|
||||
let vmDir = try temporaryVMDirectory()
|
||||
|
||||
try touch(vmDir.configURL)
|
||||
try touch(vmDir.nvramURL)
|
||||
try touch(vmDir.diskURL)
|
||||
try touch(vmDir.manifestURL)
|
||||
try touch(vmDir.overlayURL)
|
||||
|
||||
XCTAssertNil(vmDir.layout)
|
||||
XCTAssertFalse(vmDir.initialized)
|
||||
XCTAssertFalse(vmDir.isCachedImage)
|
||||
}
|
||||
|
||||
func testStackedVMAccountingUsesOverlay() throws {
|
||||
let vmDir = try temporaryVMDirectory()
|
||||
|
||||
try Data("config".utf8).write(to: vmDir.configURL)
|
||||
try Data("nvram".utf8).write(to: vmDir.nvramURL)
|
||||
try Data("overlay".utf8).write(to: vmDir.overlayURL)
|
||||
try stackedManifest(blockSize: 512, blockCount: 8).toJSON().write(to: vmDir.manifestURL)
|
||||
|
||||
XCTAssertEqual(
|
||||
try vmDir.sizeBytes(),
|
||||
try vmDir.configURL.sizeBytes() + vmDir.overlayURL.sizeBytes() + vmDir.nvramURL.sizeBytes()
|
||||
)
|
||||
XCTAssertEqual(
|
||||
try vmDir.allocatedSizeBytes(),
|
||||
try vmDir.configURL.allocatedSizeBytes() + vmDir.overlayURL.allocatedSizeBytes() + vmDir.nvramURL.allocatedSizeBytes()
|
||||
)
|
||||
}
|
||||
|
||||
func testStackedCachedImageAccountingUsesManifestBlockLayout() throws {
|
||||
let vmDir = try temporaryVMDirectory()
|
||||
|
||||
try Data("config".utf8).write(to: vmDir.configURL)
|
||||
try Data("nvram".utf8).write(to: vmDir.nvramURL)
|
||||
try stackedManifest(blockSize: 512, blockCount: 8).toJSON().write(to: vmDir.manifestURL)
|
||||
|
||||
XCTAssertEqual(
|
||||
try vmDir.sizeBytes(),
|
||||
try vmDir.configURL.sizeBytes() + vmDir.nvramURL.sizeBytes()
|
||||
)
|
||||
XCTAssertEqual(
|
||||
try vmDir.allocatedSizeBytes(),
|
||||
try vmDir.configURL.allocatedSizeBytes() + vmDir.nvramURL.allocatedSizeBytes()
|
||||
)
|
||||
XCTAssertEqual(try vmDir.diskSizeBytes(), 4096)
|
||||
}
|
||||
|
||||
func testStackedArchiveRequiresMacOS27() throws {
|
||||
if #available(macOS 27.0, *) {
|
||||
throw XCTSkip("macOS 26 compatibility test")
|
||||
}
|
||||
|
||||
let vmDir = try temporaryVMDirectory()
|
||||
try Data("config".utf8).write(to: vmDir.configURL)
|
||||
try Data("nvram".utf8).write(to: vmDir.nvramURL)
|
||||
try Data("overlay".utf8).write(to: vmDir.overlayURL)
|
||||
try stackedManifest(blockSize: 512, blockCount: 8).toJSON().write(to: vmDir.manifestURL)
|
||||
let archiveURL = try temporaryVMDirectory().baseURL.appendingPathComponent("stacked.tvm")
|
||||
XCTAssertThrowsError(try vmDir.exportToArchive(path: archiveURL.path)) { error in
|
||||
guard case DiskImageStackError.unavailable = error else {
|
||||
return XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: archiveURL.path))
|
||||
}
|
||||
|
||||
private func temporaryVMDirectory() throws -> VMDirectory {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false)
|
||||
addTeardownBlock {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
return VMDirectory(baseURL: url)
|
||||
}
|
||||
|
||||
private func touch(_ url: URL) throws {
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: url.path, contents: Data()))
|
||||
}
|
||||
|
||||
private func stackedManifest(blockSize: UInt64, blockCount: UInt64) -> OCIManifest {
|
||||
var disk = OCIManifestLayer(
|
||||
mediaType: diskV2MediaType,
|
||||
size: 1,
|
||||
digest: "sha256:transport",
|
||||
uncompressedSize: blockSize * blockCount,
|
||||
uncompressedContentDigest: "sha256:chunk"
|
||||
)
|
||||
disk.annotations?[diskFileContentDigestAnnotation] = "sha256:base"
|
||||
|
||||
var manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: 1, digest: "sha256:oci-config"),
|
||||
layers: [
|
||||
OCIManifestLayer(mediaType: configMediaType, size: 1, digest: "sha256:config"),
|
||||
disk,
|
||||
OCIManifestLayer(mediaType: nvramMediaType, size: 1, digest: "sha256:nvram"),
|
||||
]
|
||||
)
|
||||
manifest.annotations = [
|
||||
uncompressedDiskSizeAnnotation: String(blockSize * blockCount),
|
||||
diskBlockSizeAnnotation: String(blockSize),
|
||||
]
|
||||
|
||||
return manifest
|
||||
}
|
||||
}
|
||||
@@ -1,974 +0,0 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
#if canImport(DiskImageKit)
|
||||
import DiskImageKit
|
||||
#endif
|
||||
|
||||
final class VMStorageOCITests: XCTestCase {
|
||||
func testPopulateStandalonePushedImageCachesDiskAndManifest() throws {
|
||||
try withTemporaryTartHome {
|
||||
let source = try standaloneSource(diskData: Data("disk".utf8))
|
||||
let manifest = try flatManifest()
|
||||
let name = try digestName(for: manifest)
|
||||
let storage = try VMStorageOCI()
|
||||
|
||||
try storage.populate(name, from: source, manifest: manifest)
|
||||
|
||||
let cached = try storage.open(name)
|
||||
XCTAssertTrue(cached.isStandalone)
|
||||
XCTAssertEqual(try Data(contentsOf: cached.diskURL), Data("disk".utf8))
|
||||
XCTAssertEqual(try OCIManifest(fromJSON: Data(contentsOf: cached.manifestURL)), manifest)
|
||||
}
|
||||
}
|
||||
|
||||
func testStackedCloneRequiresManifestForLegacyStandaloneCachedImage() throws {
|
||||
try withTemporaryTartHome {
|
||||
let manifest = try flatManifest()
|
||||
let name = try digestName(for: manifest)
|
||||
let storage = try VMStorageOCI()
|
||||
let record = try storage.create(name)
|
||||
try config().save(toURL: record.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: record.nvramURL.path, contents: Data()))
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: record.diskURL.path, contents: Data()))
|
||||
|
||||
XCTAssertTrue(try storage.hasUsableCachedImageForClone(name))
|
||||
XCTAssertFalse(try storage.hasUsableCachedImageForClone(name, requireManifest: true))
|
||||
XCTAssertTrue(try storage.hasCompleteCachedImage(name, manifest: manifest))
|
||||
XCTAssertFalse(try storage.hasCompleteCachedImage(name, manifest: manifest, requireManifest: true))
|
||||
}
|
||||
}
|
||||
|
||||
func testCloneCacheCheckRejectsMissingOrWrongSizedStackedContent() throws {
|
||||
try withTemporaryTartHome {
|
||||
let baseData = Data("base".utf8)
|
||||
let overlayData = Data("overlay".utf8)
|
||||
let baseDigest = Digest.hash(baseData)
|
||||
let overlayDigest = Digest.hash(overlayData)
|
||||
let manifest = try stackedManifest(
|
||||
baseContentDigest: baseDigest,
|
||||
overlayContentDigest: overlayDigest,
|
||||
baseUncompressedSize: UInt64(baseData.count),
|
||||
overlayUncompressedSize: UInt64(overlayData.count)
|
||||
)
|
||||
let name = try digestName(for: manifest)
|
||||
let storage = try VMStorageOCI()
|
||||
let record = try storage.create(name)
|
||||
try config().save(toURL: record.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: record.nvramURL.path, contents: Data()))
|
||||
try manifest.toJSON().write(to: record.manifestURL)
|
||||
|
||||
XCTAssertFalse(try storage.hasUsableCachedImageForClone(name))
|
||||
|
||||
let contentStore = try ContentStore()
|
||||
try installContent(baseData, contentDigest: baseDigest, into: contentStore)
|
||||
try installContent(overlayData, contentDigest: overlayDigest, into: contentStore)
|
||||
XCTAssertTrue(try storage.hasUsableCachedImageForClone(name))
|
||||
|
||||
try Data("bad".utf8).write(to: try contentStore.contentURL(for: overlayDigest))
|
||||
XCTAssertFalse(try storage.hasUsableCachedImageForClone(name))
|
||||
}
|
||||
}
|
||||
|
||||
func testListIncludesStackedCachedImage() throws {
|
||||
try withTemporaryTartHome {
|
||||
let manifest = try stackedManifest()
|
||||
let name = try digestName(for: manifest)
|
||||
let storage = try VMStorageOCI()
|
||||
let record = try storage.create(name)
|
||||
try config().save(toURL: record.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: record.nvramURL.path, contents: Data()))
|
||||
try manifest.toJSON().write(to: record.manifestURL)
|
||||
|
||||
XCTAssertTrue(try storage.list().contains { $0.0 == name.description })
|
||||
XCTAssertEqual(try record.diskSizeBytes(), 4096)
|
||||
XCTAssertNoThrow(try record.allocatedSizeBytes())
|
||||
}
|
||||
}
|
||||
|
||||
func testStackedCacheHitRequiresExpectedContentSizes() throws {
|
||||
try withTemporaryTartHome {
|
||||
let baseData = Data(repeating: 0x41, count: 10)
|
||||
let overlayData = Data(repeating: 0x42, count: 20)
|
||||
let baseDigest = Digest.hash(baseData)
|
||||
let overlayDigest = Digest.hash(overlayData)
|
||||
let manifest = try stackedManifest(
|
||||
baseContentDigest: baseDigest,
|
||||
overlayContentDigest: overlayDigest,
|
||||
baseUncompressedSize: 10,
|
||||
overlayUncompressedSize: 20
|
||||
)
|
||||
let name = try digestName(for: manifest)
|
||||
let storage = try VMStorageOCI()
|
||||
let record = try storage.create(name)
|
||||
try config().save(toURL: record.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: record.nvramURL.path, contents: Data()))
|
||||
try manifest.toJSON().write(to: record.manifestURL)
|
||||
|
||||
XCTAssertFalse(try storage.hasCompleteCachedImage(name, manifest: manifest))
|
||||
XCTAssertEqual(try storage.requiredDiskStorageBytes(for: manifest), 30)
|
||||
|
||||
let contentStore = try ContentStore()
|
||||
try installContent(baseData, contentDigest: baseDigest, into: contentStore)
|
||||
XCTAssertFalse(try storage.hasCompleteCachedImage(name, manifest: manifest))
|
||||
XCTAssertEqual(try storage.requiredDiskStorageBytes(for: manifest), 20)
|
||||
|
||||
try installContent(overlayData, contentDigest: overlayDigest, into: contentStore)
|
||||
XCTAssertTrue(try storage.hasCompleteCachedImage(name, manifest: manifest))
|
||||
XCTAssertEqual(try storage.requiredDiskStorageBytes(for: manifest), 0)
|
||||
|
||||
let overlayURL = try contentStore.contentURL(for: overlayDigest)
|
||||
try Data("corrupt".utf8).write(to: overlayURL)
|
||||
XCTAssertFalse(try storage.hasCompleteCachedImage(name, manifest: manifest))
|
||||
XCTAssertEqual(try storage.requiredDiskStorageBytes(for: manifest), 20)
|
||||
}
|
||||
}
|
||||
|
||||
func testStackedPullReusesPreviouslyPulledStandaloneDisk() throws {
|
||||
try withTemporaryTartHome {
|
||||
let diskData = Data([0])
|
||||
let contentDigest = Digest.hash(diskData)
|
||||
let flatManifest = try flatManifest()
|
||||
let flatName = try digestName(for: flatManifest)
|
||||
let storage = try VMStorageOCI()
|
||||
let flatRecord = try storage.create(flatName)
|
||||
try config().save(toURL: flatRecord.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: flatRecord.nvramURL.path, contents: Data()))
|
||||
try diskData.write(to: flatRecord.diskURL)
|
||||
try flatManifest.toJSON().write(to: flatRecord.manifestURL)
|
||||
|
||||
let stackedManifest = try stackedManifest(baseContentDigest: contentDigest)
|
||||
XCTAssertNil(try ContentStore().existingContentURL(for: contentDigest))
|
||||
|
||||
try storage.reuseStandaloneDiskForStackedBaseIfPossible(stackedManifest)
|
||||
|
||||
let reusedURL = try XCTUnwrap(try ContentStore().existingContentURL(for: contentDigest))
|
||||
XCTAssertEqual(try Data(contentsOf: reusedURL), diskData)
|
||||
}
|
||||
}
|
||||
|
||||
func testStackedPullDoesNotRehashInstalledBaseBeforeReuse() throws {
|
||||
try withTemporaryTartHome {
|
||||
let contentDigest = Digest.hash(Data("base".utf8))
|
||||
let manifest = try stackedManifest(baseContentDigest: contentDigest)
|
||||
let contentURL = try ContentStore().contentURL(for: contentDigest)
|
||||
|
||||
// Hashing this path would throw. Once an entry is published, this
|
||||
// fast path must trust its presence and let normal pull validation
|
||||
// repair unusable content later.
|
||||
try FileManager.default.createDirectory(at: contentURL, withIntermediateDirectories: false)
|
||||
|
||||
XCTAssertNoThrow(try VMStorageOCI().reuseStandaloneDiskForStackedBaseIfPossible(manifest))
|
||||
}
|
||||
}
|
||||
|
||||
func testNewTagDoesNotValidateCachedStackBeforeLock() throws {
|
||||
try withTemporaryTartHome {
|
||||
let baseDigest = Digest.hash(Data("base".utf8))
|
||||
let overlayDigest = Digest.hash(Data("overlay".utf8))
|
||||
let manifest = try stackedManifest(
|
||||
baseContentDigest: baseDigest,
|
||||
overlayContentDigest: overlayDigest
|
||||
)
|
||||
let digestName = try digestName(for: manifest)
|
||||
let tagName = RemoteName(
|
||||
host: digestName.host,
|
||||
namespace: digestName.namespace,
|
||||
reference: Reference(tag: "latest")
|
||||
)
|
||||
let storage = try VMStorageOCI()
|
||||
let record = try storage.create(digestName)
|
||||
try config().save(toURL: record.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: record.nvramURL.path, contents: Data()))
|
||||
try manifest.toJSON().write(to: record.manifestURL)
|
||||
|
||||
// Hashing this directory as a disk file throws. A new tag must skip
|
||||
// validation until after it has taken the host lock.
|
||||
let contentURL = try ContentStore().contentURL(for: baseDigest)
|
||||
try FileManager.default.createDirectory(at: contentURL, withIntermediateDirectories: false)
|
||||
XCTAssertFalse(try storage.hasCompleteLinkedImage(tagName, digestName: digestName, manifest: manifest))
|
||||
}
|
||||
}
|
||||
|
||||
func testStandaloneLayerCacheIgnoresStackedCachedImages() async throws {
|
||||
try await withTemporaryTartHome {
|
||||
var targetManifest = try flatManifest()
|
||||
var stackedCandidateManifest = try stackedManifest()
|
||||
let sharedDiskSize = 2 * 1024 * 1024 * 1024
|
||||
targetManifest.layers[1].size = sharedDiskSize
|
||||
stackedCandidateManifest.layers[1] = targetManifest.layers[1]
|
||||
|
||||
let candidateName = try digestName(for: stackedCandidateManifest)
|
||||
let storage = try VMStorageOCI()
|
||||
let candidate = try storage.create(candidateName)
|
||||
try config().save(toURL: candidate.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: candidate.nvramURL.path, contents: Data()))
|
||||
try stackedCandidateManifest.toJSON().write(to: candidate.manifestURL)
|
||||
|
||||
let targetName = RemoteName(
|
||||
host: "example.com",
|
||||
namespace: "org/target",
|
||||
reference: Reference(digest: try targetManifest.digest())
|
||||
)
|
||||
let registry = try Registry(host: targetName.host, namespace: targetName.namespace)
|
||||
|
||||
let layerCache = try await storage.chooseLocalLayerCache(targetName, targetManifest, registry)
|
||||
XCTAssertNil(layerCache)
|
||||
}
|
||||
}
|
||||
|
||||
func testGCPrunesOnlyUnreferencedContent() throws {
|
||||
try withTemporaryTartHome {
|
||||
let contentStore = try ContentStore()
|
||||
let referenced = try installContent(Data("referenced".utf8), into: contentStore)
|
||||
let unreferenced = try installContent(Data("unreferenced".utf8), into: contentStore)
|
||||
|
||||
let stacked = try VMStorageLocal().create("stacked")
|
||||
try config().save(toURL: stacked.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: stacked.nvramURL.path, contents: Data()))
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: stacked.overlayURL.path, contents: Data()))
|
||||
try pinnedBaseManifest(contentDigest: referenced.digest).toJSON().write(to: stacked.manifestURL)
|
||||
|
||||
try VMStorageOCI().gc()
|
||||
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: referenced.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: unreferenced.url.path))
|
||||
}
|
||||
}
|
||||
|
||||
func testGCPrunesContentWithoutOCIStorageDirectory() throws {
|
||||
try withTemporaryTartHome {
|
||||
let contentStore = try ContentStore()
|
||||
let unreferenced = try installContent(Data("unreferenced".utf8), into: contentStore)
|
||||
let storage = try VMStorageOCI()
|
||||
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: storage.baseURL.path))
|
||||
|
||||
try storage.gc()
|
||||
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: unreferenced.url.path))
|
||||
}
|
||||
}
|
||||
|
||||
func testGCDoesNotPruneContentReferencedByInProgressManifest() throws {
|
||||
try withTemporaryTartHome {
|
||||
let contentStore = try ContentStore()
|
||||
let referenced = try installContent(Data("in-progress".utf8), into: contentStore)
|
||||
let temporaryVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Pull and clone publish the manifest before config, NVRAM, or a
|
||||
// writable overlay necessarily exist.
|
||||
try pinnedBaseManifest(contentDigest: referenced.digest).toJSON().write(to: temporaryVMDir.manifestURL)
|
||||
|
||||
try VMStorageOCI().gc()
|
||||
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: referenced.url.path))
|
||||
}
|
||||
}
|
||||
|
||||
func testStalePrunableDoesNotDeleteNewlyReferencedContent() throws {
|
||||
try withTemporaryTartHome {
|
||||
let contentStore = try ContentStore()
|
||||
let content = try installContent(Data("new-reference".utf8), into: contentStore)
|
||||
let storage = try VMStorageOCI()
|
||||
let candidate = try XCTUnwrap(storage.prunables().first {
|
||||
$0.url.resolvingSymlinksInPath() == content.url.resolvingSymlinksInPath()
|
||||
})
|
||||
|
||||
// Simulate a clone or pull publishing its manifest after prune built
|
||||
// the candidate list but before deletion starts.
|
||||
let temporaryVMDir = try VMDirectory.temporary()
|
||||
try contentStore.withPruneLock {
|
||||
try pinnedBaseManifest(contentDigest: content.digest).toJSON().write(to: temporaryVMDir.manifestURL)
|
||||
}
|
||||
|
||||
try candidate.delete()
|
||||
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: content.url.path))
|
||||
}
|
||||
}
|
||||
|
||||
func testVMDirectoryDeletionOfStackedOCIRecordWaitsForPruneLock() throws {
|
||||
try withTemporaryTartHome {
|
||||
let storage = try VMStorageOCI()
|
||||
let record = try createRecord(for: stackedManifest(), in: storage)
|
||||
|
||||
try assertDeletionWaitsForPruneLock(record: record) {
|
||||
try record.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testStorageDeletionOfStackedOCIRecordWaitsForPruneLock() throws {
|
||||
try withTemporaryTartHome {
|
||||
let storage = try VMStorageOCI()
|
||||
let manifest = try stackedManifest()
|
||||
let name = try digestName(for: manifest)
|
||||
let record = try createRecord(for: manifest, in: storage)
|
||||
|
||||
try assertDeletionWaitsForPruneLock(record: record) {
|
||||
try storage.delete(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testStorageDeletionOfIncompleteManifestRecordWaitsForPruneLock() throws {
|
||||
try withTemporaryTartHome {
|
||||
let storage = try VMStorageOCI()
|
||||
let name = RemoteName(
|
||||
host: "example.com",
|
||||
namespace: "org/image",
|
||||
reference: Reference(digest: "sha256:incomplete")
|
||||
)
|
||||
let record = try storage.create(name)
|
||||
try Data("{}".utf8).write(to: record.manifestURL)
|
||||
|
||||
try assertDeletionWaitsForPruneLock(record: record) {
|
||||
try storage.delete(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testVMDirectoryDeletionOfUnpublishedRecordWaitsForPruneLock() throws {
|
||||
try withTemporaryTartHome {
|
||||
let storage = try VMStorageOCI()
|
||||
let record = try storage.create(RemoteName(
|
||||
host: "example.com",
|
||||
namespace: "org/image",
|
||||
reference: Reference(digest: "sha256:unpublished")
|
||||
))
|
||||
|
||||
try assertDeletionWaitsForPruneLock(record: record) {
|
||||
try record.removeFromDisk()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testTagReplacementWaitsForPruneLock() throws {
|
||||
try withTemporaryTartHome {
|
||||
let storage = try VMStorageOCI()
|
||||
let firstManifest = try stackedManifest(baseContentDigest: "sha256:first")
|
||||
let secondManifest = try stackedManifest(baseContentDigest: "sha256:second")
|
||||
let firstName = try digestName(for: firstManifest)
|
||||
let secondName = try digestName(for: secondManifest)
|
||||
_ = try createRecord(for: firstManifest, in: storage)
|
||||
_ = try createRecord(for: secondManifest, in: storage)
|
||||
let tagName = RemoteName(
|
||||
host: secondName.host,
|
||||
namespace: secondName.namespace,
|
||||
reference: Reference(tag: "latest")
|
||||
)
|
||||
|
||||
let contentStore = try ContentStore()
|
||||
let lockHeld = DispatchSemaphore(value: 0)
|
||||
let releaseLock = DispatchSemaphore(value: 0)
|
||||
let replacementStarted = DispatchSemaphore(value: 0)
|
||||
let replacementFinished = DispatchSemaphore(value: 0)
|
||||
|
||||
DispatchQueue.global().async {
|
||||
try? contentStore.withPruneLock {
|
||||
lockHeld.signal()
|
||||
releaseLock.wait()
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(lockHeld.wait(timeout: .now() + 1), .success)
|
||||
|
||||
DispatchQueue.global().async {
|
||||
replacementStarted.signal()
|
||||
try? storage.link(from: tagName, to: secondName)
|
||||
replacementFinished.signal()
|
||||
}
|
||||
XCTAssertEqual(replacementStarted.wait(timeout: .now() + 1), .success)
|
||||
XCTAssertEqual(replacementFinished.wait(timeout: .now() + 0.1), .timedOut)
|
||||
|
||||
releaseLock.signal()
|
||||
XCTAssertEqual(replacementFinished.wait(timeout: .now() + 1), .success)
|
||||
XCTAssertTrue(storage.linked(from: tagName, to: secondName))
|
||||
XCTAssertFalse(storage.linked(from: tagName, to: firstName))
|
||||
}
|
||||
}
|
||||
|
||||
func testGCDeletionOfStackedOCIRecordWaitsForPruneLock() throws {
|
||||
try withTemporaryTartHome {
|
||||
let storage = try VMStorageOCI()
|
||||
let record = try createRecord(for: stackedManifest(), in: storage)
|
||||
|
||||
try assertDeletionWaitsForPruneLock(record: record) {
|
||||
try storage.gc()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testTemporaryManifestGCWaitsForPruneLock() throws {
|
||||
try withTemporaryTartHome {
|
||||
let temporaryVMDir = try VMDirectory.temporary()
|
||||
try stackedManifest().toJSON().write(to: temporaryVMDir.manifestURL)
|
||||
|
||||
try assertDeletionWaitsForPruneLock(record: temporaryVMDir) {
|
||||
try Config().gc()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testLockedTemporaryDirectorySurvivesGarbageCollection() throws {
|
||||
try withTemporaryTartHome {
|
||||
let temporaryVMDir = try VMDirectory.temporary()
|
||||
let lock = try FileLock(lockURL: temporaryVMDir.baseURL)
|
||||
try lock.lock()
|
||||
|
||||
try Config().gc()
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: temporaryVMDir.baseURL.path))
|
||||
|
||||
try lock.unlock()
|
||||
try Config().gc()
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: temporaryVMDir.baseURL.path))
|
||||
}
|
||||
}
|
||||
|
||||
func testMovingStackedOCIRecordWaitsForPruneLock() throws {
|
||||
try withTemporaryTartHome {
|
||||
let storage = try VMStorageOCI()
|
||||
let source = try temporaryVMDirectory()
|
||||
let manifest = try stackedManifest()
|
||||
let name = try digestName(for: manifest)
|
||||
try config().save(toURL: source.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: source.nvramURL.path, contents: Data()))
|
||||
try manifest.toJSON().write(to: source.manifestURL)
|
||||
|
||||
let contentStore = try ContentStore()
|
||||
let lockHeld = DispatchSemaphore(value: 0)
|
||||
let releaseLock = DispatchSemaphore(value: 0)
|
||||
let moveStarted = DispatchSemaphore(value: 0)
|
||||
let moveFinished = DispatchSemaphore(value: 0)
|
||||
|
||||
DispatchQueue.global().async {
|
||||
try? contentStore.withPruneLock {
|
||||
lockHeld.signal()
|
||||
releaseLock.wait()
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(lockHeld.wait(timeout: .now() + 1), .success)
|
||||
|
||||
DispatchQueue.global().async {
|
||||
moveStarted.signal()
|
||||
try? storage.move(name, from: source)
|
||||
moveFinished.signal()
|
||||
}
|
||||
XCTAssertEqual(moveStarted.wait(timeout: .now() + 1), .success)
|
||||
XCTAssertEqual(moveFinished.wait(timeout: .now() + 0.1), .timedOut)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: source.baseURL.path))
|
||||
|
||||
releaseLock.signal()
|
||||
XCTAssertEqual(moveFinished.wait(timeout: .now() + 1), .success)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: source.baseURL.path))
|
||||
XCTAssertTrue(try storage.open(name).isStackedCachedImage)
|
||||
}
|
||||
}
|
||||
|
||||
func testPruningLastStackedOCIRecordReclaimsItsContent() throws {
|
||||
try withTemporaryTartHome {
|
||||
let contentStore = try ContentStore()
|
||||
let baseContent = try installContent(Data("record-only-base".utf8), into: contentStore)
|
||||
let overlayContent = try installContent(Data("record-only-overlay".utf8), into: contentStore)
|
||||
let manifest = try stackedManifest(
|
||||
baseContentDigest: baseContent.digest,
|
||||
overlayContentDigest: overlayContent.digest,
|
||||
baseUncompressedSize: UInt64(try baseContent.url.sizeBytes()),
|
||||
overlayUncompressedSize: UInt64(try overlayContent.url.sizeBytes())
|
||||
)
|
||||
let name = try digestName(for: manifest)
|
||||
let storage = try VMStorageOCI()
|
||||
let record = try storage.create(name)
|
||||
try config().save(toURL: record.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: record.nvramURL.path, contents: Data()))
|
||||
try manifest.toJSON().write(to: record.manifestURL)
|
||||
|
||||
let candidate = try XCTUnwrap(storage.prunables().first {
|
||||
$0.url.lastPathComponent == record.url.lastPathComponent
|
||||
})
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
try candidate.allocatedSizeBytes(),
|
||||
try baseContent.url.allocatedSizeBytes() + overlayContent.url.allocatedSizeBytes()
|
||||
)
|
||||
|
||||
// The record itself fits in this budget, so pruning only succeeds if it
|
||||
// accounts for the immutable content released with the final reference.
|
||||
try Prune.pruneSpaceBudget(
|
||||
prunableStorages: [storage],
|
||||
spaceBudgetBytes: UInt64(try record.allocatedSizeBytes())
|
||||
)
|
||||
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: record.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: baseContent.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: overlayContent.url.path))
|
||||
}
|
||||
}
|
||||
|
||||
func testMalformedStackedOCIRecordDoesNotBlockPruning() throws {
|
||||
try withTemporaryTartHome {
|
||||
let contentStore = try ContentStore()
|
||||
let baseContent = try installContent(Data("valid-base".utf8), into: contentStore)
|
||||
let overlayContent = try installContent(Data("valid-overlay".utf8), into: contentStore)
|
||||
let storage = try VMStorageOCI()
|
||||
let validRecord = try createRecord(for: stackedManifest(
|
||||
baseContentDigest: baseContent.digest,
|
||||
overlayContentDigest: overlayContent.digest,
|
||||
baseUncompressedSize: UInt64(try baseContent.url.sizeBytes()),
|
||||
overlayUncompressedSize: UInt64(try overlayContent.url.sizeBytes())
|
||||
), in: storage)
|
||||
|
||||
let malformedRecord = try storage.create(RemoteName(
|
||||
host: "example.com",
|
||||
namespace: "org/image",
|
||||
reference: Reference(digest: "sha256:malformed")
|
||||
))
|
||||
try config().save(toURL: malformedRecord.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: malformedRecord.nvramURL.path, contents: Data()))
|
||||
try Data("{".utf8).write(to: malformedRecord.manifestURL)
|
||||
|
||||
XCTAssertNoThrow(try storage.prunables())
|
||||
try Prune.pruneSpaceBudget(prunableStorages: [storage], spaceBudgetBytes: 0)
|
||||
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: validRecord.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: malformedRecord.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: baseContent.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: overlayContent.url.path))
|
||||
}
|
||||
}
|
||||
|
||||
func testPruningOneStackedOCIRecordPreservesSharedContent() throws {
|
||||
try withTemporaryTartHome {
|
||||
let contentStore = try ContentStore()
|
||||
let baseContent = try installContent(Data("shared-base".utf8), into: contentStore)
|
||||
let firstOverlay = try installContent(Data("first-overlay".utf8), into: contentStore)
|
||||
let secondOverlay = try installContent(Data("second-overlay".utf8), into: contentStore)
|
||||
let storage = try VMStorageOCI()
|
||||
|
||||
let firstManifest = try stackedManifest(
|
||||
baseContentDigest: baseContent.digest,
|
||||
overlayContentDigest: firstOverlay.digest,
|
||||
baseUncompressedSize: UInt64(try baseContent.url.sizeBytes()),
|
||||
overlayUncompressedSize: UInt64(try firstOverlay.url.sizeBytes())
|
||||
)
|
||||
let secondManifest = try stackedManifest(
|
||||
baseContentDigest: baseContent.digest,
|
||||
overlayContentDigest: secondOverlay.digest,
|
||||
baseUncompressedSize: UInt64(try baseContent.url.sizeBytes()),
|
||||
overlayUncompressedSize: UInt64(try secondOverlay.url.sizeBytes())
|
||||
)
|
||||
let firstRecord = try createRecord(for: firstManifest, in: storage)
|
||||
let secondRecord = try createRecord(for: secondManifest, in: storage)
|
||||
|
||||
let firstCandidate = try XCTUnwrap(storage.prunables().first {
|
||||
$0.url.lastPathComponent == firstRecord.url.lastPathComponent
|
||||
})
|
||||
try firstCandidate.delete()
|
||||
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: baseContent.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: firstOverlay.url.path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: secondOverlay.url.path))
|
||||
|
||||
let secondCandidate = try XCTUnwrap(storage.prunables().first {
|
||||
$0.url.lastPathComponent == secondRecord.url.lastPathComponent
|
||||
})
|
||||
try secondCandidate.delete()
|
||||
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: baseContent.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: secondOverlay.url.path))
|
||||
}
|
||||
}
|
||||
|
||||
func testSpaceBudgetRecomputesSharedContentAfterOwnerDeletion() throws {
|
||||
try withTemporaryTartHome {
|
||||
let contentStore = try ContentStore()
|
||||
let baseContent = try installContent(Data("shared-base".utf8), into: contentStore)
|
||||
let firstOverlay = try installContent(Data("first-overlay".utf8), into: contentStore)
|
||||
let secondOverlay = try installContent(Data("second-overlay".utf8), into: contentStore)
|
||||
let storage = try VMStorageOCI()
|
||||
|
||||
let firstManifest = try stackedManifest(
|
||||
baseContentDigest: baseContent.digest,
|
||||
overlayContentDigest: firstOverlay.digest,
|
||||
baseUncompressedSize: UInt64(try baseContent.url.sizeBytes()),
|
||||
overlayUncompressedSize: UInt64(try firstOverlay.url.sizeBytes())
|
||||
)
|
||||
let secondManifest = try stackedManifest(
|
||||
baseContentDigest: baseContent.digest,
|
||||
overlayContentDigest: secondOverlay.digest,
|
||||
baseUncompressedSize: UInt64(try baseContent.url.sizeBytes()),
|
||||
overlayUncompressedSize: UInt64(try secondOverlay.url.sizeBytes())
|
||||
)
|
||||
let olderRecord = try createRecord(for: firstManifest, in: storage)
|
||||
let newerRecord = try createRecord(for: secondManifest, in: storage)
|
||||
try olderRecord.url.updateAccessDate(Date(timeIntervalSince1970: 1))
|
||||
try newerRecord.url.updateAccessDate(Date(timeIntervalSince1970: 2))
|
||||
|
||||
let olderCandidate = try XCTUnwrap(storage.prunables().first {
|
||||
$0.url.lastPathComponent == olderRecord.url.lastPathComponent
|
||||
})
|
||||
let budget = UInt64(try olderCandidate.allocatedSizeBytes())
|
||||
|
||||
// On the first pass the newer record owns the shared base and is
|
||||
// selected for deletion, while the older record fits this budget.
|
||||
// Recomputing must then charge the surviving record for the base and
|
||||
// prune it too.
|
||||
try Prune.pruneSpaceBudget(
|
||||
prunableStorages: [storage],
|
||||
spaceBudgetBytes: budget
|
||||
)
|
||||
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: olderRecord.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: newerRecord.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: baseContent.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: firstOverlay.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: secondOverlay.url.path))
|
||||
}
|
||||
}
|
||||
|
||||
func testSpaceBudgetRecomputesBeforeDeletingAnotherCandidate() throws {
|
||||
try withTemporaryTartHome {
|
||||
let contentStore = try ContentStore()
|
||||
let sharedBase = try installContent(Data(repeating: 0x41, count: 128 * 1024), into: contentStore)
|
||||
let newestOverlay = try installContent(Data("newest-overlay".utf8), into: contentStore)
|
||||
let middleOverlay = try installContent(Data("middle-overlay".utf8), into: contentStore)
|
||||
let retainedBase = try installContent(Data(repeating: 0x42, count: 32 * 1024), into: contentStore)
|
||||
let retainedOverlay = try installContent(Data("retained-overlay".utf8), into: contentStore)
|
||||
let storage = try VMStorageOCI()
|
||||
|
||||
let newest = try createRecord(for: stackedManifest(
|
||||
baseContentDigest: sharedBase.digest,
|
||||
overlayContentDigest: newestOverlay.digest,
|
||||
baseUncompressedSize: UInt64(try sharedBase.url.sizeBytes()),
|
||||
overlayUncompressedSize: UInt64(try newestOverlay.url.sizeBytes())
|
||||
), in: storage)
|
||||
let middle = try createRecord(for: stackedManifest(
|
||||
baseContentDigest: sharedBase.digest,
|
||||
overlayContentDigest: middleOverlay.digest,
|
||||
baseUncompressedSize: UInt64(try sharedBase.url.sizeBytes()),
|
||||
overlayUncompressedSize: UInt64(try middleOverlay.url.sizeBytes())
|
||||
), in: storage)
|
||||
let retained = try createRecord(for: stackedManifest(
|
||||
baseContentDigest: retainedBase.digest,
|
||||
overlayContentDigest: retainedOverlay.digest,
|
||||
baseUncompressedSize: UInt64(try retainedBase.url.sizeBytes()),
|
||||
overlayUncompressedSize: UInt64(try retainedOverlay.url.sizeBytes())
|
||||
), in: storage)
|
||||
try newest.url.updateAccessDate(Date(timeIntervalSince1970: 3))
|
||||
try middle.url.updateAccessDate(Date(timeIntervalSince1970: 2))
|
||||
try retained.url.updateAccessDate(Date(timeIntervalSince1970: 1))
|
||||
|
||||
let retainedCandidate = try XCTUnwrap(storage.prunables().first {
|
||||
$0.url.lastPathComponent == retained.url.lastPathComponent
|
||||
})
|
||||
|
||||
// Initially the newest record owns the shared base and is too large.
|
||||
// The middle record appears small enough to retain, making the oldest
|
||||
// unrelated record look like a second deletion candidate. After the
|
||||
// first deletion, ownership moves to the middle record; recomputing
|
||||
// before selecting again must delete it and preserve the unrelated one.
|
||||
try Prune.pruneSpaceBudget(
|
||||
prunableStorages: [storage],
|
||||
spaceBudgetBytes: UInt64(try retainedCandidate.allocatedSizeBytes())
|
||||
)
|
||||
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: newest.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: middle.url.path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: retained.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: sharedBase.url.path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: retainedBase.url.path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: retainedOverlay.url.path))
|
||||
}
|
||||
}
|
||||
|
||||
func testAutomaticReclaimRecomputesSharedContentAfterOwnerDeletion() throws {
|
||||
try withTemporaryTartHome {
|
||||
let contentStore = try ContentStore()
|
||||
let sharedBase = try installContent(Data(repeating: 0x41, count: 128 * 1024), into: contentStore)
|
||||
let initiatorOverlay = try installContent(Data("initiator-overlay".utf8), into: contentStore)
|
||||
let ownerOverlay = try installContent(Data("owner-overlay".utf8), into: contentStore)
|
||||
let unrelatedBase = try installContent(Data("unrelated-base".utf8), into: contentStore)
|
||||
let unrelatedOverlay = try installContent(Data("unrelated-overlay".utf8), into: contentStore)
|
||||
let storage = try VMStorageOCI()
|
||||
|
||||
let initiatorManifest = try stackedManifest(
|
||||
baseContentDigest: sharedBase.digest,
|
||||
overlayContentDigest: initiatorOverlay.digest,
|
||||
baseUncompressedSize: UInt64(try sharedBase.url.sizeBytes()),
|
||||
overlayUncompressedSize: UInt64(try initiatorOverlay.url.sizeBytes())
|
||||
)
|
||||
let ownerManifest = try stackedManifest(
|
||||
baseContentDigest: sharedBase.digest,
|
||||
overlayContentDigest: ownerOverlay.digest,
|
||||
baseUncompressedSize: UInt64(try sharedBase.url.sizeBytes()),
|
||||
overlayUncompressedSize: UInt64(try ownerOverlay.url.sizeBytes())
|
||||
)
|
||||
let unrelatedManifest = try stackedManifest(
|
||||
baseContentDigest: unrelatedBase.digest,
|
||||
overlayContentDigest: unrelatedOverlay.digest,
|
||||
baseUncompressedSize: UInt64(try unrelatedBase.url.sizeBytes()),
|
||||
overlayUncompressedSize: UInt64(try unrelatedOverlay.url.sizeBytes())
|
||||
)
|
||||
let initiator = try createRecord(for: initiatorManifest, in: storage)
|
||||
let owner = try createRecord(for: ownerManifest, in: storage)
|
||||
let unrelated = try createRecord(for: unrelatedManifest, in: storage)
|
||||
try initiator.url.updateAccessDate(Date(timeIntervalSince1970: 1))
|
||||
try owner.url.updateAccessDate(Date(timeIntervalSince1970: 2))
|
||||
try unrelated.url.updateAccessDate(Date(timeIntervalSince1970: 3))
|
||||
|
||||
let sharedBaseSize = UInt64(try sharedBase.url.allocatedSizeBytes())
|
||||
|
||||
// The owner is the first deletable record and is initially charged for
|
||||
// the shared base. Deleting it cannot reclaim that base because the
|
||||
// protected initiator still references it, so reclaim must continue.
|
||||
try Prune.reclaimIfPossible(sharedBaseSize, initiator)
|
||||
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: initiator.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: owner.url.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: unrelated.url.path))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: sharedBase.url.path))
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(DiskImageKit)
|
||||
@available(macOS 27.0, *)
|
||||
func testPopulateStackedPushedImageCachesImmutableTopOverlay() throws {
|
||||
if #unavailable(macOS 27.0) {
|
||||
throw XCTSkip("DiskImageKit tests require macOS 27 or newer")
|
||||
}
|
||||
|
||||
try withTemporaryTartHome {
|
||||
let source = try diskImageSource()
|
||||
let stacked = try temporaryVMDirectory()
|
||||
try source.cloneAsStackedBase(to: stacked, generateMAC: false)
|
||||
|
||||
var manifest = try OCIManifest(fromJSON: Data(contentsOf: stacked.manifestURL))
|
||||
let contentDigest = try Digest.hash(stacked.overlayURL)
|
||||
var overlay = OCIManifestLayer(
|
||||
mediaType: asifOverlayMediaType,
|
||||
size: 1,
|
||||
digest: "sha256:overlay-transport",
|
||||
uncompressedSize: 1,
|
||||
uncompressedContentDigest: "sha256:overlay-chunk"
|
||||
)
|
||||
overlay.annotations?[diskFileContentDigestAnnotation] = contentDigest
|
||||
overlay.annotations?[diskFileChunkCountAnnotation] = "1"
|
||||
manifest.layers.insert(overlay, at: manifest.layers.count - 1)
|
||||
|
||||
let name = try digestName(for: manifest)
|
||||
let storage = try VMStorageOCI()
|
||||
try storage.populate(name, from: stacked, manifest: manifest)
|
||||
|
||||
let cached = try storage.open(name)
|
||||
XCTAssertTrue(cached.isStackedCachedImage)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: cached.overlayURL.path))
|
||||
XCTAssertEqual(try OCIManifest(fromJSON: Data(contentsOf: cached.manifestURL)), manifest)
|
||||
|
||||
let cachedContent = try XCTUnwrap(try ContentStore().existingContentURL(for: contentDigest))
|
||||
XCTAssertEqual(try Digest.hash(cachedContent), contentDigest)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func standaloneSource(diskData: Data) throws -> VMDirectory {
|
||||
let vmDir = try temporaryVMDirectory()
|
||||
try config().save(toURL: vmDir.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
|
||||
try diskData.write(to: vmDir.diskURL)
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
private func createRecord(for manifest: OCIManifest, in storage: VMStorageOCI) throws -> VMDirectory {
|
||||
let record = try storage.create(try digestName(for: manifest))
|
||||
try config().save(toURL: record.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: record.nvramURL.path, contents: Data()))
|
||||
try manifest.toJSON().write(to: record.manifestURL)
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
private func assertDeletionWaitsForPruneLock(
|
||||
record: VMDirectory,
|
||||
deletion: @escaping () throws -> Void
|
||||
) throws {
|
||||
let contentStore = try ContentStore()
|
||||
let lockHeld = DispatchSemaphore(value: 0)
|
||||
let releaseLock = DispatchSemaphore(value: 0)
|
||||
let deletionStarted = DispatchSemaphore(value: 0)
|
||||
let deletionFinished = DispatchSemaphore(value: 0)
|
||||
|
||||
DispatchQueue.global().async {
|
||||
try? contentStore.withPruneLock {
|
||||
lockHeld.signal()
|
||||
releaseLock.wait()
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(lockHeld.wait(timeout: .now() + 1), .success)
|
||||
|
||||
DispatchQueue.global().async {
|
||||
deletionStarted.signal()
|
||||
try? deletion()
|
||||
deletionFinished.signal()
|
||||
}
|
||||
XCTAssertEqual(deletionStarted.wait(timeout: .now() + 1), .success)
|
||||
XCTAssertEqual(deletionFinished.wait(timeout: .now() + 0.1), .timedOut)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: record.baseURL.path))
|
||||
|
||||
releaseLock.signal()
|
||||
XCTAssertEqual(deletionFinished.wait(timeout: .now() + 1), .success)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: record.baseURL.path))
|
||||
}
|
||||
|
||||
#if canImport(DiskImageKit)
|
||||
@available(macOS 27.0, *)
|
||||
private func diskImageSource() throws -> VMDirectory {
|
||||
let vmDir = try temporaryVMDirectory()
|
||||
try config().save(toURL: vmDir.configURL)
|
||||
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
|
||||
_ = try DiskImage(creating: .raw(url: vmDir.diskURL, blockCount: 8))
|
||||
try flatManifest().toJSON().write(to: vmDir.manifestURL)
|
||||
|
||||
return vmDir
|
||||
}
|
||||
#endif
|
||||
|
||||
private func config() -> VMConfig {
|
||||
VMConfig(
|
||||
platform: Linux(),
|
||||
cpuCountMin: 2,
|
||||
memorySizeMin: 512 * 1024 * 1024,
|
||||
diskFormat: .raw
|
||||
)
|
||||
}
|
||||
|
||||
private func flatManifest() throws -> OCIManifest {
|
||||
let disk = OCIManifestLayer(
|
||||
mediaType: diskV2MediaType,
|
||||
size: 1,
|
||||
digest: "sha256:disk-transport",
|
||||
uncompressedSize: 1,
|
||||
uncompressedContentDigest: "sha256:disk-chunk"
|
||||
)
|
||||
|
||||
return OCIManifest(
|
||||
config: OCIManifestConfig(size: 1, digest: "sha256:oci-config"),
|
||||
layers: [
|
||||
OCIManifestLayer(mediaType: configMediaType, size: 1, digest: "sha256:config"),
|
||||
disk,
|
||||
OCIManifestLayer(mediaType: nvramMediaType, size: 1, digest: "sha256:nvram"),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
private func stackedManifest(
|
||||
baseContentDigest: String = "sha256:base",
|
||||
overlayContentDigest: String = "sha256:overlay",
|
||||
baseUncompressedSize: UInt64 = 1,
|
||||
overlayUncompressedSize: UInt64 = 1
|
||||
) throws -> OCIManifest {
|
||||
var manifest = try flatManifest()
|
||||
manifest.annotations?[diskBlockSizeAnnotation] = "512"
|
||||
manifest.annotations?[uncompressedDiskSizeAnnotation] = "4096"
|
||||
manifest.layers[1].annotations?[diskFileContentDigestAnnotation] = baseContentDigest
|
||||
manifest.layers[1].annotations?[uncompressedSizeAnnotation] = String(baseUncompressedSize)
|
||||
var overlay = OCIManifestLayer(
|
||||
mediaType: asifOverlayMediaType,
|
||||
size: 1,
|
||||
digest: "sha256:overlay-transport",
|
||||
uncompressedSize: overlayUncompressedSize,
|
||||
uncompressedContentDigest: "sha256:overlay-chunk"
|
||||
)
|
||||
overlay.annotations?[diskFileContentDigestAnnotation] = overlayContentDigest
|
||||
overlay.annotations?[diskFileChunkCountAnnotation] = "1"
|
||||
manifest.layers.insert(overlay, at: manifest.layers.count - 1)
|
||||
|
||||
return manifest
|
||||
}
|
||||
|
||||
private func installContent(_ data: Data, contentDigest: String, into contentStore: ContentStore) throws {
|
||||
let temporaryURL = try contentStore.temporaryContentURL(for: contentDigest)
|
||||
try data.write(to: temporaryURL)
|
||||
_ = try contentStore.install(temporaryURL, contentDigest: contentDigest)
|
||||
}
|
||||
|
||||
private func pinnedBaseManifest(contentDigest: String) -> OCIManifest {
|
||||
var disk = OCIManifestLayer(
|
||||
mediaType: diskV2MediaType,
|
||||
size: 1,
|
||||
digest: "sha256:disk-transport",
|
||||
uncompressedSize: 1,
|
||||
uncompressedContentDigest: "sha256:disk-chunk"
|
||||
)
|
||||
disk.annotations?[diskFileContentDigestAnnotation] = contentDigest
|
||||
|
||||
return OCIManifest(
|
||||
config: OCIManifestConfig(size: 1, digest: "sha256:oci-config"),
|
||||
layers: [
|
||||
OCIManifestLayer(mediaType: configMediaType, size: 1, digest: "sha256:config"),
|
||||
disk,
|
||||
OCIManifestLayer(mediaType: nvramMediaType, size: 1, digest: "sha256:nvram"),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
private func installContent(_ data: Data, into contentStore: ContentStore) throws -> (digest: String, url: URL) {
|
||||
let digest = Digest.hash(data)
|
||||
let temporaryURL = try contentStore.temporaryContentURL(for: digest)
|
||||
try data.write(to: temporaryURL)
|
||||
|
||||
return (digest, try contentStore.install(temporaryURL, contentDigest: digest))
|
||||
}
|
||||
|
||||
private func digestName(for manifest: OCIManifest) throws -> RemoteName {
|
||||
RemoteName(
|
||||
host: "example.com",
|
||||
namespace: "org/image",
|
||||
reference: Reference(digest: try manifest.digest())
|
||||
)
|
||||
}
|
||||
|
||||
private func withTemporaryTartHome(_ body: () throws -> Void) throws {
|
||||
let home = try temporaryDirectory()
|
||||
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
|
||||
setenv("TART_HOME", home.path, 1)
|
||||
defer {
|
||||
if let previousHome {
|
||||
setenv("TART_HOME", previousHome, 1)
|
||||
} else {
|
||||
unsetenv("TART_HOME")
|
||||
}
|
||||
}
|
||||
|
||||
try body()
|
||||
}
|
||||
|
||||
private func withTemporaryTartHome(_ body: () async throws -> Void) async throws {
|
||||
let home = try temporaryDirectory()
|
||||
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
|
||||
setenv("TART_HOME", home.path, 1)
|
||||
defer {
|
||||
if let previousHome {
|
||||
setenv("TART_HOME", previousHome, 1)
|
||||
} else {
|
||||
unsetenv("TART_HOME")
|
||||
}
|
||||
}
|
||||
|
||||
try await body()
|
||||
}
|
||||
|
||||
private func temporaryVMDirectory() throws -> VMDirectory {
|
||||
VMDirectory(baseURL: try temporaryDirectory())
|
||||
}
|
||||
|
||||
private func temporaryDirectory() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false)
|
||||
addTeardownBlock {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Set shell options to enable fail-fast behavior
|
||||
#
|
||||
# * -e: fail the script when an error occurs or command fails
|
||||
# * -u: fail the script when attempting to reference unset parameters
|
||||
# * -o pipefail: by default an exit status of a pipeline is that of its
|
||||
# last command, this fails the pipe early if an error in
|
||||
# any of its commands occurs
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
OUTPUT_PATH="Resources/actool"
|
||||
PLIST_PATH="$OUTPUT_PATH/Info.plist"
|
||||
|
||||
rm -rf "${OUTPUT_PATH}"
|
||||
mkdir -p "${OUTPUT_PATH}"
|
||||
|
||||
actool "Resources/UPW Tart.icon" \
|
||||
--compile "${OUTPUT_PATH}" \
|
||||
--output-format human-readable-text \
|
||||
--notices \
|
||||
--warnings \
|
||||
--errors \
|
||||
--app-icon "UPW Tart" \
|
||||
--output-partial-info-plist $PLIST_PATH \
|
||||
--include-all-app-icons \
|
||||
--target-device mac \
|
||||
--minimum-deployment-target 13.0 \
|
||||
--platform macosx
|
||||
@@ -22,32 +22,6 @@ You can also enable the debugging output to diagnose issues:
|
||||
go run cmd/main.go fio --debug
|
||||
```
|
||||
|
||||
To compare an empty Tart home with the same pull after its immutable base has
|
||||
been prewarmed, provide a standalone remote base image and a stacked image built
|
||||
from it. For example, create and push a stacked child of the public Tahoe base:
|
||||
|
||||
```shell
|
||||
BASE_IMAGE=ghcr.io/cirruslabs/macos-tahoe-base:latest
|
||||
STACKED_IMAGE=ghcr.io/your-org/macos-tahoe-stacked:latest
|
||||
|
||||
tart clone --stacked "$BASE_IMAGE" macos-tahoe-stacked
|
||||
tart push macos-tahoe-stacked "$STACKED_IMAGE"
|
||||
```
|
||||
|
||||
Then benchmark that pair:
|
||||
|
||||
```shell
|
||||
go run cmd/main.go stacked-oci \
|
||||
--base-image "$BASE_IMAGE" \
|
||||
--image "$STACKED_IMAGE"
|
||||
```
|
||||
|
||||
The command first performs an unmeasured pull to warm registry, CDN, and
|
||||
filesystem caches. It then uses disposable `TART_HOME` directories for both
|
||||
measured scenarios. The prewarmed scenario keeps the VM created by
|
||||
`tart clone --stacked` alive while pulling the child, so the shared immutable
|
||||
base layer remains referenced and available for reuse.
|
||||
|
||||
## Results
|
||||
|
||||
### Mar 27, 2024
|
||||
|
||||
@@ -2,7 +2,6 @@ package command
|
||||
|
||||
import (
|
||||
"github.com/cirruslabs/tart/benchmark/internal/command/fio"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/command/stackedoci"
|
||||
"github.com/cirruslabs/tart/benchmark/internal/command/xcode"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -16,7 +15,6 @@ func NewCommand() *cobra.Command {
|
||||
|
||||
cmd.AddCommand(
|
||||
fio.NewCommand(),
|
||||
stackedoci.NewCommand(),
|
||||
xcode.NewCommand(),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
package stackedoci
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gosuri/uitable"
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapio"
|
||||
)
|
||||
|
||||
var (
|
||||
debug bool
|
||||
baseImage string
|
||||
stackedImage string
|
||||
insecure bool
|
||||
concurrency uint
|
||||
)
|
||||
|
||||
func NewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "stacked-oci",
|
||||
Short: "benchmark empty and prewarmed Tart homes for stacked OCI pulls",
|
||||
Long: "Warm the registry once, then compare an empty Tart home with one whose " +
|
||||
"immutable base has already been materialized by tart clone --stacked. " +
|
||||
"Every scenario uses a disposable TART_HOME and leaves the user's Tart home untouched.",
|
||||
RunE: run,
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging")
|
||||
cmd.Flags().StringVar(&baseImage, "base-image", "", "remote flat OCI image used as the stacked image's base")
|
||||
cmd.Flags().StringVar(&stackedImage, "image", "", "remote stacked OCI image to pull and clone")
|
||||
cmd.Flags().BoolVar(&insecure, "insecure", false, "connect to the OCI registry via insecure HTTP")
|
||||
cmd.Flags().UintVar(&concurrency, "concurrency", 4, "network concurrency passed to tart pull and clone")
|
||||
_ = cmd.MarkFlagRequired("base-image")
|
||||
_ = cmd.MarkFlagRequired("image")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, _ []string) error {
|
||||
if concurrency < 1 {
|
||||
return fmt.Errorf("concurrency cannot be less than 1")
|
||||
}
|
||||
|
||||
config := zap.NewProductionConfig()
|
||||
if debug {
|
||||
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
|
||||
}
|
||||
logger, err := config.Build()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = logger.Sync() }()
|
||||
|
||||
warmupHome, err := os.MkdirTemp("", "tart-stacked-oci-warmup-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(warmupHome)
|
||||
|
||||
emptyHome, err := os.MkdirTemp("", "tart-stacked-oci-empty-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(emptyHome)
|
||||
|
||||
warmHome, err := os.MkdirTemp("", "tart-stacked-oci-warm-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(warmHome)
|
||||
|
||||
table := uitable.New()
|
||||
table.AddRow("Scenario", "Operation", "Time")
|
||||
|
||||
// Warm registry, CDN, and filesystem caches before either measured
|
||||
// scenario so their difference reflects Tart's local base reuse.
|
||||
if _, err := timedTart(cmd.Context(), logger, warmupHome, pullArguments(stackedImage)...); err != nil {
|
||||
return fmt.Errorf("registry warmup failed: %w", err)
|
||||
}
|
||||
if err := os.RemoveAll(warmupHome); err != nil {
|
||||
return fmt.Errorf("removing registry warmup home: %w", err)
|
||||
}
|
||||
|
||||
duration, err := timedTart(cmd.Context(), logger, emptyHome, pullArguments(stackedImage)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("empty-home stacked pull failed: %w", err)
|
||||
}
|
||||
table.AddRow("empty", "pull stacked image", duration)
|
||||
|
||||
duration, err = timedTart(cmd.Context(), logger, emptyHome, "clone", stackedImage, "empty-clone")
|
||||
if err != nil {
|
||||
return fmt.Errorf("empty-home stacked clone failed: %w", err)
|
||||
}
|
||||
table.AddRow("empty", "clone stacked image", duration)
|
||||
if err := os.RemoveAll(emptyHome); err != nil {
|
||||
return fmt.Errorf("removing empty home: %w", err)
|
||||
}
|
||||
|
||||
duration, err = timedTart(cmd.Context(), logger, warmHome, cloneBaseArguments(baseImage)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("base prewarm failed: %w", err)
|
||||
}
|
||||
table.AddRow("prewarmed", "clone --stacked base image", duration)
|
||||
|
||||
duration, err = timedTart(cmd.Context(), logger, warmHome, pullArguments(stackedImage)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prewarmed stacked pull failed: %w", err)
|
||||
}
|
||||
table.AddRow("prewarmed", "pull stacked image", duration)
|
||||
|
||||
duration, err = timedTart(cmd.Context(), logger, warmHome, "clone", stackedImage, "warm-clone")
|
||||
if err != nil {
|
||||
return fmt.Errorf("prewarmed stacked clone failed: %w", err)
|
||||
}
|
||||
table.AddRow("prewarmed", "clone stacked image", duration)
|
||||
|
||||
fmt.Println(table.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func pullArguments(image string) []string {
|
||||
args := []string{"pull", "--concurrency", fmt.Sprint(concurrency)}
|
||||
if insecure {
|
||||
args = append(args, "--insecure")
|
||||
}
|
||||
return append(args, image)
|
||||
}
|
||||
|
||||
func cloneBaseArguments(image string) []string {
|
||||
args := []string{"clone", "--stacked", "--concurrency", fmt.Sprint(concurrency)}
|
||||
if insecure {
|
||||
args = append(args, "--insecure")
|
||||
}
|
||||
return append(args, image, "prewarmed-base")
|
||||
}
|
||||
|
||||
func timedTart(
|
||||
ctx context.Context,
|
||||
logger *zap.Logger,
|
||||
tartHome string,
|
||||
args ...string,
|
||||
) (time.Duration, error) {
|
||||
logger.Sugar().Debugf("TART_HOME=%s tart %s", tartHome, strings.Join(args, " "))
|
||||
start := time.Now()
|
||||
|
||||
command := exec.CommandContext(ctx, "tart", args...)
|
||||
command.Env = append(os.Environ(), "TART_HOME="+tartHome)
|
||||
loggerWriter := &zapio.Writer{Log: logger, Level: zap.DebugLevel}
|
||||
command.Stdout = loggerWriter
|
||||
command.Stderr = loggerWriter
|
||||
|
||||
err := command.Run()
|
||||
return time.Since(start).Round(time.Millisecond), err
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.8 MiB |
@@ -11,12 +11,6 @@ categories:
|
||||
|
||||
# Changing Tart License
|
||||
|
||||
!!! note "Current license"
|
||||
This post describes a historical license change announced on February 11, 2023.
|
||||
As of June 5, 2026, Tart is maintained by OpenAI and licensed under
|
||||
[FSL-1.1-ALv2](https://github.com/openai/tart/blob/main/LICENSE).
|
||||
The usage limits, paid tiers, pricing, support commitments, and contact details described below no longer apply.
|
||||
|
||||
**TLDR:** We are transitioning Tart's licensing from AGPL-3.0 to [Fair Source 100](https://fair.io/). This change will
|
||||
permit unlimited installations on personal computers, but organizations that exceed a certain number of server
|
||||
installations utilizing 100 CPU cores will be required to obtain a paid license.
|
||||
@@ -26,7 +20,7 @@ installations utilizing 100 CPU cores will be required to obtain a paid license.
|
||||
Exactly a year ago on February 11th 2022 we started working on Tart – a tiny CLI to run macOS virtual machines on Apple Silicon.
|
||||
Three months later we successfully started using Tart in our own production system and decided to share Tart with everyone.
|
||||
|
||||
<img src="https://github.com/openai/tart/raw/main/Resources/TartSocial.png"/>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
|
||||
|
||||
The goal was to establish a community of users and contributors to transform Tart from a small CLI to a robust tool
|
||||
for various scenarios. **Unfortunately, we were not successful in attracting a significant number of contributors.**
|
||||
@@ -66,9 +60,13 @@ device without a physical display connected. For example, a Mac Mini with a HDMI
|
||||
but a Mac Mini on a desk with a connected physical display is considered a personal computer. **Usage on personal computers
|
||||
and before reaching the 100 CPU cores limit is royalty-free and does not have the viral properties of AGPL.**
|
||||
|
||||
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a Gold Tier License,
|
||||
which costs \$12,000 per year. Upon reaching a limit of 500 CPU cores, a Platinum Tier License
|
||||
(\$36,000 per year) will be required, and for organizations that exceed 3000 CPU cores, a custom Diamond Tier License
|
||||
!!! 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 \$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?
|
||||
@@ -80,5 +78,6 @@ this approach is not addressing concerns related to the viral nature of AGPL for
|
||||
we concluded that transitioning to a source-available model with a mandatory paid licensing is fair, as the licensing fees
|
||||
are relatively insignificant for companies that reach a significant level of usage.
|
||||
|
||||
If you have any questions or concerns, please feel free to reach out to [licensing@cirruslabs.org](mailto:licensing@cirruslabs.org).
|
||||
If the new licensing model is not suitable for your organization, you are welcome to continue using the AGPL version of Tart,
|
||||
but please ensure it is not used in a non-AGPL environment.
|
||||
|
||||
@@ -78,7 +78,7 @@ in “worker” mode on macOS hosts. Orchard controller is using extremely fast
|
||||
|
||||
## Conclusion
|
||||
|
||||
Please give [Orchard](https://github.com/openai/orchard) a try! To run it locally in development mode on any Apple Silicon device
|
||||
Please give [Orchard](https://github.com/cirruslabs/orchard) a try! To run it locally in development mode on any Apple Silicon device
|
||||
please run the following command:
|
||||
|
||||
```bash
|
||||
@@ -86,9 +86,9 @@ brew install cirruslabs/cli/orchard
|
||||
orchard dev
|
||||
```
|
||||
|
||||
This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/openai/orchard#creating-virtual-machines)
|
||||
This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/cirruslabs/orchard#creating-virtual-machines)
|
||||
on how to create your first virtual machine and access it.
|
||||
|
||||
In a [separate blog post](2023-04-28-orchard-ssh-over-grpc.md)
|
||||
we’ll cover how Orchard implements seamless SSH access over a gRPC connection. Stay tuned and please don’t hesitate to
|
||||
[open an issue](https://github.com/openai/orchard/issues)!
|
||||
[reach out](https://github.com/cirruslabs/orchard/discussions/landing)!
|
||||
|
||||
@@ -11,7 +11,7 @@ categories:
|
||||
|
||||
# SSH over gRPC or how Orchard simplifies accessing VMs in private networks
|
||||
|
||||
We started developing [Orchard](https://github.com/openai/orchard), an orchestrator for [Tart](https://tart.run/), with the requirement that it should allow users to access virtual machines running on worker nodes in private networks that users might not have access to.
|
||||
We started developing [Orchard](https://github.com/cirruslabs/orchard), an orchestrator for [Tart](https://tart.run/), with the requirement that it should allow users to access virtual machines running on worker nodes in private networks that users might not have access to.
|
||||
|
||||
At the same time, we wanted to enable users to access VMs on these remote workers just as easily as they’d access network services on their local Tart VMs.
|
||||
|
||||
@@ -102,14 +102,14 @@ Overall, the technology described in this article somewhat resembles what [we pr
|
||||
|
||||
We really hope this feature will be useful for many, just as the Cirrus Terminal, and that it will remove the pain of scaling Tart beyond a single machine.
|
||||
|
||||
You can give [Orchard](https://github.com/openai/orchard) a try by running it locally in development mode on any Apple Silicon device:
|
||||
You can give [Orchard](https://github.com/cirruslabs/orchard) a try by running it locally in development mode on any Apple Silicon device:
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/orchard
|
||||
orchard dev
|
||||
```
|
||||
|
||||
This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/openai/orchard#creating-virtual-machines)
|
||||
This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/cirruslabs/orchard#creating-virtual-machines)
|
||||
on how to create your first virtual machine and access it.
|
||||
|
||||
Stay tuned and don’t hesitate to send us your feedback either [on GitHub](https://github.com/openai/orchard) or [Twitter](https://twitter.com/cirrus_labs)!
|
||||
Stay tuned and don’t hesitate to send us your feedback either [on GitHub](https://github.com/cirruslabs/orchard) or [Twitter](https://twitter.com/cirrus_labs)!
|
||||
|
||||
@@ -19,21 +19,25 @@ Today we'd like to share some news and updates around the Tart ecosystem since t
|
||||
|
||||
In the last 7 months Tart community almost tripled and growth is continuing to accelerate. Tart just crossed 25,000 installations,
|
||||
dozens of companies that we know of are using Tart in their daily workflows. If your company is not in the list please consider
|
||||
[joining](https://github.com/openai/tart/blob/main/Resources/Users/HowToAddYourself.md)!
|
||||
[joining](https://github.com/cirruslabs/tart/blob/main/Resources/Users/HowToAddYourself.md)!
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
- { height="65" }
|
||||
|
||||
</div>
|
||||
|
||||
We are also very pleased by how the community responded to [the license change](2023-02-11-changing-tart-license.md).
|
||||
We now have a number of companies running Tart at scale under the new license. Revenue from the licensing allowed us to
|
||||
allocate time to continue improving Tart which brings us to the section below.
|
||||
|
||||
## Recent updates and what's changing in Tart 2.0.0
|
||||
|
||||
In the last 7 months we've had 12 feature releases that brought a lot of features requested by the community. Here are just
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2023-10-06
|
||||
search:
|
||||
exclude: true
|
||||
authors:
|
||||
- fkorotkov
|
||||
categories:
|
||||
- announcement
|
||||
---
|
||||
|
||||
# Tart is now available on AWS Marketplace
|
||||
|
||||
Announcing [official AMIs for EC2 Mac Instances](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws)
|
||||
with preconfigured Tart installation that is optimized to work within AWS infrastructure.
|
||||
|
||||
EC2 Mac Instances is a gem of engineering powered by AWS Nitro devices. Just imagine there is a physical Mac Mini with
|
||||
a plugged in Nitro device that can push the physical power button!
|
||||
|
||||

|
||||
|
||||
This clever synergy between Apple Hardware and Nitro System allows seamless integration with VPC networking and booting macOS from an EBS volume.
|
||||
|
||||
In this blog post we’ll see how a virtualization solution like Tart can compliment and elevate experience with EC2 Mac Instances.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
Let’s start from the basics, what EC2 Mac Instances allow to do compared to physical Mac Minis seating in offices of
|
||||
many companies around the world?
|
||||
|
||||
First and foremost, EC2 Mac Instances sit inside AWS data centers and can leverage all the goodies of VPC networking
|
||||
within your company's existing infrastructure. No need to connect your Macs in the office through a VPN and deal
|
||||
with networking and security.
|
||||
|
||||
Additionally, EC2 Mac Instances are booting from EBS volumes which means it is possible to always have reproducible instances
|
||||
and apply all the best practices of Infrastructure-as-Code. Managing a fleet of physical Macs is a pain and it's very hard
|
||||
to make them configured in a reproducible and stable way. With booting from identical EBS volumes your team is always sure
|
||||
about the identical initial state of the fleet.
|
||||
|
||||
## Compromises of EC2 Mac Instances
|
||||
|
||||
The flexibility of EBS volumes for macOS comes with some compromises that virtualization solutions like Tart can help with.
|
||||
The initial boot from an EBS volume takes some time and not instant. macOS itself is pretty heavy and a Nitro device needs
|
||||
to download tens of gigabytes that macOS requires in order to boot. This means that **resetting a EC2 Mac Instance to a clean state
|
||||
is not instant and usually takes a couple of minutes** when you can’t utilize the precious resources for your workloads.
|
||||
|
||||
It is much easier to tailor such EBS volumes with tools like Packer but there is still a **friction to test newly created EBS volumes**
|
||||
since one needs to start and run a EC2 Mac Instance and it’s not possible to test things locally. Similarly it is even harder
|
||||
to test beta versions of macOS that require manual interaction with a running instance.
|
||||
|
||||
## Solution
|
||||
|
||||
Tart can help with all the compromises! Tart virtual machines (VMs) have nearly native performance thanks to utilizing
|
||||
native `Virtualization.Framework` that was developed along the first Apple Silicon chip. **Tart VMs can be copied/disposed
|
||||
instantly and booting a fresh Tart VM takes only several seconds**. It is also possible to run two different Tart VMs in parallel
|
||||
that can have completely different versions of macOS and packages. For example, it is possible to have the latest stable macOS
|
||||
with the release version of Xcode along with the next version of macOS with the latest beta of Xcode.
|
||||
|
||||
Creation of Tart VMs can be automated with [a Packer plugin](https://github.com/cirruslabs/packer-plugin-tart) the same way as
|
||||
creation of EC2 AMIs with one caveat that **Tart Packer Plugin works locally so you can test the same virtual machine locally
|
||||
as you would run it in the cloud**.
|
||||
|
||||
Lightweight nature of Tart VMs with a focus on an easy-to-integrate Tart CLI compliments any macOS automation and helps to reduce
|
||||
the feedback cycle and improves reproducibility of macOS environments even further.
|
||||
|
||||
## Conclusion
|
||||
|
||||
We are excited to bring [official AMIs that include Tart installation optimized to work within AWS](https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws).
|
||||
In the coming weeks when macOS Sonoma will become available on AWS we’ll release another update specifically targeting EC2 Mac Instances.
|
||||
This update will simplify access to local SSDs of Mac Instances that are slightly faster than EBS volumes. Stay tuned and don’t hesitate
|
||||
to ask any [questions](https://tart.run/licensing/).
|
||||
@@ -11,7 +11,7 @@ categories:
|
||||
|
||||
# Jumping through the hoops: SSH jump host functionality in Orchard
|
||||
|
||||
Almost a year ago, when we started building [Orchard](https://github.com/openai/orchard), an orchestration system for Tart, we quickly realized that most worker machines will be in a private network, and that VMs will be only reachable from the worker machines themselves. Thus, one of our goals became to simplify accessing the compute resources in a cluster through a centralized controller host.
|
||||
Almost a year ago, when we started building [Orchard](https://github.com/cirruslabs/orchard), an orchestration system for Tart, we quickly realized that most worker machines will be in a private network, and that VMs will be only reachable from the worker machines themselves. Thus, one of our goals became to simplify accessing the compute resources in a cluster through a centralized controller host.
|
||||
|
||||
This effort resulted in commands like `orchard port-forward` and `orchard ssh`, which were later improved to support connecting not just to the VMs, but to the worker machines themselves.
|
||||
|
||||
@@ -55,6 +55,8 @@ Once running, you can connect to any VM in the cluster using the `ssh -J <servic
|
||||
|
||||
## Future plans
|
||||
|
||||
First of all, we’d like to thank our paid clients, without which this feature wouldn’t be possible. [Become one now](../../licensing.md) and get the benefit of higher Tart VMs and Orchard workers allowances and making sure that the roadmap for Tart and Orchard is aligned with your company's needs.
|
||||
|
||||
In the near future we plan to implement a mechanism similar to `authorized_keys` file that will allow attaching public SSH keys to the Orchard controller’s service accounts, and thus avoid the need to type the passwords.
|
||||
|
||||
Stay tuned and don’t hesitate to send us your feedback on [GitHub](https://github.com/openai/orchard) and [Twitter](https://x.com/cirrus_labs)!
|
||||
Stay tuned and don’t hesitate to send us your feedback on [GitHub](https://github.com/cirruslabs/orchard) and [Twitter](https://x.com/cirrus_labs)!
|
||||
|
||||
@@ -11,9 +11,9 @@ categories:
|
||||
|
||||
# Bridging the gaps with the Tart Guest Agent
|
||||
|
||||
We're introducing a new improvement for the Tart usability experience: a [Tart Guest Agent](https://github.com/openai/tart-guest-agent).
|
||||
We're introducing a new improvement for the Tart usability experience: a [Tart Guest Agent](https://github.com/cirruslabs/tart-guest-agent).
|
||||
|
||||
This agent provides automatic disk resizing, seamless clipboard sharing for macOS guests (a [long-awaited](https://github.com/openai/tart/issues/14) feature), and the ability to run commands, without SSH and networking, using the new `tart exec` command.
|
||||
This agent provides automatic disk resizing, seamless clipboard sharing for macOS guests (a [long-awaited](https://github.com/cirruslabs/tart/issues/14) feature), and the ability to run commands, without SSH and networking, using the new `tart exec` command.
|
||||
|
||||
As of recently, we include this agent in all non-vanilla Cirrus Labs images, so you likely won't need to do anything to benefit from these usability improvements.
|
||||
|
||||
@@ -47,7 +47,7 @@ Using gRPC simplifies `tart exec` implementation because of code generation and
|
||||
|
||||
Thanks to [gRPC Swift](https://github.com/grpc/grpc-swift), which is built on top of [SwiftNIO](https://github.com/apple/swift-nio), we get [`async/await`](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/) support for free, further simplifying the `tart exec` logic.
|
||||
|
||||
As for the Tart Guest Agent, the final result is a Golang binary that [can be customized](https://github.com/openai/tart-guest-agent?tab=readme-ov-file#guest-agent-for-tart-vms) depending on the execution context:
|
||||
As for the Tart Guest Agent, the final result is a Golang binary that [can be customized](https://github.com/cirruslabs/tart-guest-agent?tab=readme-ov-file#guest-agent-for-tart-vms) depending on the execution context:
|
||||
|
||||
* launchd global daemon — runs as a privileged user (`root`), has no clipboard access
|
||||
* `--resize-disk` — resizes the disk when there's a free space at the end of a disk (assuming that one previously ran `tart set --disk-size`)
|
||||
@@ -59,10 +59,14 @@ We’ve also introduced `--run-daemon` (which implies `--resize-disk`) and `--ru
|
||||
|
||||
## Future plans
|
||||
|
||||
First, we'd like to thank our paid clients, without whom this feature wouldn't have been possible.
|
||||
|
||||
[Become one now](../../licensing.md) and enjoy higher allowances for Tart VMs and Orchard workers—while helping ensure that our roadmap aligns with your company's needs.
|
||||
|
||||
In the near future we plan to implement:
|
||||
|
||||
* Linux support — to provide seamless experience for Linux guests too
|
||||
* a new `tart ip` resolver — to provide a more robust IP retrieval facility for Linux guests, which often struggle to populate the host's ARP table with their network activity
|
||||
* `tart cp` command — to copy files from/to guest VMs
|
||||
|
||||
Stay tuned, and feel free to send us feedback on [GitHub](https://github.com/openai/tart) and [Twitter](https://x.com/cirrus_labs)!
|
||||
Stay tuned, and feel free to send us feedback on [GitHub](https://github.com/cirruslabs/tart) and [Twitter](https://x.com/cirrus_labs)!
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2025-10-27
|
||||
search:
|
||||
exclude: true
|
||||
authors:
|
||||
- fkorotkov
|
||||
categories:
|
||||
- announcement
|
||||
---
|
||||
|
||||
# Press Release: Cirrus Labs Successfully Enforces Its Fair Source License
|
||||
|
||||
**New York City, NY – October 27th, 2025 – Cirrus Labs, Inc.**, a leading provider of platforms for digital transformation, today announced that it has reached a settlement agreement regarding a violation of its Fair Source License.
|
||||
|
||||
<!-- more -->
|
||||
|
||||
Cirrus Labs makes its Tart Virtualization Toolset, a leading virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon,
|
||||
freely available on GitHub under the Fair Source License, a source-available license. Tart is used by tens of thousands of engineers at no charge within its generous free‑use limits.
|
||||
Many large enterprises that need to exceed those limits support continued development through paid licenses. Cirrus Labs also uses Tart to power [Cirrus Runners](https://cirrus-runners.app/)
|
||||
— a drop‑in replacement for macOS and Linux runners for GitHub Actions — offered at a fixed monthly price for unlimited usage.
|
||||
|
||||
Cirrus Labs discovered that, **despite a prior licensing request that was declined due to a conflict of interest**, another company used Tart in a manner that exceeded the license’s free‑use limits,
|
||||
in order to create a competing product.
|
||||
|
||||
After several months of negotiations, the matter was settled and a settlement payment to Cirrus Labs was agreed upon.
|
||||
|
||||
!!! quote "Comment by Fedor Korotkov, CEO of Cirrus Labs"
|
||||
|
||||
As a company we embrace healthy competition that ultimately benefits the end user. Most of our users have no trouble complying with our license,
|
||||
and even when they need something more than our free use limits, we can almost always grant them a license that fits their needs. **This was an exceptional case.**
|
||||
We are pleased to have reached this settlement, which validates our source-available licensing strategy and reinforces our commitment to protecting our company and serving our community.
|
||||
|
||||
Cirrus Labs was represented in this matter by [Jordan Raphael](https://byronraphael.com/attorneys/jordan-raphael/) of Byron Raphael LLP, a boutique intellectual property law firm,
|
||||
and [Heather Meeker](https://www.techlawpartners.com/heather), a well-known specialist in open source and source available licensing.
|
||||
|
||||
The specific financial terms of the settlement and the identity of the counterparty remain confidential.
|
||||
|
||||
**About Cirrus Labs:** Cirrus Labs, Inc. is a bootstrapped developer-infrastructure company founded in 2017. Our offerings among others include Tart and Cirrus Runners,
|
||||
and our software is used by teams at category-leading companies including Atlassian, Figma, Zendesk, Sentry and many more.
|
||||
|
||||
Learn more at [https://tart.run/](https://tart.run/) and [https://cirrus-runners.app/](https://cirrus-runners.app/).
|
||||
|
||||
**Contact:** [hello@cirruslabs.org](mailto:hello@cirruslabs.org)
|
||||
@@ -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) or later and [only for Linux VMs](https://github.com/openai/tart/issues/1231#issuecomment-4410915463). By default, it is disabled, but can be enabled by passing the `--nested` flag to `tart run`.
|
||||
supports nested virtualization only on M3 or M4 chips running macOS 15 (Sequoia) or later. By default, it is disabled, but can be enabled by passing the `--nested` flag to `tart run`.
|
||||
|
||||
## Connecting to a service running on host
|
||||
|
||||
@@ -74,7 +74,7 @@ netstat -nr | awk '/default/{print $2; exit}'
|
||||
```
|
||||
|
||||
Note: that accessing host is only possible with the default NAT network. If you are running your virtual machines with
|
||||
[Softnet](https://github.com/openai/softnet) (via `tart run --net-softnet <VM NAME>)`, then the network isolation
|
||||
[Softnet](https://github.com/cirruslabs/softnet) (via `tart run --net-softnet <VM NAME>)`, then the network isolation
|
||||
is stricter and it's not possible to access the host.
|
||||
|
||||
## Avoiding the "Local Network" permission pop-up
|
||||
@@ -112,7 +112,7 @@ sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.pli
|
||||
|
||||
By default, the built-in macOS DHCP server allocates IP-addresses to the VMs for the duration of 86,400 seconds (one day), which may easily cause DHCP exhaustion if you run more than ~253 VMs per day, or in other words, more than one VM every ~6 minutes.
|
||||
|
||||
This issue is worked around automatically [when using Softnet](https://github.com/openai/softnet), however, if you don't use or can't use it, the following command will reduce the lease time from the default 86,400 seconds (one day) to 600 seconds (10 minutes):
|
||||
This issue is worked around automatically [when using Softnet](http://github.com/cirruslabs/softnet), however, if you don't use or can't use it, the following command will reduce the lease time from the default 86,400 seconds (one day) to 600 seconds (10 minutes):
|
||||
|
||||
```shell
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.InternetSharing.default.plist bootpd -dict DHCPLeaseTimeSecs -int 600
|
||||
@@ -204,7 +204,7 @@ Alternatively, you can pass the credentials via the environment variables, see [
|
||||
## How is Tart different from Anka?
|
||||
|
||||
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
|
||||
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/openai/tart/issues).
|
||||
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
|
||||
|
||||
Instead of Anka Registry, Tart can work with any OCI-compatible container registry. This provides a much more consistent
|
||||
and scalable experience for distributing virtual machines.
|
||||
@@ -231,28 +231,6 @@ export TART_NO_AUTO_PRUNE=
|
||||
TART_NO_AUTO_PRUNE= tart pull ...
|
||||
```
|
||||
|
||||
## Stacked disk images
|
||||
|
||||
On macOS 27 or newer, `tart clone --stacked` can create a VM from a remote,
|
||||
standalone macOS OCI image whose writes are stored in a private ASIF overlay while
|
||||
its source disk remains a shared read-only base:
|
||||
|
||||
```shell
|
||||
tart clone --stacked ghcr.io/cirruslabs/macos-tahoe-base:latest macos-build
|
||||
```
|
||||
|
||||
Running and pushing `macos-build` preserves that disk relationship. Pulling
|
||||
another image from the same lineage only downloads immutable disk files that
|
||||
are not already present in Tart's cache. For a stopped stacked VM,
|
||||
`tart set --disk-size` grows its private writable overlay without changing the
|
||||
base; a subsequent push records the new guest-visible disk size.
|
||||
|
||||
`tart pull` can cache a stacked image without assembling its disk. Clone, run,
|
||||
import, and export require a Tart build with DiskImageKit support and macOS 27
|
||||
or newer. Existing standalone raw and ASIF images continue to work on older
|
||||
hosts. Keep published lineages shallow when possible: every additional parent
|
||||
overlay adds another ASIF file to validate and assemble at run time.
|
||||
|
||||
## Disk resizing
|
||||
|
||||
Disk resizing works on most cloud-ready Linux distributions out-of-the box (e.g. Ubuntu Cloud Images have the `cloud-initramfs-growroot` package installed that runs on boot) and on the rest of the distributions by running the `growpart` or `resize2fs` commands.
|
||||
|
||||
@@ -5,8 +5,8 @@ description: Tool for running isolated tasks reproducibly in any environment wit
|
||||
|
||||
# Cirrus CLI
|
||||
|
||||
Tart itself is only responsible for managing virtual machines, but Cirrus Labs built Tart support into a tool called
|
||||
[Cirrus CLI](https://github.com/cirruslabs/cirrus-cli), a command line tool with
|
||||
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
|
||||
also developed by Cirrus Labs. [Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is a command line tool with
|
||||
one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
|
||||
We built Cirrus CLI to solve "But it works on my machine!" problem.
|
||||
|
||||
@@ -38,7 +38,7 @@ cirrus run
|
||||
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
|
||||
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
|
||||
|
||||
**Note:** Cirrus CI only allows [images managed and regularly updated by Cirrus Labs](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
||||
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
||||
|
||||
## Retrieving artifacts from within Tart VMs
|
||||
|
||||
|
||||