Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e88e1e51 | ||
|
|
b4de3bee83 | ||
|
|
cd0f238a67 | ||
|
|
02f94720c5 | ||
|
|
c0443060cf | ||
|
|
9c879b3f55 | ||
|
|
f7b38769a9 | ||
|
|
7c1ed4640f | ||
|
|
3fb8069edd | ||
|
|
c78c89e274 | ||
|
|
770220f905 | ||
|
|
768d1f9bad | ||
|
|
d49ed46439 | ||
|
|
b52a857698 | ||
|
|
3bf0bb22f3 | ||
|
|
accbd0cb33 | ||
|
|
c0b20932c7 | ||
|
|
3694af946c | ||
|
|
dbf711a6c9 | ||
|
|
b9f24a40c1 | ||
|
|
10c6ace671 | ||
|
|
b98e23956b | ||
|
|
ce23f9c2a7 | ||
|
|
3da91e6518 | ||
|
|
7046886713 | ||
|
|
3fde7d08dd | ||
|
|
227301436c | ||
|
|
106eb5a2c8 | ||
|
|
10bf706653 | ||
|
|
ff928ad77d | ||
|
|
33b5cfe2ed | ||
|
|
3892cdb00d | ||
|
|
5f2199ef3e | ||
|
|
3f26baa341 | ||
|
|
06cae1296e | ||
|
|
1b81b12760 | ||
|
|
4ed73bc775 | ||
|
|
2dc25ce478 | ||
|
|
1e74e268a5 | ||
|
|
bff344fb7f | ||
|
|
ababe8cefc | ||
|
|
ea5313698e | ||
|
|
679289d7ab | ||
|
|
5eccdf7412 | ||
|
|
63e3235d91 | ||
|
|
a760a431c3 | ||
|
|
bf5081b3d9 | ||
|
|
017592075f | ||
|
|
84e1ae2b38 | ||
|
|
d50e113300 | ||
|
|
fce52f1514 | ||
|
|
9484b8b2c9 | ||
|
|
dd46033812 | ||
|
|
c655288de7 | ||
|
|
204002f776 | ||
|
|
a0ae2f4e66 | ||
|
|
7c386e3466 | ||
|
|
dbbd716214 | ||
|
|
13d5ddb4a4 | ||
|
|
626316a4cd | ||
|
|
fbe35302c2 | ||
|
|
e1353f4540 | ||
|
|
985db24474 | ||
|
|
1d01bf63fb | ||
|
|
3ff3850da2 | ||
|
|
c6e8d0bfd7 | ||
|
|
755aad4d7c | ||
|
|
9f38441a42 | ||
|
|
3d46c4e6c2 | ||
|
|
e59221f6a0 | ||
|
|
c6e99345cd | ||
|
|
8bc2e99f63 | ||
|
|
f36f86b61c | ||
|
|
79084555f6 | ||
|
|
896d03ce0b | ||
|
|
99c91cbf87 | ||
|
|
da8afa1348 | ||
|
|
97b7ffef52 | ||
|
|
13e7794bfc | ||
|
|
b7b3b702ac | ||
|
|
560dba79e4 | ||
|
|
2b33b8f9e6 | ||
|
|
d8b010c79c | ||
|
|
5cd83c38cd | ||
|
|
1a3b862631 | ||
|
|
ae2d59e5c2 | ||
|
|
7eac45702b | ||
|
|
e06d89f95d | ||
|
|
0602d6e0e1 | ||
|
|
ac5d0baa0c | ||
|
|
ee27cc57bb | ||
|
|
a9e2a19015 | ||
|
|
89ff5f6b65 | ||
|
|
6bf39e73d0 | ||
|
|
0b693f6bc9 | ||
|
|
99bbd838a1 | ||
|
|
5c7743b7cd | ||
|
|
a40e104c03 | ||
|
|
e2d6c13ed0 | ||
|
|
3f17884ac2 | ||
|
|
7dcebf9c04 | ||
|
|
f6c56ed8eb | ||
|
|
a48f4d4ec9 | ||
|
|
9bb71a4051 | ||
|
|
18d462dd3d | ||
|
|
cd6a97f842 | ||
|
|
3e4bc73ff0 | ||
|
|
89fff42d0a | ||
|
|
7bb50b5e4b | ||
|
|
b5cbf67ee6 | ||
|
|
090a8232fc | ||
|
|
306ace792c | ||
|
|
96f6f94fa7 | ||
|
|
fbc481250d | ||
|
|
35538c2c5d | ||
|
|
4c33064916 | ||
|
|
1a267d4a39 | ||
|
|
36c54d95cb | ||
|
|
1d8bfafde5 | ||
|
|
537f0ae5db | ||
|
|
02f1ff5238 | ||
|
|
9c9bcd586e | ||
|
|
60f0eac7a8 | ||
|
|
35377a3475 | ||
|
|
dda4e91a91 | ||
|
|
ac5f794e6d | ||
|
|
5bcbc77249 | ||
|
|
bf03873c8d | ||
|
|
bad37b129c | ||
|
|
0f47cca746 | ||
|
|
d70eca4484 | ||
|
|
25887b075f | ||
|
|
8c011623be | ||
|
|
2dccdfb306 | ||
|
|
aca768a838 | ||
|
|
68b3557747 | ||
|
|
b2c923f2fe | ||
|
|
c75009e46f | ||
|
|
43e74ab769 | ||
|
|
1338864ed6 | ||
|
|
70040b633c | ||
|
|
f4bc02d175 | ||
|
|
6c24aa639a | ||
|
|
d8b69de52d | ||
|
|
c4c2bfeded | ||
|
|
b95585b56b | ||
|
|
8d5574ed3f | ||
|
|
457c2bc7db | ||
|
|
4bf9bdd531 | ||
|
|
8e9d61d5f5 | ||
|
|
71d03226fe | ||
|
|
36dab9878d | ||
|
|
f634002813 | ||
|
|
8e79669afb | ||
|
|
2da8bc0fb5 | ||
|
|
2d984ba194 | ||
|
|
50ce44c3eb | ||
|
|
c9e49ceb39 | ||
|
|
6df50e55d8 | ||
|
|
1fd710d00d | ||
|
|
4f6c7e79e1 | ||
|
|
1afb43e85b | ||
|
|
954cac3bee | ||
|
|
3ff4fc34c6 | ||
|
|
e118b42b1f | ||
|
|
f823190039 | ||
|
|
27cadc3f3b | ||
|
|
d4d3852745 | ||
|
|
4bb248e7b4 | ||
|
|
f45551cbf0 | ||
|
|
f68297097e | ||
|
|
637a2387e7 | ||
|
|
050d6a6ff1 | ||
|
|
5eddd1ce41 | ||
|
|
35f5b30bc4 | ||
|
|
8b27fea745 | ||
|
|
e00f62c95a | ||
|
|
d90893e9a0 | ||
|
|
feb733a7c0 | ||
|
|
545b6fcd94 | ||
|
|
c750d63ac9 |
@@ -7,6 +7,8 @@ export VERSION="${CIRRUS_TAG:-0}"
|
|||||||
mkdir -p .ci/pkg/
|
mkdir -p .ci/pkg/
|
||||||
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
|
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
|
||||||
cp Resources/embedded.provisionprofile .ci/pkg/embedded.provisionprofile
|
cp Resources/embedded.provisionprofile .ci/pkg/embedded.provisionprofile
|
||||||
|
cp Resources/AppIcon.png .ci/pkg/AppIcon.png
|
||||||
|
cp Resources/Info.plist .ci/pkg/Info.plist
|
||||||
pkgbuild --root .ci/pkg/ --identifier com.github.cirruslabs.tart --version $VERSION \
|
pkgbuild --root .ci/pkg/ --identifier com.github.cirruslabs.tart --version $VERSION \
|
||||||
--scripts .ci/pkg/scripts \
|
--scripts .ci/pkg/scripts \
|
||||||
--install-location "/Library/Application Support/Tart" \
|
--install-location "/Library/Application Support/Tart" \
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# fix structure
|
# fix structure
|
||||||
mkdir -p "$2/tart.app/Contents/MacOS"
|
mkdir -p "$2/tart.app/Contents/MacOS" "$2/tart.app/Resources"
|
||||||
mv "$2/tart" "$2/tart.app/Contents/MacOS/tart"
|
mv "$2/tart" "$2/tart.app/Contents/MacOS/tart"
|
||||||
mv "$2/embedded.provisionprofile" "$2/tart.app/Contents/embedded.provisionprofile"
|
mv "$2/embedded.provisionprofile" "$2/tart.app/Contents/embedded.provisionprofile"
|
||||||
|
mv "$2/AppIcon.png" "$2/tart.app/Resources/AppIcon.png"
|
||||||
|
mv "$2/Info.plist" "$2/tart.app/Contents/Info.plist"
|
||||||
|
|
||||||
echo "#!/bin/sh" > /usr/local/bin/tart
|
echo "#!/bin/sh" > /usr/local/bin/tart
|
||||||
echo "exec '$2/tart.app/Contents/MacOS/tart' \"\$@\"" >> /usr/local/bin/tart
|
echo "exec '$2/tart.app/Contents/MacOS/tart' \"\$@\"" >> /usr/local/bin/tart
|
||||||
|
|||||||
@@ -3,3 +3,5 @@
|
|||||||
TMPFILE=$(mktemp)
|
TMPFILE=$(mktemp)
|
||||||
envsubst < Sources/tart/CI/CI.swift > $TMPFILE
|
envsubst < Sources/tart/CI/CI.swift > $TMPFILE
|
||||||
mv $TMPFILE Sources/tart/CI/CI.swift
|
mv $TMPFILE Sources/tart/CI/CI.swift
|
||||||
|
|
||||||
|
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string ${CIRRUS_TAG}" Resources/Info.plist
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
use_compute_credits: true
|
use_compute_credits: true
|
||||||
|
|
||||||
env:
|
|
||||||
XCODE_TAG: 15-beta-2
|
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Test on Ventura
|
name: Test on Sonoma
|
||||||
alias: test
|
alias: test
|
||||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
|
||||||
persistent_worker:
|
persistent_worker:
|
||||||
labels:
|
labels:
|
||||||
name: dev-mini
|
name: dev-mini
|
||||||
|
resources:
|
||||||
|
tart-vms: 1
|
||||||
|
build_script:
|
||||||
|
- swift build
|
||||||
test_script:
|
test_script:
|
||||||
- swift test
|
- swift test
|
||||||
integration_test_script:
|
integration_test_script:
|
||||||
# Build Tart
|
|
||||||
- swift build
|
|
||||||
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||||
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
|
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
|
||||||
# Run integration tests
|
# Run integration tests
|
||||||
@@ -28,12 +26,19 @@ task:
|
|||||||
path: "integration-tests/pytest-junit.xml"
|
path: "integration-tests/pytest-junit.xml"
|
||||||
format: junit
|
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:
|
task:
|
||||||
name: Lint
|
name: Lint
|
||||||
alias: lint
|
alias: lint
|
||||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||||
lint_script:
|
lint_script:
|
||||||
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
||||||
always:
|
always:
|
||||||
@@ -43,15 +48,18 @@ task:
|
|||||||
|
|
||||||
task:
|
task:
|
||||||
only_if: $CIRRUS_TAG == ''
|
only_if: $CIRRUS_TAG == ''
|
||||||
name: Build
|
env:
|
||||||
|
matrix:
|
||||||
|
BUILD_ARCH: arm64
|
||||||
|
BUILD_ARCH: x86_64
|
||||||
|
name: Build ($BUILD_ARCH)
|
||||||
alias: build
|
alias: build
|
||||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||||
build_script: swift build --product tart
|
build_script: swift build --arch $BUILD_ARCH --product tart
|
||||||
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||||
binary_artifacts:
|
binary_artifacts:
|
||||||
path: .build/debug/tart
|
path: .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||||
|
|
||||||
task:
|
task:
|
||||||
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
||||||
@@ -59,9 +67,8 @@ task:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- lint
|
- lint
|
||||||
- build
|
- build
|
||||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||||
env:
|
env:
|
||||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||||
@@ -77,13 +84,13 @@ task:
|
|||||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
- 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
|
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||||
install_script:
|
install_script:
|
||||||
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
|
- brew install go goreleaser/tap/goreleaser-pro
|
||||||
- brew install mitchellh/gon/gon
|
- brew install mitchellh/gon/gon
|
||||||
info_script:
|
info_script:
|
||||||
- security find-identity -v
|
- security find-identity -v
|
||||||
- xcodebuild -version
|
- xcodebuild -version
|
||||||
- swift -version
|
- swift -version
|
||||||
goreleaser_script: goreleaser release --skip-publish --snapshot --clean
|
goreleaser_script: goreleaser release --skip=publish --snapshot --clean
|
||||||
always:
|
always:
|
||||||
dist_artifacts:
|
dist_artifacts:
|
||||||
path: "dist/*"
|
path: "dist/*"
|
||||||
@@ -95,9 +102,8 @@ task:
|
|||||||
- lint
|
- lint
|
||||||
- test
|
- test
|
||||||
- build
|
- build
|
||||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||||
env:
|
env:
|
||||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||||
@@ -105,7 +111,7 @@ task:
|
|||||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||||
SENTRY_ORG: cirrus-labs
|
SENTRY_ORG: cirrus-labs
|
||||||
SENTRY_PROJECT: persistent-workers
|
SENTRY_PROJECT: persistent-workers
|
||||||
SENTRY_AUTH_TOKEN: ENCRYPTED[!c16a5cf7da5f856b4bc2f21fe8cb7aa2a6c981f851c094ed4d3025fd02ea59a58a86cee8b193a69a1fc20fa217e56ac3!]
|
SENTRY_AUTH_TOKEN: ENCRYPTED[!9eaf2875d51b113e2f68598441ff8e6b2e53242e48fcb93633bd75a373fbe2e7caa900d837cc92f0b142b65579731644!]
|
||||||
setup_script:
|
setup_script:
|
||||||
- cd $HOME
|
- cd $HOME
|
||||||
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
||||||
@@ -142,7 +148,6 @@ task:
|
|||||||
task:
|
task:
|
||||||
name: Deploy Documentation
|
name: Deploy Documentation
|
||||||
only_if: $CIRRUS_BRANCH == 'main'
|
only_if: $CIRRUS_BRANCH == 'main'
|
||||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
|
||||||
container:
|
container:
|
||||||
image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest
|
image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest
|
||||||
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
|
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
|
||||||
|
|||||||
@@ -16,3 +16,6 @@ dist/
|
|||||||
|
|
||||||
# mkdocs
|
# mkdocs
|
||||||
.cache
|
.cache
|
||||||
|
|
||||||
|
# mkdocs-material
|
||||||
|
site
|
||||||
|
|||||||
@@ -3,27 +3,35 @@ project_name: tart
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- .ci/set-version.sh
|
- .ci/set-version.sh
|
||||||
- swift build -c release --product tart
|
- swift build --arch x86_64 -c release --product tart
|
||||||
|
- swift build --arch arm64 -c release --product tart
|
||||||
- gon gon.hcl
|
- gon gon.hcl
|
||||||
- mkdir -p tart.app/Contents/MacOS
|
|
||||||
- cp .build/arm64-apple-macosx/release/tart tart.app/Contents/MacOS/
|
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- builder: prebuilt
|
- id: tart
|
||||||
|
builder: prebuilt
|
||||||
|
goamd64: [v1]
|
||||||
goos:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
goarch:
|
goarch:
|
||||||
- arm64
|
- arm64
|
||||||
|
- amd64
|
||||||
binary: tart.app/Contents/MacOS/tart
|
binary: tart.app/Contents/MacOS/tart
|
||||||
prebuilt:
|
prebuilt:
|
||||||
path: tart.app/Contents/MacOS/tart
|
path: '.build/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}-apple-macosx/release/tart'
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- name_template: "{{ .ProjectName }}"
|
- name_template: "{{ .ProjectName }}-{{ .Arch }}"
|
||||||
files:
|
files:
|
||||||
- src: Resources/embedded.provisionprofile
|
- src: Resources/embedded.provisionprofile
|
||||||
dst: tart.app/Contents
|
dst: tart.app/Contents
|
||||||
strip_parent: true
|
strip_parent: true
|
||||||
|
- src: Resources/Info.plist
|
||||||
|
dst: tart.app/Contents
|
||||||
|
strip_parent: true
|
||||||
|
- src: Resources/AppIcon.png
|
||||||
|
dst: tart.app/Contents/Resources
|
||||||
|
strip_parent: true
|
||||||
- LICENSE
|
- LICENSE
|
||||||
|
|
||||||
release:
|
release:
|
||||||
@@ -31,24 +39,19 @@ release:
|
|||||||
|
|
||||||
brews:
|
brews:
|
||||||
- name: tart
|
- name: tart
|
||||||
tap:
|
repository:
|
||||||
owner: cirruslabs
|
owner: cirruslabs
|
||||||
name: homebrew-cli
|
name: homebrew-cli
|
||||||
caveats: See the GitHub repository for more information
|
caveats: See the GitHub repository for more information
|
||||||
homepage: https://github.com/cirruslabs/tart
|
homepage: https://github.com/cirruslabs/tart
|
||||||
license: "Fair Source"
|
license: "Fair Source"
|
||||||
description: Run macOS VMs on Apple Silicon
|
description: Run macOS and Linux VMs on Apple Hardware
|
||||||
skip_upload: auto
|
skip_upload: auto
|
||||||
dependencies:
|
dependencies:
|
||||||
- "cirruslabs/cli/softnet"
|
- "cirruslabs/cli/softnet"
|
||||||
install: |
|
install: |
|
||||||
libexec.install Dir["*"]
|
libexec.install Dir["*"]
|
||||||
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
|
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: |
|
custom_block: |
|
||||||
depends_on :macos => :monterey
|
depends_on :macos => :ventura
|
||||||
|
|
||||||
on_macos do
|
|
||||||
unless Hardware::CPU.arm?
|
|
||||||
odie "Tart only works on Apple Silicon!"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Contributing to Tart
|
||||||
|
|
||||||
|
Table of Contents
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
- [How to Build](#how-to-build)
|
||||||
|
- [How to Create an Issue/Enhancement](#how-to-create-an-issueenhancement)
|
||||||
|
- [Style Guidelines](#style-guidelines)
|
||||||
|
- [Pull Requests](#Pull-Requests)
|
||||||
|
|
||||||
|
## How to Build
|
||||||
|
|
||||||
|
1. Fork the repository to your own GitHub account
|
||||||
|
2. Clone the forked repository to your local machine
|
||||||
|
3. If using Xcode, use from Xcode 15 or newer
|
||||||
|
4. Run ./scripts/run-signed.sh from the root of your repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/run-signed.sh list
|
||||||
|
```
|
||||||
|
## How to Create an Issue/Enhancement
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
## Style Guidelines
|
||||||
|
|
||||||
|
1. Code should follow camel case
|
||||||
|
2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command:
|
||||||
|
```bash
|
||||||
|
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
1. Provide a detailed description of the changes you made in the pull request
|
||||||
|
2. Wait for pull request to be reviewed
|
||||||
|
3. Make adjustments if necessary
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Profiling Tart
|
||||||
|
|
||||||
|
## Using `time(1)`
|
||||||
|
|
||||||
|
Perhaps, the easiest, but not the most comprehensive way to tell what's going on with Tart is to use the [`time(1)`](https://ss64.com/mac/time.html) command.
|
||||||
|
|
||||||
|
In the example below, you will run `tart pull` via `time(1)` to gather generalized CPU, I/O and memory usage metrics:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
/usr/bin/time -l tart pull ghcr.io/cirruslabs/macos-sequoia-base:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** you need to specify a full path to `time(1)` binary, otherwise the shell's built-in `time` command will be invoked, which doesn't have the `-l` command-line argument.
|
||||||
|
|
||||||
|
**Note:** The `-l` command-line argument makes `time(1)` return much more useful information, for example, maximum memory usage.
|
||||||
|
|
||||||
|
When running the command above, you'll see the `tart pull` output first as it pulls the image, and then the `time(1)` output, which will be printed once the Tart process finishes:
|
||||||
|
|
||||||
|
```
|
||||||
|
172.17 real 10.29 user 8.36 sys
|
||||||
|
353796096 maximum resident set size
|
||||||
|
0 average shared memory size
|
||||||
|
0 average unshared data size
|
||||||
|
0 average unshared stack size
|
||||||
|
23838 page reclaims
|
||||||
|
35 page faults
|
||||||
|
0 swaps
|
||||||
|
0 block input operations
|
||||||
|
0 block output operations
|
||||||
|
8 messages sent
|
||||||
|
8 messages received
|
||||||
|
0 signals received
|
||||||
|
146 voluntary context switches
|
||||||
|
222950 involuntary context switches
|
||||||
|
39683070975 instructions retired
|
||||||
|
27562035252 cycles elapsed
|
||||||
|
170920448 peak memory footprint
|
||||||
|
```
|
||||||
|
|
||||||
|
From the output above, you can tell that `tart pull` spent nearly 90% of time off-CPU (`real` > `user` + `sys`), which means that Tart was mostly waiting for the I/O (be it a network or disk), instead of decompressing disk layers or doing other useful computations.
|
||||||
|
|
||||||
|
## Using `xctrace(1)`
|
||||||
|
|
||||||
|
[`xctrace(1)`](https://keith.github.io/xcode-man-pages/xctrace.1.html) is a `.trace` format recorder for the [Instruments](https://en.wikipedia.org/wiki/Instruments_(software)) app, which yields much more powerful insights compared to `time(1)`. For example, it can tell which Tart functions spent the most time on the CPU, thus allowing the Tart developers to further optimize these functions.
|
||||||
|
|
||||||
|
To use it, make sure that [Xcode](https://developer.apple.com/xcode/resources/) is installed. If you're installing Xcode for the first time on the machine, you'll need to launch it once and click the blue "Install" button. There's no need to choose any platforms except for the macOS.
|
||||||
|
|
||||||
|
Once done, you can create a CPU profile of `tart pull`:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
xctrace record --template "CPU Profiler" --target-stdout - --launch -- /opt/homebrew/bin/tart pull ghcr.io/cirruslabs/macos-sequoia-base:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Now that `xctrace(1)` is running, you'll see the `tart pull`-related output first, and once finished, the following line will appear:
|
||||||
|
|
||||||
|
```
|
||||||
|
Output file saved as: Launch_[...].trace
|
||||||
|
```
|
||||||
|
|
||||||
|
To view this trace in the Instruments app, simply find this directory in Finder and double-click it. Instruments app will appear:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
To send this trace, right-click its directory in Finder and choose "Compress [...]". This will result in a similarly named file with a `.zip` at the end, which can now be conveniently sent via email or uploaded.
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
|
"originHash" : "6a15657d8cb1d3e2b447f31aff5b47d6a9655d2262e48ca76476ba525435269b",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "antlr4",
|
"identity" : "antlr4",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/antlr/antlr4",
|
"location" : "https://github.com/antlr/antlr4",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "dev",
|
"revision" : "cc82115a4e7f53d71d9d905caa2c2dfa4da58899",
|
||||||
"revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2"
|
"version" : "4.13.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -18,13 +19,22 @@
|
|||||||
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "semaphore",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/groue/Semaphore",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2",
|
||||||
|
"version" : "0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "sentry-cocoa",
|
"identity" : "sentry-cocoa",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "d277532e1c8af813981ba01f591b15bbdd735615",
|
"revision" : "5575af93efb776414f243e93d6af9f6258dc539a",
|
||||||
"version" : "8.8.0"
|
"version" : "8.36.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -32,8 +42,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-algorithms",
|
"location" : "https://github.com/apple/swift-algorithms",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
|
"revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42",
|
||||||
"version" : "1.0.0"
|
"version" : "1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -41,8 +51,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-argument-parser",
|
"location" : "https://github.com/apple/swift-argument-parser",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "f3c9084a71ef4376f2fabbdf1d3d90a49f1fabdb",
|
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
|
||||||
"version" : "1.1.2"
|
"version" : "1.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -51,7 +61,7 @@
|
|||||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
"location" : "https://github.com/apple/swift-async-algorithms",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "main",
|
"branch" : "main",
|
||||||
"revision" : "f05e450f0b909c0e80670a47516c4b9700b9e5da"
|
"revision" : "5c8bd186f48c16af0775972700626f0b74588278"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,8 +69,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-atomics.git",
|
"location" : "https://github.com/apple/swift-atomics.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
|
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
|
||||||
"version" : "1.0.2"
|
"version" : "1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,8 +78,17 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-collections.git",
|
"location" : "https://github.com/apple/swift-collections.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
|
"revision" : "9bf03ff58ce34478e66aaee630e491823326fd06",
|
||||||
"version" : "1.0.3"
|
"version" : "1.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-log",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-log.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "9cb486020ebf03bfa5b5df985387a14a98744537",
|
||||||
|
"version" : "1.6.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,13 +100,31 @@
|
|||||||
"version" : "1.0.2"
|
"version" : "1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-retry",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/fumoboy007/swift-retry",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "df9d7b185d2e433147ec0083a73c257e665eea0d",
|
||||||
|
"version" : "0.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-sysctl",
|
"identity" : "swift-sysctl",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/sersoft-gmbh/swift-sysctl.git",
|
"location" : "https://github.com/sersoft-gmbh/swift-sysctl.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "71fd64ee84819bb19fbecfb36d5a4503726b6fb7",
|
"revision" : "a91be36de6803ebe48f678699dfd0694c2200d2f",
|
||||||
"version" : "1.6.0"
|
"version" : "1.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-xattr",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/jozefizso/swift-xattr",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "f8605af7b3290dbb235fb182ec6e9035d0c8c3ac",
|
||||||
|
"version" : "3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -95,8 +132,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/malcommac/SwiftDate",
|
"location" : "https://github.com/malcommac/SwiftDate",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
"revision" : "5d943224c3bb173e6ecf27295611615eba90c80e",
|
||||||
"version" : "6.3.1"
|
"version" : "7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -104,8 +141,17 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/nicklockwood/SwiftFormat",
|
"location" : "https://github.com/nicklockwood/SwiftFormat",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
|
"revision" : "ab6844edb79a7b88dc6320e6cee0a0db7674dac3",
|
||||||
"version" : "0.50.6"
|
"version" : "0.54.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftradix",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/orchetect/SwiftRadix",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a52c37a4c213403f7377ae77b4c68451bcab8330",
|
||||||
|
"version" : "1.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -118,5 +164,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 2
|
"version" : 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
// swift-tools-version:5.7
|
// swift-tools-version:5.10
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Tart",
|
name: "Tart",
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS(.v12)
|
.macOS(.v13)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.executable(name: "tart", targets: ["tart"])
|
.executable(name: "tart", targets: ["tart"])
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"),
|
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"),
|
||||||
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
||||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
|
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
|
||||||
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
|
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
|
||||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
|
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
|
||||||
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
|
.package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"),
|
||||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
|
||||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
|
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"),
|
||||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.8.0"),
|
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.36.0"),
|
||||||
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||||
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.0.0"),
|
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"),
|
||||||
|
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"),
|
||||||
|
.package(url: "https://github.com/groue/Semaphore", from: "0.0.8"),
|
||||||
|
.package(url: "https://github.com/fumoboy007/swift-retry", from: "0.2.3"),
|
||||||
|
.package(url: "https://github.com/jozefizso/swift-xattr", from: "3.0.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(name: "tart", dependencies: [
|
.executableTarget(name: "tart", dependencies: [
|
||||||
@@ -34,6 +38,10 @@ let package = Package(
|
|||||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||||
.product(name: "TextTable", package: "TextTable"),
|
.product(name: "TextTable", package: "TextTable"),
|
||||||
.product(name: "Sysctl", package: "swift-sysctl"),
|
.product(name: "Sysctl", package: "swift-sysctl"),
|
||||||
|
.product(name: "SwiftRadix", package: "SwiftRadix"),
|
||||||
|
.product(name: "Semaphore", package: "Semaphore"),
|
||||||
|
.product(name: "DMRetry", package: "swift-retry"),
|
||||||
|
.product(name: "XAttr", package: "swift-xattr"),
|
||||||
], exclude: [
|
], exclude: [
|
||||||
"OCI/Reference/Makefile",
|
"OCI/Reference/Makefile",
|
||||||
"OCI/Reference/Reference.g4",
|
"OCI/Reference/Reference.g4",
|
||||||
|
|||||||
@@ -8,43 +8,32 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
|||||||
* Use Tart Packer Plugin to automate VM creation.
|
* Use Tart Packer Plugin to automate VM creation.
|
||||||
* Easily integrates with any CI system.
|
* Easily integrates with any CI system.
|
||||||
|
|
||||||
Tart powers [Cirrus Runners](https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral)
|
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.
|
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">
|
<p align="center">
|
||||||
<a href="https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral" target=_blank>
|
<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"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Tart is also adopted by several other automation services:
|
Many companies are using Tart in their internal setups. Here are just a few of them:
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
|
<a href="https://atlassian.com/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Atlassian.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://codemagic.io/" target=_blank>
|
<a href="https://www.figma.com/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Codemagic.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Figma.png" height="65"/>
|
||||||
</a>
|
|
||||||
<a href="https://testingbot.com/" target=_blank>
|
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
Many more companies are using Tart in their internal setups. Here are a few of them:
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://ahrefs.com/" target=_blank>
|
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
|
|
||||||
</a>
|
|
||||||
<a href="https://krisp.ai/" target=_blank>
|
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
|
||||||
</a>
|
</a>
|
||||||
<a href="https://mullvad.net/" target=_blank>
|
<a href="https://mullvad.net/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://suran.com/" target=_blank>
|
<a href="https://krisp.ai/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.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/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://symflower.com/" target=_blank>
|
<a href="https://symflower.com/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
|
||||||
@@ -52,21 +41,33 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
|||||||
<a href="https://transloadit.com/" target=_blank>
|
<a href="https://transloadit.com/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://cirrus-ci.org/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
||||||
|
</a>
|
||||||
<a href="https://www.pitsdatarecovery.net/" target=_blank>
|
<a href="https://www.pitsdatarecovery.net/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://expo.dev/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Expo.png" height="65"/>
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
|
**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
|
## Usage
|
||||||
|
|
||||||
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
|
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install cirruslabs/cli/tart
|
brew install cirruslabs/cli/tart
|
||||||
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
|
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
|
||||||
tart run ventura-base
|
tart run sonoma-base
|
||||||
```
|
```
|
||||||
|
|
||||||
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
|
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<?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>CFBundleName</key>
|
||||||
|
<string>Tart</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Tart</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>org.cirruslabs.tart</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>tart</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.developer-tools</string>
|
||||||
|
<key>CFBundleIconFiles</key>
|
||||||
|
<array>
|
||||||
|
<string>AppIcon.png</string>
|
||||||
|
</array>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1,4 +0,0 @@
|
|||||||
If you'd like to highlight your use of Tart, please create a `456px` by `130px` logo and create a PR
|
|
||||||
that adds it to `README.md` in alphabetical order. Don't forget to include a small description of your usage pattern.
|
|
||||||
|
|
||||||
You can refer to `Background.png` as a base for your logo.
|
|
||||||
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.virtualization</key>
|
<key>com.apple.security.virtualization</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.get-task-allow</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -8,17 +8,15 @@ struct Clone: AsyncParsableCommand {
|
|||||||
discussion: """
|
discussion: """
|
||||||
Creates a local virtual machine by cloning either a remote or another local virtual machine.
|
Creates a local virtual machine by cloning either a remote or another local virtual machine.
|
||||||
|
|
||||||
Due to copy-on-write magic in Apple File System a cloned VM won't actually claim all the space right away.
|
Due to copy-on-write magic in Apple File System, a cloned VM won't actually claim all the space right away.
|
||||||
Only changes to a cloned disk will be written and claim new space. By default, Tart checks available capacity
|
Only changes to a cloned disk will be written and claim new space. This also speeds up clones enormously.
|
||||||
in Tart's home directory and checks if there is enough space for the worst possible scenario: when the whole disk
|
|
||||||
will be modified.
|
|
||||||
|
|
||||||
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable. This might be helpful
|
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the cloned image
|
||||||
for use cases when the original image is very big and a workload is known to only modify a fraction of the cloned disk.
|
to fit. This behaviour is called "automatic pruning" and can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@Argument(help: "source VM name")
|
@Argument(help: "source VM name", completion: .custom(completeMachines))
|
||||||
var sourceName: String
|
var sourceName: String
|
||||||
|
|
||||||
@Argument(help: "new VM name")
|
@Argument(help: "new VM name")
|
||||||
@@ -27,10 +25,20 @@ struct Clone: AsyncParsableCommand {
|
|||||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||||
var insecure: Bool = false
|
var insecure: Bool = false
|
||||||
|
|
||||||
|
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
|
||||||
|
var concurrency: UInt = 4
|
||||||
|
|
||||||
|
@Flag(help: .hidden)
|
||||||
|
var deduplicate: Bool = false
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
if newName.contains("/") {
|
if newName.contains("/") {
|
||||||
throw ValidationError("<new-name> should be a local name")
|
throw ValidationError("<new-name> should be a local name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if concurrency < 1 {
|
||||||
|
throw ValidationError("network concurrency cannot be less than 1")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
@@ -40,7 +48,7 @@ struct Clone: AsyncParsableCommand {
|
|||||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
// 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)
|
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||||
try await ociStorage.pull(remoteName, registry: registry)
|
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||||
@@ -56,7 +64,7 @@ struct Clone: AsyncParsableCommand {
|
|||||||
try lock.lock()
|
try lock.lock()
|
||||||
|
|
||||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||||
&& sourceVM.state() != "suspended"
|
&& sourceVM.state() != .Suspended
|
||||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||||
|
|
||||||
try localStorage.move(newName, from: tmpVMDir)
|
try localStorage.move(newName, from: tmpVMDir)
|
||||||
@@ -66,7 +74,7 @@ struct Clone: AsyncParsableCommand {
|
|||||||
// APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
|
// APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
|
||||||
// is not actually claiming new space until the VM is started and it writes something to disk.
|
// is not actually claiming new space until the VM is started and it writes something to disk.
|
||||||
// So once we clone the VM let's try to claim a little bit of space for the VM to run.
|
// So once we clone the VM let's try to claim a little bit of space for the VM to run.
|
||||||
try Prune.reclaimIfNeeded(UInt64(sourceVM.sizeBytes()), sourceVM)
|
try Prune.reclaimIfNeeded(UInt64(sourceVM.allocatedSizeBytes()), sourceVM)
|
||||||
}, onCancel: {
|
}, onCancel: {
|
||||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import Dispatch
|
import Dispatch
|
||||||
import SwiftUI
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
struct Create: AsyncParsableCommand {
|
struct Create: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Create a VM")
|
static var configuration = CommandConfiguration(abstract: "Create a VM")
|
||||||
@@ -15,13 +16,18 @@ struct Create: AsyncParsableCommand {
|
|||||||
@Flag(help: "create a Linux VM")
|
@Flag(help: "create a Linux VM")
|
||||||
var linux: Bool = false
|
var linux: Bool = false
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("Disk size in Gb"))
|
@Option(help: ArgumentHelp("Disk size in GB"))
|
||||||
var diskSize: UInt16 = 50
|
var diskSize: UInt16 = 50
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
if fromIPSW == nil && !linux {
|
if fromIPSW == nil && !linux {
|
||||||
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
||||||
}
|
}
|
||||||
|
#if arch(x86_64)
|
||||||
|
if fromIPSW != nil {
|
||||||
|
throw ValidationError("Only Linux VMs are supported on Intel!")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
@@ -32,26 +38,32 @@ struct Create: AsyncParsableCommand {
|
|||||||
try tmpVMDirLock.lock()
|
try tmpVMDirLock.lock()
|
||||||
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
try await withTaskCancellationHandler(operation: {
|
||||||
if let fromIPSW = fromIPSW {
|
#if arch(arm64)
|
||||||
let ipswURL: URL
|
if let fromIPSW = fromIPSW {
|
||||||
|
let ipswURL: URL
|
||||||
|
|
||||||
if fromIPSW == "latest" {
|
if fromIPSW == "latest" {
|
||||||
ipswURL = try await VM.latestIPSWURL()
|
defaultLogger.appendNewLine("Looking up the latest supported IPSW...")
|
||||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
|
||||||
ipswURL = URL(string: fromIPSW)!
|
let image = try await withCheckedThrowingContinuation { continuation in
|
||||||
} else {
|
VZMacOSRestoreImage.fetchLatestSupported() { result in
|
||||||
ipswURL = URL(fileURLWithPath: fromIPSW)
|
continuation.resume(with: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ipswURL = image.url
|
||||||
|
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||||
|
ipswURL = URL(string: fromIPSW)!
|
||||||
|
} else {
|
||||||
|
ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if linux {
|
if linux {
|
||||||
if #available(macOS 13, *) {
|
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
|
||||||
} else {
|
|
||||||
throw UnsupportedOSError("Linux VMs", "are")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import SwiftUI
|
|||||||
struct Delete: AsyncParsableCommand {
|
struct Delete: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Delete a VM")
|
static var configuration = CommandConfiguration(abstract: "Delete a VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeMachines))
|
||||||
var name: [String]
|
var name: [String]
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Foundation
|
|||||||
struct Export: AsyncParsableCommand {
|
struct Export: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Export VM to a compressed .tvm file")
|
static var configuration = CommandConfiguration(abstract: "Export VM to a compressed .tvm file")
|
||||||
|
|
||||||
@Argument(help: "Source VM name.")
|
@Argument(help: "Source VM name.", completion: .custom(completeMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Argument(help: "Path to the destination file.")
|
@Argument(help: "Path to the destination file.")
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import SystemConfiguration
|
||||||
|
|
||||||
|
struct FQN: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(abstract: "Get a fully-qualified VM name", shouldDisplay: false)
|
||||||
|
|
||||||
|
@Argument(help: "VM name", completion: .custom(completeMachines))
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
if var remoteName = try? RemoteName(name) {
|
||||||
|
let digest = try VMStorageOCI().digest(remoteName)
|
||||||
|
|
||||||
|
remoteName.reference = Reference(digest: digest)
|
||||||
|
|
||||||
|
print(remoteName)
|
||||||
|
} else {
|
||||||
|
print(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ import ArgumentParser
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
fileprivate struct VMInfo: Encodable {
|
fileprivate struct VMInfo: Encodable {
|
||||||
|
let OS: OS
|
||||||
let CPU: Int
|
let CPU: Int
|
||||||
let Memory: UInt64
|
let Memory: UInt64
|
||||||
let Disk: Int
|
let Disk: Int
|
||||||
|
let Size: String
|
||||||
let Display: String
|
let Display: String
|
||||||
let Running: Bool
|
let Running: Bool
|
||||||
let State: String
|
let State: String
|
||||||
@@ -13,7 +15,7 @@ fileprivate struct VMInfo: Encodable {
|
|||||||
struct Get: AsyncParsableCommand {
|
struct Get: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
||||||
|
|
||||||
@Argument(help: "VM name.")
|
@Argument(help: "VM name.", completion: .custom(completeLocalMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Option(help: "Output format: text or json")
|
@Option(help: "Output format: text or json")
|
||||||
@@ -22,11 +24,9 @@ struct Get: AsyncParsableCommand {
|
|||||||
func run() async throws {
|
func run() async throws {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
let diskSizeInGb = try vmDir.sizeGB()
|
|
||||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||||
|
|
||||||
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb,
|
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
|
||||||
Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state())
|
|
||||||
print(format.renderSingle(info))
|
print(format.renderSingle(info))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
|||||||
struct IP: AsyncParsableCommand {
|
struct IP: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Option(help: "Number of seconds to wait for a potential VM booting")
|
@Option(help: "Number of seconds to wait for a potential VM booting")
|
||||||
@@ -61,7 +61,7 @@ struct IP: AsyncParsableCommand {
|
|||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
case .dhcp:
|
case .dhcp:
|
||||||
if let leases = try Leases(), let ip = try leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import SwiftUI
|
|||||||
fileprivate struct VMInfo: Encodable {
|
fileprivate struct VMInfo: Encodable {
|
||||||
let Source: String
|
let Source: String
|
||||||
let Name: String
|
let Name: String
|
||||||
|
let Disk: Int
|
||||||
let Size: Int
|
let Size: Int
|
||||||
|
let SizeOnDisk: Int
|
||||||
let Running: Bool
|
let Running: Bool
|
||||||
let State: String
|
let State: String
|
||||||
}
|
}
|
||||||
@@ -37,13 +39,13 @@ struct List: AsyncParsableCommand {
|
|||||||
|
|
||||||
if source == nil || source == "local" {
|
if source == nil || source == "local" {
|
||||||
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
||||||
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
|
try VMInfo(Source: "local", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if source == nil || source == "oci" {
|
if source == nil || source == "oci" {
|
||||||
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
||||||
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
|
try VMInfo(Source: "OCI", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ struct Login: AsyncParsableCommand {
|
|||||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||||
var insecure: Bool = false
|
var insecure: Bool = false
|
||||||
|
|
||||||
|
@Flag(help: "skip validation of the registry's credentials before logging-in")
|
||||||
|
var noValidate: Bool = false
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
let usernameProvided = username != nil
|
let usernameProvided = username != nil
|
||||||
let passwordProvided = passwordStdin
|
let passwordProvided = passwordStdin
|
||||||
@@ -45,12 +48,15 @@ struct Login: AsyncParsableCommand {
|
|||||||
host: (user, password)
|
host: (user, password)
|
||||||
])
|
])
|
||||||
|
|
||||||
do {
|
if !noValidate {
|
||||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||||
credentialsProviders: [credentialsProvider])
|
credentialsProviders: [credentialsProvider])
|
||||||
try await registry.ping()
|
|
||||||
} catch {
|
do {
|
||||||
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
try await registry.ping()
|
||||||
|
} catch {
|
||||||
|
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Dispatch
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct Logout: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(abstract: "Logout from a registry")
|
||||||
|
|
||||||
|
@Argument(help: "host")
|
||||||
|
var host: String
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
try KeychainCredentialsProvider().remove(host: host)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ struct Prune: AsyncParsableCommand {
|
|||||||
var prunablesToDelete: [Prunable] = []
|
var prunablesToDelete: [Prunable] = []
|
||||||
|
|
||||||
for prunable in prunables {
|
for prunable in prunables {
|
||||||
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
|
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
|
||||||
|
|
||||||
if prunableSizeBytes <= spaceBudgetBytes {
|
if prunableSizeBytes <= spaceBudgetBytes {
|
||||||
// Don't mark for deletion as
|
// Don't mark for deletion as
|
||||||
@@ -158,7 +158,7 @@ struct Prune: AsyncParsableCommand {
|
|||||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||||
|
|
||||||
// Does it even make sense to start?
|
// Does it even make sense to start?
|
||||||
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
let cacheUsedBytes = try prunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
|
||||||
if cacheUsedBytes < reclaimBytes {
|
if cacheUsedBytes < reclaimBytes {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -177,9 +177,9 @@ struct Prune: AsyncParsableCommand {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
try SentrySDK.span?.setData(value: prunable.sizeBytes(), key: prunable.url.path)
|
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path)
|
||||||
|
|
||||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
cacheReclaimedBytes += try prunable.allocatedSizeBytes()
|
||||||
|
|
||||||
try prunable.delete()
|
try prunable.delete()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ struct Pull: AsyncParsableCommand {
|
|||||||
Pulls a virtual machine from a remote OCI-compatible registry. Supports authorization via Keychain (see "tart login --help"),
|
Pulls a virtual machine from a remote OCI-compatible registry. Supports authorization via Keychain (see "tart login --help"),
|
||||||
Docker credential helpers defined in ~/.docker/config.json or via TART_REGISTRY_USERNAME/TART_REGISTRY_PASSWORD environment variables.
|
Docker credential helpers defined in ~/.docker/config.json or via TART_REGISTRY_USERNAME/TART_REGISTRY_PASSWORD environment variables.
|
||||||
|
|
||||||
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image to fit via "tart prune".
|
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image
|
||||||
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
|
to fit. This behaviour is called "automatic pruning" and can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +20,18 @@ struct Pull: AsyncParsableCommand {
|
|||||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||||
var insecure: Bool = false
|
var insecure: Bool = false
|
||||||
|
|
||||||
|
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
|
||||||
|
var concurrency: UInt = 4
|
||||||
|
|
||||||
|
@Flag(help: .hidden)
|
||||||
|
var deduplicate: Bool = false
|
||||||
|
|
||||||
|
func validate() throws {
|
||||||
|
if concurrency < 1 {
|
||||||
|
throw ValidationError("network concurrency cannot be less than 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
// Be more liberal when accepting local image as argument,
|
// Be more liberal when accepting local image as argument,
|
||||||
// see https://github.com/cirruslabs/tart/issues/36
|
// see https://github.com/cirruslabs/tart/issues/36
|
||||||
@@ -34,6 +46,6 @@ struct Pull: AsyncParsableCommand {
|
|||||||
|
|
||||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||||
|
|
||||||
try await VMStorageOCI().pull(remoteName, registry: registry)
|
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Compression
|
|||||||
struct Push: AsyncParsableCommand {
|
struct Push: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
|
static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
|
||||||
|
|
||||||
@Argument(help: "local or remote VM name")
|
@Argument(help: "local or remote VM name", completion: .custom(completeMachines))
|
||||||
var localName: String
|
var localName: String
|
||||||
|
|
||||||
@Argument(help: "remote VM name(s)")
|
@Argument(help: "remote VM name(s)")
|
||||||
@@ -15,6 +15,9 @@ struct Push: AsyncParsableCommand {
|
|||||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||||
var insecure: Bool = false
|
var insecure: Bool = false
|
||||||
|
|
||||||
|
@Option(help: "network concurrency to use when pushing a local VM to the OCI-compatible registry")
|
||||||
|
var concurrency: UInt = 4
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
|
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
|
||||||
discussion: """
|
discussion: """
|
||||||
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
|
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
|
||||||
@@ -23,6 +26,9 @@ struct Push: AsyncParsableCommand {
|
|||||||
"""))
|
"""))
|
||||||
var chunkSize: Int = 0
|
var chunkSize: Int = 0
|
||||||
|
|
||||||
|
@Option(help: .hidden)
|
||||||
|
var diskFormat: String = "v2"
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp("cache pushed images locally",
|
@Flag(help: ArgumentHelp("cache pushed images locally",
|
||||||
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
|
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
|
||||||
var populateCache: Bool = false
|
var populateCache: Bool = false
|
||||||
@@ -30,6 +36,10 @@ struct Push: AsyncParsableCommand {
|
|||||||
func run() async throws {
|
func run() async throws {
|
||||||
let ociStorage = VMStorageOCI()
|
let ociStorage = VMStorageOCI()
|
||||||
let localVMDir = try VMStorageHelper.open(localName)
|
let localVMDir = try VMStorageHelper.open(localName)
|
||||||
|
let lock = try localVMDir.lock()
|
||||||
|
if try !lock.trylock() {
|
||||||
|
throw RuntimeError.VMIsRunning(localName)
|
||||||
|
}
|
||||||
|
|
||||||
// Parse remote names supplied by the user
|
// Parse remote names supplied by the user
|
||||||
let remoteNames = try remoteNames.map{
|
let remoteNames = try remoteNames.map{
|
||||||
@@ -69,7 +79,9 @@ struct Push: AsyncParsableCommand {
|
|||||||
pushedRemoteName = try await localVMDir.pushToRegistry(
|
pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||||
registry: registry,
|
registry: registry,
|
||||||
references: references,
|
references: references,
|
||||||
chunkSizeMb: chunkSize
|
chunkSizeMb: chunkSize,
|
||||||
|
diskFormat: diskFormat,
|
||||||
|
concurrency: concurrency
|
||||||
)
|
)
|
||||||
// Populate the local cache (if requested)
|
// Populate the local cache (if requested)
|
||||||
if populateCache {
|
if populateCache {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import ArgumentParser
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Rename: AsyncParsableCommand {
|
struct Rename: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Rename a VM")
|
static var configuration = CommandConfiguration(abstract: "Rename a local VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Argument(help: "new VM name")
|
@Argument(help: "new VM name")
|
||||||
@@ -20,11 +20,11 @@ struct Rename: AsyncParsableCommand {
|
|||||||
let localStorage = VMStorageLocal()
|
let localStorage = VMStorageLocal()
|
||||||
|
|
||||||
if !localStorage.exists(name) {
|
if !localStorage.exists(name) {
|
||||||
throw ValidationError("failed to rename a non-existent VM: \(name)")
|
throw ValidationError("failed to rename a non-existent local VM: \(name)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if localStorage.exists(newName) {
|
if localStorage.exists(newName) {
|
||||||
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
|
throw ValidationError("failed to rename VM \(name), target VM \(newName) already exists, delete it first!")
|
||||||
}
|
}
|
||||||
|
|
||||||
try localStorage.rename(name, newName)
|
try localStorage.rename(name, newName)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
struct Set: AsyncParsableCommand {
|
struct Set: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
|
static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Option(help: "Number of VM CPUs")
|
@Option(help: "Number of VM CPUs")
|
||||||
@@ -16,7 +17,29 @@ struct Set: AsyncParsableCommand {
|
|||||||
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||||
var display: VMDisplayConfig?
|
var display: VMDisplayConfig?
|
||||||
|
|
||||||
@Option(help: .hidden)
|
@Flag(help: ArgumentHelp("Generate a new random MAC address for the VM."))
|
||||||
|
var randomMAC: Bool = false
|
||||||
|
|
||||||
|
#if arch(arm64)
|
||||||
|
@Flag(help: ArgumentHelp("Generate a new random serial number for the macOS VM."))
|
||||||
|
#endif
|
||||||
|
var randomSerial: Bool = false
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Replace the VM's disk contents with the disk contents at path.", valueName: "path"))
|
||||||
|
var disk: String?
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Resize the VMs disk to the specified size in GB (note that the disk size can only be increased to avoid losing data)",
|
||||||
|
discussion: """
|
||||||
|
Disk resizing works on most cloud-ready Linux distributions out-of-the box (e.g. Ubuntu Cloud Images
|
||||||
|
have the \"cloud-initramfs-growroot\" package installed that runs on boot) and on the rest of the
|
||||||
|
distributions by running the \"growpart\" or \"resize2fs\" commands.
|
||||||
|
|
||||||
|
For macOS, however, things are a bit more complicated: you need to remove the recovery partition
|
||||||
|
first and then run various \"diskutil\" commands, see Tart's packer plugin source code for more
|
||||||
|
details[1].
|
||||||
|
|
||||||
|
[1]: https://github.com/cirruslabs/packer-plugin-tart/blob/main/builder/tart/step_disk_resize.go
|
||||||
|
"""))
|
||||||
var diskSize: UInt16?
|
var diskSize: UInt16?
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
@@ -40,8 +63,27 @@ struct Set: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if randomMAC {
|
||||||
|
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||||
|
}
|
||||||
|
|
||||||
|
#if arch(arm64)
|
||||||
|
if randomSerial {
|
||||||
|
let oldPlatform = vmConfig.platform as! Darwin
|
||||||
|
vmConfig.platform = Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: oldPlatform.hardwareModel)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
try vmConfig.save(toURL: vmDir.configURL)
|
try vmConfig.save(toURL: vmDir.configURL)
|
||||||
|
|
||||||
|
if let disk = disk {
|
||||||
|
let temporaryDiskURL = try Config().tartTmpDir.appendingPathComponent("set-disk-\(UUID().uuidString)")
|
||||||
|
|
||||||
|
try FileManager.default.copyItem(atPath: disk, toPath: temporaryDiskURL.path())
|
||||||
|
|
||||||
|
_ = try FileManager.default.replaceItemAt(vmDir.diskURL, withItemAt: temporaryDiskURL)
|
||||||
|
}
|
||||||
|
|
||||||
if diskSize != nil {
|
if diskSize != nil {
|
||||||
try vmDir.resizeDisk(diskSize!)
|
try vmDir.resizeDisk(diskSize!)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import SwiftDate
|
|||||||
struct Stop: AsyncParsableCommand {
|
struct Stop: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
|
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
|
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
|
||||||
@@ -15,12 +15,12 @@ struct Stop: AsyncParsableCommand {
|
|||||||
func run() async throws {
|
func run() async throws {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
switch try vmDir.state() {
|
switch try vmDir.state() {
|
||||||
case "suspended":
|
case .Suspended:
|
||||||
try stopSuspended(vmDir)
|
try stopSuspended(vmDir)
|
||||||
case "running":
|
case .Running:
|
||||||
try await stopRunning(vmDir)
|
try await stopRunning(vmDir)
|
||||||
default:
|
case .Stopped:
|
||||||
return
|
throw RuntimeError.VMNotRunning(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,12 +29,12 @@ struct Stop: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopRunning(_ vmDir: VMDirectory) async throws {
|
func stopRunning(_ vmDir: VMDirectory) async throws {
|
||||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
let lock = try vmDir.lock()
|
||||||
|
|
||||||
// Find the VM's PID
|
// Find the VM's PID
|
||||||
var pid = try lock.pid()
|
var pid = try lock.pid()
|
||||||
if pid == 0 {
|
if pid == 0 {
|
||||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
throw RuntimeError.VMNotRunning(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to gracefully terminate the VM
|
// Try to gracefully terminate the VM
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ import SwiftDate
|
|||||||
struct Suspend: AsyncParsableCommand {
|
struct Suspend: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM")
|
static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
let lock = try vmDir.lock()
|
||||||
|
|
||||||
// Find the VM's PID
|
// Find the VM's PID
|
||||||
var pid = try lock.pid()
|
let pid = try lock.pid()
|
||||||
if pid == 0 {
|
if pid == 0 {
|
||||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
|
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
|
||||||
return credentialsFromAuth
|
return credentialsFromAuth
|
||||||
}
|
}
|
||||||
if let helperProgram = config.credHelpers?[host] {
|
if let helperProgram = try config.findCredHelper(host: host) {
|
||||||
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
|
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,15 +39,21 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
||||||
inPipe.fileHandleForWriting.closeFile()
|
inPipe.fileHandleForWriting.closeFile()
|
||||||
|
|
||||||
|
let outputData = try outPipe.fileHandleForReading.readToEnd()
|
||||||
|
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
|
|
||||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||||
|
if let outputData = outputData {
|
||||||
|
print(String(decoding: outputData, as: UTF8.self))
|
||||||
|
}
|
||||||
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
|
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
|
||||||
}
|
}
|
||||||
|
if outputData == nil || outputData?.count == 0 {
|
||||||
|
throw CredentialsProviderError.Failed(message: "Docker helper output is empty!")
|
||||||
|
}
|
||||||
|
|
||||||
let getOutput = try JSONDecoder().decode(
|
let getOutput = try JSONDecoder().decode(DockerGetOutput.self, from: outputData!)
|
||||||
DockerGetOutput.self, from: outPipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
)
|
|
||||||
return (getOutput.Username, getOutput.Secret)
|
return (getOutput.Username, getOutput.Secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +65,26 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
struct DockerConfig: Codable {
|
struct DockerConfig: Codable {
|
||||||
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
|
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
|
||||||
var credHelpers: Dictionary<String, String>? = Dictionary()
|
var credHelpers: Dictionary<String, String>? = Dictionary()
|
||||||
|
|
||||||
|
func findCredHelper(host: String) throws -> String? {
|
||||||
|
// Tart supports wildcards in credHelpers
|
||||||
|
// Similar to what is requested from Docker: https://github.com/docker/cli/issues/2928
|
||||||
|
|
||||||
|
guard let credHelpers else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for (hostPattern, helperProgram) in credHelpers {
|
||||||
|
if (hostPattern == host) {
|
||||||
|
return helperProgram
|
||||||
|
}
|
||||||
|
let compiledPattern = try? Regex(hostPattern)
|
||||||
|
if (try compiledPattern?.wholeMatch(in: host) != nil) {
|
||||||
|
return helperProgram
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DockerAuthConfig: Codable {
|
struct DockerAuthConfig: Codable {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
|||||||
kSecAttrLabel as String: "Tart Credentials",
|
kSecAttrLabel as String: "Tart Credentials",
|
||||||
]
|
]
|
||||||
let value: [String: Any] = [kSecAttrAccount as String: user,
|
let value: [String: Any] = [kSecAttrAccount as String: user,
|
||||||
kSecValueData as String: passwordData,
|
kSecValueData as String: passwordData as Any,
|
||||||
]
|
]
|
||||||
|
|
||||||
let status = SecItemCopyMatching(key as CFDictionary, nil)
|
let status = SecItemCopyMatching(key as CFDictionary, nil)
|
||||||
@@ -61,6 +61,24 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
|||||||
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
|
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func remove(host: String) throws {
|
||||||
|
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||||
|
kSecAttrServer as String: host,
|
||||||
|
kSecAttrLabel as String: "Tart Credentials",
|
||||||
|
]
|
||||||
|
|
||||||
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case errSecSuccess:
|
||||||
|
return
|
||||||
|
case errSecItemNotFound:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
throw CredentialsProviderError.Failed(message: "Failed to remove Keychain item(s): \(status.explanation())")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension OSStatus {
|
extension OSStatus {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class StdinCredentials {
|
|||||||
return (user, password)
|
return (user, password)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String {
|
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 1024, isSensitive: Bool) throws -> String {
|
||||||
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
|
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
|
||||||
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
|
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
|
||||||
throw StdinCredentialsError.CredentialRequired(which: name)
|
throw StdinCredentialsError.CredentialRequired(which: name)
|
||||||
|
|||||||
@@ -3,19 +3,32 @@ import AsyncAlgorithms
|
|||||||
|
|
||||||
fileprivate let urlSession = createURLSession()
|
fileprivate let urlSession = createURLSession()
|
||||||
|
|
||||||
class Fetcher {
|
class DownloadDelegate: NSObject, URLSessionTaskDelegate {
|
||||||
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
let progress: Progress
|
||||||
if viaFile {
|
init(_ progress: Progress) throws {
|
||||||
return try await fetchViaFile(request)
|
self.progress = progress
|
||||||
}
|
|
||||||
|
|
||||||
return try await fetchViaMemory(request)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func fetchViaMemory(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) {
|
||||||
|
self.progress.addChild(task.progress, withPendingUnitCount: self.progress.totalUnitCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Fetcher {
|
||||||
|
static func fetch(_ request: URLRequest, viaFile: Bool = false, progress: Progress? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
|
let delegate = progress != nil ? try DownloadDelegate(progress!) : nil
|
||||||
|
|
||||||
|
if viaFile {
|
||||||
|
return try await fetchViaFile(request, delegate: delegate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await fetchViaMemory(request, delegate: delegate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fetchViaMemory(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||||
|
|
||||||
let (data, response) = try await urlSession.data(for: request)
|
let (data, response) = try await urlSession.data(for: request, delegate: delegate)
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await dataCh.send(data)
|
await dataCh.send(data)
|
||||||
@@ -26,21 +39,21 @@ class Fetcher {
|
|||||||
return (dataCh, response as! HTTPURLResponse)
|
return (dataCh, response as! HTTPURLResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func fetchViaFile(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
private static func fetchViaFile(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||||
|
|
||||||
let (fileURL, response) = try await urlSession.download(for: request)
|
let (fileURL, response) = try await urlSession.download(for: request, delegate: delegate)
|
||||||
|
|
||||||
// Acquire a handle to the downloaded file and then remove it.
|
// Acquire a handle to the downloaded file and then remove it.
|
||||||
//
|
//
|
||||||
// This keeps a working reference to that file, yet we don't
|
// This keeps a working reference to that file, yet we don't
|
||||||
// have to deal with the cleanup any more.
|
// have to deal with the cleanup any more.
|
||||||
let fh = try FileHandle(forReadingFrom: fileURL)
|
let mappedFile = try Data(contentsOf: fileURL, options: [.alwaysMapped])
|
||||||
try FileManager.default.removeItem(at: fileURL)
|
try FileManager.default.removeItem(at: fileURL)
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
while let data = try fh.read(upToCount: 64 * 1024 * 1024) {
|
for chunk in (0 ..< mappedFile.count).chunks(ofCount: 64 * 1024 * 1024) {
|
||||||
await dataCh.send(data)
|
await dataCh.send(mappedFile.subdata(in: chunk))
|
||||||
}
|
}
|
||||||
|
|
||||||
dataCh.finish()
|
dataCh.finish()
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LocalLayerCache {
|
||||||
|
struct DigestInfo {
|
||||||
|
let range: Range<Data.Index>
|
||||||
|
let compressedDigest: String
|
||||||
|
let uncompressedContentDigest: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
let name: String
|
||||||
|
let deduplicatedBytes: UInt64
|
||||||
|
let diskURL: URL
|
||||||
|
|
||||||
|
private let mappedDisk: Data
|
||||||
|
private var digestToRange: [String: DigestInfo] = [:]
|
||||||
|
private var offsetToRange: [UInt64: DigestInfo] = [:]
|
||||||
|
|
||||||
|
init?(_ name: String, _ deduplicatedBytes: UInt64, _ diskURL: URL, _ manifest: OCIManifest) throws {
|
||||||
|
self.name = name
|
||||||
|
self.deduplicatedBytes = deduplicatedBytes
|
||||||
|
self.diskURL = diskURL
|
||||||
|
|
||||||
|
// mmap(2) the disk that contains the layers from the manifest
|
||||||
|
self.mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||||
|
|
||||||
|
// Record the ranges of the disk layers listed in the manifest
|
||||||
|
var offset: UInt64 = 0
|
||||||
|
|
||||||
|
for layer in manifest.layers.filter({ $0.mediaType == diskV2MediaType }) {
|
||||||
|
guard let uncompressedSize = layer.uncompressedSize() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let info = DigestInfo(
|
||||||
|
range: Int(offset)..<Int(offset + uncompressedSize),
|
||||||
|
compressedDigest: layer.digest,
|
||||||
|
uncompressedContentDigest: layer.uncompressedContentDigest()!
|
||||||
|
)
|
||||||
|
self.digestToRange[layer.digest] = info
|
||||||
|
self.offsetToRange[offset] = info
|
||||||
|
|
||||||
|
offset += uncompressedSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findInfo(digest: String, offsetHint: UInt64) -> DigestInfo? {
|
||||||
|
// Layers can have the same digests, for example, empty ones. Let's use the offset hint to make a better guess.
|
||||||
|
if let info = self.offsetToRange[offsetHint], info.compressedDigest == digest {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
return self.digestToRange[digest]
|
||||||
|
}
|
||||||
|
|
||||||
|
func subdata(_ range: Range<Data.Index>) -> Data {
|
||||||
|
return self.mappedDisk.subdata(in: range)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,11 @@ struct ARPCache {
|
|||||||
process.standardInput = FileHandle.nullDevice
|
process.standardInput = FileHandle.nullDevice
|
||||||
|
|
||||||
try process.run()
|
try process.run()
|
||||||
|
|
||||||
|
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
||||||
|
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||||
|
}
|
||||||
|
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
|
|
||||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||||
@@ -60,10 +65,6 @@ struct ARPCache {
|
|||||||
terminationStatus: process.terminationStatus)
|
terminationStatus: process.terminationStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
|
||||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
|
||||||
}
|
|
||||||
|
|
||||||
self.arpCommandOutput = arpCommandOutput
|
self.arpCommandOutput = arpCommandOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
|
import SwiftRadix
|
||||||
|
|
||||||
struct Lease {
|
struct Lease {
|
||||||
var mac: MACAddress
|
var mac: MACAddress
|
||||||
var ip: IPv4Address
|
var ip: IPv4Address
|
||||||
|
var expiresAt: Date
|
||||||
|
|
||||||
init?(fromRawLease: [String : String]) {
|
init?(fromRawLease: [String : String]) {
|
||||||
// Retrieve the required fields
|
// Retrieve the required fields
|
||||||
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
||||||
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
||||||
|
guard let lease = fromRawLease["lease"] else { return nil }
|
||||||
|
|
||||||
// Parse MAC address
|
// Parse MAC address
|
||||||
let hwAddressSplits = hwAddress.split(separator: ",")
|
let hwAddressSplits = hwAddress.split(separator: ",")
|
||||||
@@ -26,7 +30,13 @@ struct Lease {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse expiration timestamp
|
||||||
|
guard let leaseTimestamp = lease.hex?.value else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
self.ip = ip
|
self.ip = ip
|
||||||
self.mac = mac
|
self.mac = mac
|
||||||
|
self.expiresAt = Date(timeIntervalSince1970: TimeInterval(leaseTimestamp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,13 +37,18 @@ class Leases {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(_ fromString: String) throws {
|
init(_ fromString: String) throws {
|
||||||
var leases: [MACAddress : Lease] = Dictionary()
|
let leases = try Self.retrieveRawLeases(fromString).compactMap({ rawLease in
|
||||||
|
Lease(fromRawLease: rawLease)
|
||||||
|
}).filter({ lease in
|
||||||
|
lease.expiresAt.isInFuture
|
||||||
|
}).map({ lease in
|
||||||
|
(lease.mac, lease)
|
||||||
|
})
|
||||||
|
|
||||||
for lease in try Self.retrieveRawLeases(fromString).compactMap({ Lease(fromRawLease: $0) }) {
|
self.leases = Dictionary(leases) { (left, right) in
|
||||||
leases[lease.mac] = lease
|
// When duplicate lease is found, prefer a newer lease over the older one
|
||||||
|
(left.expiresAt > right.expiresAt) ? left : right
|
||||||
}
|
}
|
||||||
|
|
||||||
self.leases = leases
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
|
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
|
||||||
@@ -107,7 +112,7 @@ class Leases {
|
|||||||
return rawLeases
|
return rawLeases
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
func ResolveMACAddress(macAddress: MACAddress) -> IPv4Address? {
|
||||||
leases[macAddress]?.ip
|
leases[macAddress]?.ip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
import Semaphore
|
||||||
|
|
||||||
protocol Network {
|
protocol Network {
|
||||||
func attachment() -> VZNetworkDeviceAttachment
|
func attachments() -> [VZNetworkDeviceAttachment]
|
||||||
func run(_ sema: DispatchSemaphore) throws
|
func run(_ sema: AsyncSemaphore) throws
|
||||||
func stop() async throws
|
func stop() async throws
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Semaphore
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
class NetworkBridged: Network {
|
class NetworkBridged: Network {
|
||||||
let interface: VZBridgedNetworkInterface
|
let interfaces: [VZBridgedNetworkInterface]
|
||||||
|
|
||||||
init(interface: VZBridgedNetworkInterface) {
|
init(interfaces: [VZBridgedNetworkInterface]) {
|
||||||
self.interface = interface
|
self.interfaces = interfaces
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachment() -> VZNetworkDeviceAttachment {
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
VZBridgedNetworkDeviceAttachment(interface: interface)
|
interfaces.map { VZBridgedNetworkDeviceAttachment(interface: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(_ sema: DispatchSemaphore) throws {
|
func run(_ sema: AsyncSemaphore) throws {
|
||||||
// no-op, only used for Softnet
|
// no-op, only used for Softnet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Semaphore
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
class NetworkShared: Network {
|
class NetworkShared: Network {
|
||||||
func attachment() -> VZNetworkDeviceAttachment {
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
VZNATNetworkDeviceAttachment()
|
[VZNATNetworkDeviceAttachment()]
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(_ sema: DispatchSemaphore) throws {
|
func run(_ sema: AsyncSemaphore) throws {
|
||||||
// no-op, only used for Softnet
|
// no-op, only used for Softnet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Foundation
|
|
||||||
import Virtualization
|
|
||||||
import Atomics
|
import Atomics
|
||||||
|
import Foundation
|
||||||
|
import Semaphore
|
||||||
import System
|
import System
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
enum SoftnetError: Error {
|
enum SoftnetError: Error {
|
||||||
case InitializationFailed(why: String)
|
case InitializationFailed(why: String)
|
||||||
@@ -15,7 +16,7 @@ class Softnet: Network {
|
|||||||
|
|
||||||
let vmFD: Int32
|
let vmFD: Int32
|
||||||
|
|
||||||
init(vmMACAddress: String) throws {
|
init(vmMACAddress: String, extraArguments: [String] = []) throws {
|
||||||
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||||
|
|
||||||
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
||||||
@@ -30,7 +31,7 @@ class Softnet: Network {
|
|||||||
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
||||||
|
|
||||||
process.executableURL = try Self.softnetExecutableURL()
|
process.executableURL = try Self.softnetExecutableURL()
|
||||||
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress]
|
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress] + extraArguments
|
||||||
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ class Softnet: Network {
|
|||||||
return executableURL
|
return executableURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(_ sema: DispatchSemaphore) throws {
|
func run(_ sema: AsyncSemaphore) throws {
|
||||||
try process.run()
|
try process.run()
|
||||||
|
|
||||||
monitorTask = Task {
|
monitorTask = Task {
|
||||||
@@ -92,9 +93,9 @@ class Softnet: Network {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachment() -> VZNetworkDeviceAttachment {
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
let fh = FileHandle.init(fileDescriptor: vmFD)
|
let fh = FileHandle.init(fileDescriptor: vmFD)
|
||||||
return VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
return [VZFileHandleNetworkDeviceAttachment(fileHandle: fh)]
|
||||||
}
|
}
|
||||||
|
|
||||||
static func configureSUIDBitIfNeeded() throws {
|
static func configureSUIDBitIfNeeded() throws {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
actor AuthenticationKeeper {
|
||||||
|
var authentication: Authentication? = nil
|
||||||
|
|
||||||
|
func set(_ authentication: Authentication) {
|
||||||
|
self.authentication = authentication
|
||||||
|
}
|
||||||
|
|
||||||
|
func header() -> (String, String)? {
|
||||||
|
if let authentication = authentication {
|
||||||
|
// Do not suggest any headers if the
|
||||||
|
// authentication token has expired
|
||||||
|
if !authentication.isValid() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return authentication.header()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not suggest any headers if the
|
||||||
|
// authentication token is not set
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
enum DigestError: Error {
|
||||||
|
case InvalidOffset
|
||||||
|
case InvalidSize
|
||||||
|
}
|
||||||
|
|
||||||
class Digest {
|
class Digest {
|
||||||
var hash: SHA256 = SHA256()
|
var hash: SHA256 = SHA256()
|
||||||
|
|
||||||
@@ -15,6 +20,37 @@ class Digest {
|
|||||||
static func hash(_ data: Data) -> String {
|
static func hash(_ data: Data) -> String {
|
||||||
SHA256.hash(data: data).hexdigest()
|
SHA256.hash(data: data).hexdigest()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func hash(_ url: URL) throws -> String {
|
||||||
|
hash(try Data(contentsOf: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hash(_ url: URL, offset: UInt64, size: UInt64) throws -> String {
|
||||||
|
// Sanity check
|
||||||
|
let fhSanity = try FileHandle(forReadingFrom: url)
|
||||||
|
try fhSanity.seekToEnd()
|
||||||
|
let fileSize = try fhSanity.offset()
|
||||||
|
try fhSanity.close()
|
||||||
|
|
||||||
|
if offset > fileSize {
|
||||||
|
throw DigestError.InvalidOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset + size) > fileSize {
|
||||||
|
throw DigestError.InvalidSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a chunk of size ``size`` at offset ``offset``
|
||||||
|
// and calculate it's digest
|
||||||
|
let fh = try FileHandle(forReadingFrom: url)
|
||||||
|
defer { try! fh.close() }
|
||||||
|
|
||||||
|
try fh.seek(toOffset: offset)
|
||||||
|
|
||||||
|
let data = try fh.read(upToCount: Int(size))!
|
||||||
|
|
||||||
|
return hash(data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SHA256.Digest {
|
extension SHA256.Digest {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol Disk {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Foundation
|
||||||
|
import Compression
|
||||||
|
|
||||||
|
class DiskV1: Disk {
|
||||||
|
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||||
|
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||||
|
|
||||||
|
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||||
|
var pushedLayers: [OCIManifestLayer] = []
|
||||||
|
|
||||||
|
// Open the disk file
|
||||||
|
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||||
|
var mappedDiskReadOffset = 0
|
||||||
|
|
||||||
|
// Compress the disk file as a single stream
|
||||||
|
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
||||||
|
// Determine the size of the next chunk
|
||||||
|
let bytesRead = min(length, mappedDisk.count - mappedDiskReadOffset)
|
||||||
|
|
||||||
|
// Read the next uncompressed chunk
|
||||||
|
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
||||||
|
|
||||||
|
// Advance the offset
|
||||||
|
mappedDiskReadOffset += bytesRead
|
||||||
|
|
||||||
|
// Provide the uncompressed chunk to the compressing filter
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cut the compressed stream into layers, each equal exactly ``Self.layerLimitBytes`` bytes,
|
||||||
|
// except for the last one, which may be smaller
|
||||||
|
while let compressedData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
||||||
|
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||||
|
|
||||||
|
pushedLayers.append(OCIManifestLayer(
|
||||||
|
mediaType: diskV1MediaType,
|
||||||
|
size: compressedData.count,
|
||||||
|
digest: layerDigest
|
||||||
|
))
|
||||||
|
|
||||||
|
// Update progress using an absolute value
|
||||||
|
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pushedLayers
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws {
|
||||||
|
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||||
|
throw OCIError.FailedToCreateVmFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the disk file
|
||||||
|
let disk = try FileHandle(forWritingTo: diskURL)
|
||||||
|
defer { try! disk.close() }
|
||||||
|
|
||||||
|
// Decompress the layers onto the disk in a single stream
|
||||||
|
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||||
|
if let data = data {
|
||||||
|
disk.write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for diskLayer in diskLayers {
|
||||||
|
try await registry.pullBlob(diskLayer.digest) { data in
|
||||||
|
try filter.write(data)
|
||||||
|
|
||||||
|
// Update the progress
|
||||||
|
progress.completedUnitCount += Int64(data.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try filter.finalize()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import Foundation
|
||||||
|
import Compression
|
||||||
|
import System
|
||||||
|
import Retry
|
||||||
|
|
||||||
|
class DiskV2: Disk {
|
||||||
|
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||||
|
private static let layerLimitBytes = 512 * 1024 * 1024
|
||||||
|
|
||||||
|
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||||
|
var pushedLayers: [(index: Int, pushedLayer: OCIManifestLayer)] = []
|
||||||
|
|
||||||
|
// Open the disk file
|
||||||
|
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||||
|
|
||||||
|
// Compress the disk file as multiple individually decompressible streams,
|
||||||
|
// each equal ``Self.layerLimitBytes`` bytes or less due to LZ4 compression
|
||||||
|
try await withThrowingTaskGroup(of: (Int, OCIManifestLayer).self) { group in
|
||||||
|
for (index, data) in mappedDisk.chunks(ofCount: layerLimitBytes).enumerated() {
|
||||||
|
// Respect the concurrency limit
|
||||||
|
if index >= concurrency {
|
||||||
|
if let (index, pushedLayer) = try await group.next() {
|
||||||
|
pushedLayers.append((index, pushedLayer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch a disk layer pushing task
|
||||||
|
group.addTask {
|
||||||
|
let compressedData = try (data as NSData).compressed(using: .lz4) as Data
|
||||||
|
let compressedDataDigest = Digest.hash(compressedData)
|
||||||
|
|
||||||
|
try await retry(maxAttempts: 5, backoff: .exponentialWithFullJitter(baseDelay: .seconds(5), maxDelay: .seconds(60))) {
|
||||||
|
if try await !registry.blobExists(compressedDataDigest) {
|
||||||
|
_ = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb, digest: compressedDataDigest)
|
||||||
|
}
|
||||||
|
} recoverFromFailure: { error in
|
||||||
|
if error is URLError {
|
||||||
|
print("Error: \(error.localizedDescription)")
|
||||||
|
print("Attempting to re-try...")
|
||||||
|
|
||||||
|
return .retry
|
||||||
|
}
|
||||||
|
|
||||||
|
return .throw
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress using a relative value
|
||||||
|
progress.completedUnitCount += Int64(data.count)
|
||||||
|
|
||||||
|
return (index, OCIManifestLayer(
|
||||||
|
mediaType: diskV2MediaType,
|
||||||
|
size: compressedData.count,
|
||||||
|
digest: compressedDataDigest,
|
||||||
|
uncompressedSize: UInt64(data.count),
|
||||||
|
uncompressedContentDigest: Digest.hash(data)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for try await pushedLayer in group {
|
||||||
|
pushedLayers.append(pushedLayer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pushedLayers.sorted {
|
||||||
|
$0.index < $1.index
|
||||||
|
}.map {
|
||||||
|
$0.pushedLayer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws {
|
||||||
|
// Support resumable pulls
|
||||||
|
let pullResumed = FileManager.default.fileExists(atPath: diskURL.path)
|
||||||
|
|
||||||
|
if !pullResumed {
|
||||||
|
if deduplicate, let localLayerCache = localLayerCache {
|
||||||
|
// Clone the local layer cache's disk and use it as a base, potentially
|
||||||
|
// reducing the space usage since some blocks won't be written at all
|
||||||
|
try FileManager.default.copyItem(at: localLayerCache.diskURL, to: diskURL)
|
||||||
|
} else {
|
||||||
|
// Otherwise create an empty disk
|
||||||
|
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||||
|
throw OCIError.FailedToCreateVmFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the uncompressed disk size
|
||||||
|
var uncompressedDiskSize: UInt64 = 0
|
||||||
|
|
||||||
|
for layer in diskLayers {
|
||||||
|
guard let uncompressedLayerSize = layer.uncompressedSize() else {
|
||||||
|
throw OCIError.LayerIsMissingUncompressedSizeAnnotation
|
||||||
|
}
|
||||||
|
|
||||||
|
uncompressedDiskSize += uncompressedLayerSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate the target disk file so that it will be able
|
||||||
|
// to accomodate the uncompressed disk size
|
||||||
|
let disk = try FileHandle(forWritingTo: diskURL)
|
||||||
|
try disk.truncate(atOffset: uncompressedDiskSize)
|
||||||
|
try disk.close()
|
||||||
|
|
||||||
|
// Determine the file system block size
|
||||||
|
var st = stat()
|
||||||
|
if stat(diskURL.path, &st) == -1 {
|
||||||
|
let details = Errno(rawValue: errno)
|
||||||
|
|
||||||
|
throw RuntimeError.PullFailed("failed to stat(2) disk \(diskURL.path): \(details)")
|
||||||
|
}
|
||||||
|
let fsBlockSize = UInt64(st.st_blksize)
|
||||||
|
|
||||||
|
// Concurrently fetch and decompress layers
|
||||||
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||||
|
var globalDiskWritingOffset: UInt64 = 0
|
||||||
|
|
||||||
|
for (index, diskLayer) in diskLayers.enumerated() {
|
||||||
|
// Respect the concurrency limit
|
||||||
|
if index >= concurrency {
|
||||||
|
try await group.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve layer annotations
|
||||||
|
guard let uncompressedLayerSize = diskLayer.uncompressedSize() else {
|
||||||
|
throw OCIError.LayerIsMissingUncompressedSizeAnnotation
|
||||||
|
}
|
||||||
|
guard let uncompressedLayerContentDigest = diskLayer.uncompressedContentDigest() else {
|
||||||
|
throw OCIError.LayerIsMissingUncompressedDigestAnnotation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the current disk writing offset
|
||||||
|
let diskWritingOffset = globalDiskWritingOffset
|
||||||
|
|
||||||
|
// Launch a fetching and decompression task
|
||||||
|
group.addTask {
|
||||||
|
// No need to fetch and decompress anything if we've already done so
|
||||||
|
if pullResumed {
|
||||||
|
// do not check hash in the condition above to make it lazy e.g. only do expensive calculations if needed
|
||||||
|
if try Digest.hash(diskURL, offset: diskWritingOffset, size: uncompressedLayerSize) == uncompressedLayerContentDigest {
|
||||||
|
// Update the progress
|
||||||
|
progress.completedUnitCount += Int64(diskLayer.size)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the disk file for writing
|
||||||
|
let disk = try FileHandle(forWritingTo: diskURL)
|
||||||
|
|
||||||
|
// Also open the disk file for reading and verifying
|
||||||
|
// its contents in case the local layer cache is used
|
||||||
|
let rdisk: FileHandle? = if deduplicate && localLayerCache != nil {
|
||||||
|
try FileHandle(forReadingFrom: diskURL)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we already have this layer contents in the local layer cache,
|
||||||
|
// or perhaps even on the cloned disk (when the deduplication is enabled)
|
||||||
|
if let localLayerCache = localLayerCache,
|
||||||
|
let localLayerInfo = localLayerCache.findInfo(digest: diskLayer.digest, offsetHint: diskWritingOffset),
|
||||||
|
localLayerInfo.uncompressedContentDigest == uncompressedLayerContentDigest {
|
||||||
|
if deduplicate && localLayerInfo.range.lowerBound == diskWritingOffset {
|
||||||
|
// Do nothing, because the data is already on the disk that we've inherited from
|
||||||
|
} else {
|
||||||
|
// Fulfil the layer contents from the local blob cache
|
||||||
|
let data = localLayerCache.subdata(localLayerInfo.range)
|
||||||
|
_ = try zeroSkippingWrite(disk, rdisk, fsBlockSize, diskWritingOffset, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
try disk.close()
|
||||||
|
|
||||||
|
if let rdisk = rdisk {
|
||||||
|
try rdisk.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the progress
|
||||||
|
progress.completedUnitCount += Int64(diskLayer.size)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var diskWritingOffset = diskWritingOffset
|
||||||
|
|
||||||
|
// Pull and decompress a single layer into the specific offset on disk
|
||||||
|
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||||
|
guard let data = data else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
diskWritingOffset = try zeroSkippingWrite(disk, rdisk, fsBlockSize, diskWritingOffset, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await registry.pullBlob(diskLayer.digest) { data in
|
||||||
|
try filter.write(data)
|
||||||
|
|
||||||
|
// Update the progress
|
||||||
|
progress.completedUnitCount += Int64(data.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
try filter.finalize()
|
||||||
|
|
||||||
|
try disk.close()
|
||||||
|
|
||||||
|
if let rdisk = rdisk {
|
||||||
|
try rdisk.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalDiskWritingOffset += uncompressedLayerSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func zeroSkippingWrite(_ disk: FileHandle, _ rdisk: FileHandle?, _ fsBlockSize: UInt64, _ offset: UInt64, _ data: Data) throws -> UInt64 {
|
||||||
|
let holeGranularityBytes = 64 * 1024
|
||||||
|
|
||||||
|
// A zero chunk for faster than byte-by-byte comparisons
|
||||||
|
//
|
||||||
|
// Assumes that the other Data(...) is equal in size, but it's fine to get a false-negative
|
||||||
|
// on the last block since it costs only 64 KiB of excess data per 500 MB layer.
|
||||||
|
//
|
||||||
|
// Some simple benchmarks ("sync && sudo purge" command was used to negate the disk caching effects):
|
||||||
|
// +--------------------------------------+---------------------------------------------------+
|
||||||
|
// | Operation | time(1) result |
|
||||||
|
// +--------------------------------------+---------------------------------------------------+
|
||||||
|
// | Data(...) == zeroChunk | 2.16s user 11.71s system 73% cpu 18.928 total |
|
||||||
|
// | Data(...).contains(where: {$0 != 0}) | 603.68s user 12.97s system 99% cpu 10:22.85 total |
|
||||||
|
// +--------------------------------------+---------------------------------------------------+
|
||||||
|
let zeroChunk = Data(count: holeGranularityBytes)
|
||||||
|
|
||||||
|
var offset = offset
|
||||||
|
|
||||||
|
for chunk in data.chunks(ofCount: holeGranularityBytes) {
|
||||||
|
// If the local layer cache is used, only write chunks that differ
|
||||||
|
// since the base disk can contain anything at any position
|
||||||
|
if let rdisk = rdisk {
|
||||||
|
// F_PUNCHHOLE requires the holes to be aligned to file system block boundaries
|
||||||
|
let isHoleAligned = (offset % fsBlockSize) == 0 && (UInt64(chunk.count) % fsBlockSize) == 0
|
||||||
|
|
||||||
|
if isHoleAligned && chunk == zeroChunk {
|
||||||
|
var arg = fpunchhole_t(fp_flags: 0, reserved: 0, fp_offset: off_t(offset), fp_length: off_t(chunk.count))
|
||||||
|
|
||||||
|
if fcntl(disk.fileDescriptor, F_PUNCHHOLE, &arg) == -1 {
|
||||||
|
let details = Errno(rawValue: errno)
|
||||||
|
|
||||||
|
throw RuntimeError.PullFailed("failed to punch hole: \(details)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try rdisk.seek(toOffset: offset)
|
||||||
|
let actualContentsOnDisk = try rdisk.read(upToCount: chunk.count)
|
||||||
|
|
||||||
|
if chunk != actualContentsOnDisk {
|
||||||
|
try disk.seek(toOffset: offset)
|
||||||
|
disk.write(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += UInt64(chunk.count)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, only write chunks that are not zero
|
||||||
|
// since the base disk is created from scratch and
|
||||||
|
// is zeroed via truncate(2)
|
||||||
|
if chunk != zeroChunk {
|
||||||
|
try disk.seek(toOffset: offset)
|
||||||
|
disk.write(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += UInt64(chunk.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// OCI manifest and OCI config media types
|
||||||
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
|
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
|
||||||
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
|
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
|
||||||
|
|
||||||
// Annotations
|
// Layer media types
|
||||||
|
let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
|
||||||
|
let diskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
||||||
|
let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2"
|
||||||
|
let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
||||||
|
|
||||||
|
// Manifest annotations
|
||||||
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
||||||
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
|
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
|
||||||
|
|
||||||
|
// Layer annotations
|
||||||
|
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
|
||||||
|
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
|
||||||
|
|
||||||
struct OCIManifest: Codable, Equatable {
|
struct OCIManifest: Codable, Equatable {
|
||||||
var schemaVersion: Int = 2
|
var schemaVersion: Int = 2
|
||||||
var mediaType: String = ociManifestMediaType
|
var mediaType: String = ociManifestMediaType
|
||||||
@@ -67,10 +78,49 @@ struct OCIManifestConfig: Codable, Equatable {
|
|||||||
var digest: String
|
var digest: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct OCIManifestLayer: Codable, Equatable {
|
struct OCIManifestLayer: Codable, Equatable, Hashable {
|
||||||
var mediaType: String
|
var mediaType: String
|
||||||
var size: Int
|
var size: Int
|
||||||
var digest: String
|
var digest: String
|
||||||
|
var annotations: Dictionary<String, String>?
|
||||||
|
|
||||||
|
init(mediaType: String, size: Int, digest: String, uncompressedSize: UInt64? = nil, uncompressedContentDigest: String? = nil) {
|
||||||
|
self.mediaType = mediaType
|
||||||
|
self.size = size
|
||||||
|
self.digest = digest
|
||||||
|
|
||||||
|
var annotations: [String: String] = [:]
|
||||||
|
|
||||||
|
if let uncompressedSize = uncompressedSize {
|
||||||
|
annotations[uncompressedSizeAnnotation] = String(uncompressedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let uncompressedContentDigest = uncompressedContentDigest {
|
||||||
|
annotations[uncompressedContentDigestAnnotation] = uncompressedContentDigest
|
||||||
|
}
|
||||||
|
|
||||||
|
self.annotations = annotations
|
||||||
|
}
|
||||||
|
|
||||||
|
func uncompressedSize() -> UInt64? {
|
||||||
|
guard let value = annotations?[uncompressedSizeAnnotation] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return UInt64(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uncompressedContentDigest() -> String? {
|
||||||
|
annotations?[uncompressedContentDigestAnnotation]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||||
|
return lhs.digest == rhs.digest
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(digest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Descriptor: Equatable {
|
struct Descriptor: Equatable {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated from java-escape by ANTLR 4.11.1
|
// Generated from Reference.g4 by ANTLR 4.13.2
|
||||||
|
|
||||||
import Antlr4
|
import Antlr4
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated from java-escape by ANTLR 4.11.1
|
// Generated from Reference.g4 by ANTLR 4.13.2
|
||||||
import Antlr4
|
import Antlr4
|
||||||
|
|
||||||
open class ReferenceLexer: Lexer {
|
open class ReferenceLexer: Lexer {
|
||||||
@@ -49,7 +49,7 @@ open class ReferenceLexer: Lexer {
|
|||||||
|
|
||||||
public
|
public
|
||||||
required init(_ input: CharStream) {
|
required init(_ input: CharStream) {
|
||||||
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
RuntimeMetaData.checkVersion("4.13.2", RuntimeMetaData.VERSION)
|
||||||
super.init(input)
|
super.init(input)
|
||||||
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
|
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated from java-escape by ANTLR 4.11.1
|
// Generated from Reference.g4 by ANTLR 4.13.2
|
||||||
import Antlr4
|
import Antlr4
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated from java-escape by ANTLR 4.11.1
|
// Generated from Reference.g4 by ANTLR 4.13.2
|
||||||
import Antlr4
|
import Antlr4
|
||||||
|
|
||||||
open class ReferenceParser: Parser {
|
open class ReferenceParser: Parser {
|
||||||
@@ -41,7 +41,7 @@ open class ReferenceParser: Parser {
|
|||||||
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
||||||
|
|
||||||
override open
|
override open
|
||||||
func getGrammarFileName() -> String { return "java-escape" }
|
func getGrammarFileName() -> String { return "Reference.g4" }
|
||||||
|
|
||||||
override open
|
override open
|
||||||
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
|
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
|
||||||
@@ -60,7 +60,7 @@ open class ReferenceParser: Parser {
|
|||||||
|
|
||||||
override public
|
override public
|
||||||
init(_ input:TokenStream) throws {
|
init(_ input:TokenStream) throws {
|
||||||
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
RuntimeMetaData.checkVersion("4.13.2", RuntimeMetaData.VERSION)
|
||||||
try super.init(input)
|
try super.init(input)
|
||||||
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
|
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
|
||||||
}
|
}
|
||||||
@@ -460,7 +460,7 @@ open class ReferenceParser: Parser {
|
|||||||
setState(63)
|
setState(63)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
if ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
if (((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
|
||||||
setState(62)
|
setState(62)
|
||||||
try separator()
|
try separator()
|
||||||
|
|
||||||
@@ -611,7 +611,7 @@ open class ReferenceParser: Parser {
|
|||||||
setState(84)
|
setState(84)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
while ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
while (((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
|
||||||
setState(79)
|
setState(79)
|
||||||
try separator()
|
try separator()
|
||||||
setState(80)
|
setState(80)
|
||||||
@@ -664,7 +664,7 @@ open class ReferenceParser: Parser {
|
|||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(87)
|
setState(87)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
if (!((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
|
if (!(((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0))) {
|
||||||
try _errHandler.recoverInline(self)
|
try _errHandler.recoverInline(self)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ enum RegistryError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum HTTPMethod: String {
|
enum HTTPMethod: String {
|
||||||
|
case HEAD = "HEAD"
|
||||||
case GET = "GET"
|
case GET = "GET"
|
||||||
case POST = "POST"
|
case POST = "POST"
|
||||||
case PUT = "PUT"
|
case PUT = "PUT"
|
||||||
@@ -21,6 +22,7 @@ enum HTTPCode: Int {
|
|||||||
case Created = 201
|
case Created = 201
|
||||||
case Accepted = 202
|
case Accepted = 202
|
||||||
case Unauthorized = 401
|
case Unauthorized = 401
|
||||||
|
case NotFound = 404
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Data {
|
extension Data {
|
||||||
@@ -42,9 +44,6 @@ extension AsyncThrowingChannel<Data, Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TokenResponse: Decodable, Authentication {
|
struct TokenResponse: Decodable, Authentication {
|
||||||
let defaultIssuedAt = Date()
|
|
||||||
let defaultExpiresIn = 60
|
|
||||||
|
|
||||||
var token: String?
|
var token: String?
|
||||||
var accessToken: String?
|
var accessToken: String?
|
||||||
var expiresIn: Int?
|
var expiresIn: Int?
|
||||||
@@ -66,7 +65,8 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
return dateFormatter.date(from: dateString) ?? Date()
|
return dateFormatter.date(from: dateString) ?? Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = try decoder.decode(TokenResponse.self, from: fromData)
|
var response = try decoder.decode(TokenResponse.self, from: fromData)
|
||||||
|
response.issuedAt = response.issuedAt ?? Date()
|
||||||
|
|
||||||
guard response.token != nil || response.accessToken != nil else {
|
guard response.token != nil || response.accessToken != nil else {
|
||||||
throw DecodingError.keyNotFound(CodingKeys.token, .init(codingPath: [], debugDescription: "Missing token or access_token. One must be present."))
|
throw DecodingError.keyNotFound(CodingKeys.token, .init(codingPath: [], debugDescription: "Missing token or access_token. One must be present."))
|
||||||
@@ -85,7 +85,7 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
//
|
//
|
||||||
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
|
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
|
||||||
|
|
||||||
(issuedAt ?? defaultIssuedAt) + TimeInterval(expiresIn ?? defaultExpiresIn)
|
(issuedAt ?? Date()) + TimeInterval(expiresIn ?? 60)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,8 +102,7 @@ class Registry {
|
|||||||
private let baseURL: URL
|
private let baseURL: URL
|
||||||
let namespace: String
|
let namespace: String
|
||||||
let credentialsProviders: [CredentialsProvider]
|
let credentialsProviders: [CredentialsProvider]
|
||||||
|
let authenticationKeeper = AuthenticationKeeper()
|
||||||
var currentAuthToken: Authentication? = nil
|
|
||||||
|
|
||||||
var host: String? {
|
var host: String? {
|
||||||
guard let host = baseURL.host else { return nil }
|
guard let host = baseURL.host else { return nil }
|
||||||
@@ -115,11 +114,11 @@ class Registry {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
init(urlComponents: URLComponents,
|
init(baseURL: URL,
|
||||||
namespace: String,
|
namespace: String,
|
||||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||||
) throws {
|
) throws {
|
||||||
baseURL = urlComponents.url!
|
self.baseURL = baseURL
|
||||||
self.namespace = namespace
|
self.namespace = namespace
|
||||||
self.credentialsProviders = credentialsProviders
|
self.credentialsProviders = credentialsProviders
|
||||||
}
|
}
|
||||||
@@ -133,7 +132,17 @@ class Registry {
|
|||||||
let proto = insecure ? "http" : "https"
|
let proto = insecure ? "http" : "https"
|
||||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||||
|
|
||||||
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProviders: credentialsProviders)
|
guard let baseURL = baseURLComponents.url else {
|
||||||
|
var hint = ""
|
||||||
|
|
||||||
|
if host.hasPrefix("http://") || host.hasPrefix("https://") {
|
||||||
|
hint += ", make sure that it doesn't start with http:// or https://"
|
||||||
|
}
|
||||||
|
|
||||||
|
throw RuntimeError.ImproperlyFormattedHost(host, hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
try self.init(baseURL: baseURL, namespace: namespace, credentialsProviders: credentialsProviders)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ping() async throws {
|
func ping() async throws {
|
||||||
@@ -182,7 +191,7 @@ class Registry {
|
|||||||
return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)!
|
return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)!
|
||||||
}
|
}
|
||||||
|
|
||||||
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
|
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0, digest: String? = nil) async throws -> String {
|
||||||
// Initiate a blob upload
|
// Initiate a blob upload
|
||||||
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
|
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
|
||||||
headers: ["Content-Length": "0"])
|
headers: ["Content-Length": "0"])
|
||||||
@@ -194,7 +203,7 @@ class Registry {
|
|||||||
// Figure out where to upload the blob
|
// Figure out where to upload the blob
|
||||||
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||||
|
|
||||||
let digest = Digest.hash(fromData)
|
let digest = digest ?? Digest.hash(fromData)
|
||||||
|
|
||||||
if chunkSizeMb == 0 {
|
if chunkSizeMb == 0 {
|
||||||
// monolithic upload
|
// monolithic upload
|
||||||
@@ -242,7 +251,20 @@ class Registry {
|
|||||||
return digest
|
return digest
|
||||||
}
|
}
|
||||||
|
|
||||||
public func pullBlob(_ digest: String, handler: (Data) throws -> Void) async throws {
|
public func blobExists(_ digest: String) async throws -> Bool {
|
||||||
|
let (data, response) = try await dataRequest(.HEAD, endpointURL("\(namespace)/blobs/\(digest)"))
|
||||||
|
|
||||||
|
switch response.statusCode {
|
||||||
|
case HTTPCode.Ok.rawValue:
|
||||||
|
return true
|
||||||
|
case HTTPCode.NotFound.rawValue:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "checking blob", code: response.statusCode, details: data.asText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pullBlob(_ digest: String, handler: (Data) async throws -> Void) async throws {
|
||||||
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
|
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
|
||||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||||
let body = try await channel.asData().asText()
|
let body = try await channel.asData().asText()
|
||||||
@@ -253,7 +275,7 @@ class Registry {
|
|||||||
for try await part in channel {
|
for try await part in channel {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
try handler(Data(part))
|
try await handler(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,17 +327,12 @@ class Registry {
|
|||||||
request.httpBody = body
|
request.httpBody = body
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate token if it has expired
|
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile, doAuth: doAuth)
|
||||||
if currentAuthToken?.isValid() == false {
|
|
||||||
currentAuthToken = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
|
||||||
|
|
||||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||||
_ = try await channel.asData()
|
_ = try await channel.asData()
|
||||||
try await auth(response: response)
|
try await auth(response: response)
|
||||||
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile, doAuth: doAuth)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (channel, response)
|
return (channel, response)
|
||||||
@@ -331,7 +348,7 @@ class Registry {
|
|||||||
|
|
||||||
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
||||||
if let (user, password) = try lookupCredentials() {
|
if let (user, password) = try lookupCredentials() {
|
||||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
await authenticationKeeper.set(BasicAuthentication(user: user, password: password))
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -378,7 +395,7 @@ class Registry {
|
|||||||
+ "while retrieving an authentication token", details: data.asText())
|
+ "while retrieving an authentication token", details: data.asText())
|
||||||
}
|
}
|
||||||
|
|
||||||
currentAuthToken = try TokenResponse.parse(fromData: data)
|
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func lookupCredentials() throws -> (String, String)? {
|
private func lookupCredentials() throws -> (String, String)? {
|
||||||
@@ -396,12 +413,13 @@ class Registry {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
private func authAwareRequest(request: URLRequest, viaFile: Bool = false, doAuth: Bool) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
var request = request
|
var request = request
|
||||||
|
|
||||||
if let token = currentAuthToken {
|
if doAuth {
|
||||||
let (name, value) = token.header()
|
if let (name, value) = await authenticationKeeper.header() {
|
||||||
request.addValue(value, forHTTPHeaderField: name)
|
request.addValue(value, forHTTPHeaderField: name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))",
|
request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class PIDLock {
|
|||||||
if fd == -1 {
|
if fd == -1 {
|
||||||
let details = Errno(rawValue: CInt(errno))
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
throw RuntimeError.PIDLockFailed("failed to open lock file \(url): \(details)")
|
throw RuntimeError.PIDLockMissing("failed to open lock file \(url): \(details)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,129 +6,135 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Darwin: PlatformSuspendable {
|
#if arch(arm64)
|
||||||
var ecid: VZMacMachineIdentifier
|
|
||||||
var hardwareModel: VZMacHardwareModel
|
|
||||||
|
|
||||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
struct Darwin: PlatformSuspendable {
|
||||||
self.ecid = ecid
|
var ecid: VZMacMachineIdentifier
|
||||||
self.hardwareModel = hardwareModel
|
var hardwareModel: VZMacHardwareModel
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
self.ecid = ecid
|
||||||
|
self.hardwareModel = hardwareModel
|
||||||
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
|
||||||
guard let data = Data.init(base64Encoded: encodedECID) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
|
||||||
in: container,
|
|
||||||
debugDescription: "failed to initialize Data using the provided value")
|
|
||||||
}
|
|
||||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
|
||||||
in: container,
|
|
||||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
|
||||||
}
|
|
||||||
self.ecid = ecid
|
|
||||||
|
|
||||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
|
||||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
|
||||||
}
|
|
||||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
|
||||||
}
|
|
||||||
self.hardwareModel = hardwareModel
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
|
||||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
func os() -> OS {
|
|
||||||
.darwin
|
|
||||||
}
|
|
||||||
|
|
||||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
|
||||||
VZMacOSBootLoader()
|
|
||||||
}
|
|
||||||
|
|
||||||
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
|
|
||||||
let result = VZMacPlatformConfiguration()
|
|
||||||
|
|
||||||
result.machineIdentifier = ecid
|
|
||||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
|
||||||
|
|
||||||
if !hardwareModel.isSupported {
|
|
||||||
// At the moment support of M1 chip is not yet dropped in any macOS version
|
|
||||||
// This mean that host software is not supporting this hardware model and should be updated
|
|
||||||
throw UnsupportedHostOSError()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.hardwareModel = hardwareModel
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
return result
|
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
||||||
}
|
guard let data = Data.init(base64Encoded: encodedECID) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||||
|
in: container,
|
||||||
|
debugDescription: "failed to initialize Data using the provided value")
|
||||||
|
}
|
||||||
|
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||||
|
in: container,
|
||||||
|
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
||||||
|
}
|
||||||
|
self.ecid = ecid
|
||||||
|
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
||||||
let result = VZMacGraphicsDeviceConfiguration()
|
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||||
|
}
|
||||||
|
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||||
|
}
|
||||||
|
self.hardwareModel = hardwareModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||||
|
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func os() -> OS {
|
||||||
|
.darwin
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||||
|
VZMacOSBootLoader()
|
||||||
|
}
|
||||||
|
|
||||||
|
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
|
||||||
|
if needsNestedVirtualization {
|
||||||
|
throw RuntimeError.VMConfigurationError("macOS virtual machines do not support nested virtualization")
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = VZMacPlatformConfiguration()
|
||||||
|
|
||||||
|
result.machineIdentifier = ecid
|
||||||
|
result.auxiliaryStorage = VZMacAuxiliaryStorage(url: nvramURL)
|
||||||
|
|
||||||
|
if !hardwareModel.isSupported {
|
||||||
|
// At the moment support of M1 chip is not yet dropped in any macOS version
|
||||||
|
// This mean that host software is not supporting this hardware model and should be updated
|
||||||
|
throw UnsupportedHostOSError()
|
||||||
|
}
|
||||||
|
|
||||||
|
result.hardwareModel = hardwareModel
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||||
|
let result = VZMacGraphicsDeviceConfiguration()
|
||||||
|
|
||||||
|
if let hostMainScreen = NSScreen.main {
|
||||||
|
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
||||||
|
result.displays = [
|
||||||
|
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
if let hostMainScreen = NSScreen.main {
|
|
||||||
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
|
||||||
result.displays = [
|
result.displays = [
|
||||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
VZMacGraphicsDisplayConfiguration(
|
||||||
|
widthInPixels: vmConfig.display.width,
|
||||||
|
heightInPixels: vmConfig.display.height,
|
||||||
|
// A reasonable guess according to Apple's documentation[1]
|
||||||
|
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||||
|
pixelsPerInch: 72
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
result.displays = [
|
func keyboards() -> [VZKeyboardConfiguration] {
|
||||||
VZMacGraphicsDisplayConfiguration(
|
if #available(macOS 14, *) {
|
||||||
widthInPixels: vmConfig.display.width,
|
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||||
heightInPixels: vmConfig.display.height,
|
return [VZUSBKeyboardConfiguration(), VZMacKeyboardConfiguration()]
|
||||||
// A reasonable guess according to Apple's documentation[1]
|
} else {
|
||||||
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
return [VZUSBKeyboardConfiguration()]
|
||||||
pixelsPerInch: 72
|
}
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func keyboards() -> [VZKeyboardConfiguration] {
|
|
||||||
if #available(macOS 14, *) {
|
|
||||||
// Mac keyboard is only supported by guests starting with macOS Ventura
|
|
||||||
return [VZMacKeyboardConfiguration(), VZUSBKeyboardConfiguration()]
|
|
||||||
} else {
|
|
||||||
return [VZUSBKeyboardConfiguration()]
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||||
if #available(macOS 14, *) {
|
if #available(macOS 14, *) {
|
||||||
return [VZMacKeyboardConfiguration()]
|
return [VZMacKeyboardConfiguration()]
|
||||||
} else {
|
} else {
|
||||||
return []
|
// fallback to the regular configuration
|
||||||
|
return keyboards()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||||
if #available(macOS 13, *) {
|
|
||||||
// Trackpad is only supported by guests starting with macOS Ventura
|
// Trackpad is only supported by guests starting with macOS Ventura
|
||||||
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
[VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()]
|
||||||
} else {
|
}
|
||||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
|
||||||
|
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||||
|
if #available(macOS 14, *) {
|
||||||
|
return [VZMacTrackpadConfiguration()]
|
||||||
|
} else {
|
||||||
|
// fallback to the regular configuration
|
||||||
|
return pointingDevices()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
#endif
|
||||||
if #available(macOS 13, *) {
|
|
||||||
return [VZMacTrackpadConfiguration()]
|
|
||||||
} else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ struct Linux: Platform {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
|
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
|
||||||
VZGenericPlatformConfiguration()
|
let config = VZGenericPlatformConfiguration()
|
||||||
|
if #available(macOS 15, *) {
|
||||||
|
config.isNestedVirtualizationEnabled = needsNestedVirtualization
|
||||||
|
}
|
||||||
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Virtualization
|
|||||||
protocol Platform: Codable {
|
protocol Platform: Codable {
|
||||||
func os() -> OS
|
func os() -> OS
|
||||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||||
func platform(nvramURL: URL) throws -> VZPlatformConfiguration
|
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||||
func keyboards() -> [VZKeyboardConfiguration]
|
func keyboards() -> [VZKeyboardConfiguration]
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||||
|
|||||||
@@ -8,5 +8,8 @@ protocol Prunable {
|
|||||||
var url: URL { get }
|
var url: URL { get }
|
||||||
func delete() throws
|
func delete() throws
|
||||||
func accessDate() throws -> Date
|
func accessDate() throws -> Date
|
||||||
|
// size on disk as seen in Finder including empty blocks
|
||||||
func sizeBytes() throws -> Int
|
func sizeBytes() throws -> Int
|
||||||
|
// actual size on disk without empty blocks
|
||||||
|
func allocatedSizeBytes() throws -> Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
Get.self,
|
Get.self,
|
||||||
List.self,
|
List.self,
|
||||||
Login.self,
|
Login.self,
|
||||||
|
Logout.self,
|
||||||
IP.self,
|
IP.self,
|
||||||
Pull.self,
|
Pull.self,
|
||||||
Push.self,
|
Push.self,
|
||||||
@@ -25,6 +26,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
Rename.self,
|
Rename.self,
|
||||||
Stop.self,
|
Stop.self,
|
||||||
Delete.self,
|
Delete.self,
|
||||||
|
FQN.self,
|
||||||
])
|
])
|
||||||
|
|
||||||
public static func main() async throws {
|
public static func main() async throws {
|
||||||
@@ -48,6 +50,10 @@ struct Root: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
||||||
|
|
||||||
|
SentrySDK.configureScope { scope in
|
||||||
|
scope.setExtra(value: ProcessInfo.processInfo.arguments, key: "Command-line arguments")
|
||||||
|
}
|
||||||
|
|
||||||
// Enrich future events with Cirrus CI-specific tags
|
// Enrich future events with Cirrus CI-specific tags
|
||||||
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
||||||
SentrySDK.configureScope { scope in
|
SentrySDK.configureScope { scope in
|
||||||
@@ -81,10 +87,12 @@ struct Root: AsyncParsableCommand {
|
|||||||
var command = try parseAsRoot()
|
var command = try parseAsRoot()
|
||||||
|
|
||||||
// Run garbage-collection before each command (shouldn't take too long)
|
// Run garbage-collection before each command (shouldn't take too long)
|
||||||
do {
|
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
|
||||||
try Config().gc()
|
do {
|
||||||
} catch {
|
try Config().gc()
|
||||||
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
|
} catch {
|
||||||
|
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if var asyncCommand = command as? AsyncParsableCommand {
|
if var asyncCommand = command as? AsyncParsableCommand {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
fileprivate func normalizeName(_ name: String) -> String {
|
||||||
|
// Colons are misinterpreted by Zsh completion
|
||||||
|
return name.replacingOccurrences(of: ":", with: "\\:")
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeMachines(_ arguments: [String]) -> [String] {
|
||||||
|
let localVMs = (try? VMStorageLocal().list().map { name, _ in
|
||||||
|
normalizeName(name)
|
||||||
|
}) ?? []
|
||||||
|
let ociVMs = (try? VMStorageOCI().list().map { name, _, _ in
|
||||||
|
normalizeName(name)
|
||||||
|
}) ?? []
|
||||||
|
return (localVMs + ociVMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeLocalMachines(_ arguments: [String]) -> [String] {
|
||||||
|
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||||
|
return localVMs.map { name, _ in normalizeName(name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeRunningMachines(_ arguments: [String]) -> [String] {
|
||||||
|
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||||
|
return localVMs
|
||||||
|
.filter { _, vmDir in (try? vmDir.state() == .Running) ?? false}
|
||||||
|
.map { name, _ in normalizeName(name) }
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import System
|
||||||
|
|
||||||
extension URL {
|
extension URL {
|
||||||
func accessDate() throws -> Date {
|
func accessDate() throws -> Date {
|
||||||
@@ -13,7 +14,9 @@ extension URL {
|
|||||||
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
||||||
let ret = utimes(path, times)
|
let ret = utimes(path, times)
|
||||||
if ret != 0 {
|
if ret != 0 {
|
||||||
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(ret.explanation())")
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(details)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import XAttr
|
||||||
|
|
||||||
extension URL: Prunable {
|
extension URL: Prunable {
|
||||||
var url: URL {
|
var url: URL {
|
||||||
@@ -9,7 +10,35 @@ extension URL: Prunable {
|
|||||||
try FileManager.default.removeItem(at: self)
|
try FileManager.default.removeItem(at: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sizeBytes() throws -> Int {
|
func allocatedSizeBytes() throws -> Int {
|
||||||
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
|
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deduplicatedSizeBytes() throws -> Int {
|
||||||
|
let values = try resourceValues(forKeys: [.totalFileAllocatedSizeKey, .mayShareFileContentKey])
|
||||||
|
// make sure the file's origin file is there and duplication works
|
||||||
|
if values.mayShareFileContent == true {
|
||||||
|
return Int(deduplicatedBytes())
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeBytes() throws -> Int {
|
||||||
|
try resourceValues(forKeys: [.totalFileSizeKey]).totalFileSize!
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDeduplicatedBytes(_ size: UInt64) {
|
||||||
|
let data = "\(size)".data(using: .utf8)!
|
||||||
|
try! self.setExtendedAttribute(name: "run.tart.deduplicated-bytes", value: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deduplicatedBytes() -> UInt64 {
|
||||||
|
guard let data = try? self.extendedAttributeValue(forName: "run.tart.deduplicated-bytes") else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if let strValue = String(data: data, encoding: .utf8) {
|
||||||
|
return UInt64(strValue) ?? 0
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Virtualization
|
import Virtualization
|
||||||
import AsyncAlgorithms
|
import AsyncAlgorithms
|
||||||
|
import Semaphore
|
||||||
|
|
||||||
struct UnsupportedRestoreImageError: Error {
|
struct UnsupportedRestoreImageError: Error {
|
||||||
}
|
}
|
||||||
@@ -30,7 +31,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
var configuration: VZVirtualMachineConfiguration
|
var configuration: VZVirtualMachineConfiguration
|
||||||
|
|
||||||
// Semaphore used to communicate with the VZVirtualMachineDelegate
|
// Semaphore used to communicate with the VZVirtualMachineDelegate
|
||||||
var sema = DispatchSemaphore(value: 0)
|
var sema = AsyncSemaphore(value: 0)
|
||||||
|
|
||||||
// VM's config
|
// VM's config
|
||||||
var name: String
|
var name: String
|
||||||
@@ -42,10 +43,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
init(vmDir: VMDirectory,
|
init(vmDir: VMDirectory,
|
||||||
network: Network = NetworkShared(),
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||||
serialPorts: [VZSerialPortConfiguration] = [],
|
serialPorts: [VZSerialPortConfiguration] = [],
|
||||||
suspendable: Bool = false
|
suspendable: Bool = false,
|
||||||
|
nested: Bool = false,
|
||||||
|
audio: Bool = true,
|
||||||
|
clipboard: Bool = true,
|
||||||
|
sync: VZDiskImageSynchronizationMode = .full
|
||||||
) throws {
|
) throws {
|
||||||
name = vmDir.name
|
name = vmDir.name
|
||||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
@@ -58,10 +63,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
self.network = network
|
self.network = network
|
||||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||||
network: network, additionalDiskAttachments: additionalDiskAttachments,
|
network: network, additionalStorageDevices: additionalStorageDevices,
|
||||||
directorySharingDevices: directorySharingDevices,
|
directorySharingDevices: directorySharingDevices,
|
||||||
serialPorts: serialPorts,
|
serialPorts: serialPorts,
|
||||||
suspendable: suspendable
|
suspendable: suspendable,
|
||||||
|
nested: nested,
|
||||||
|
audio: audio,
|
||||||
|
clipboard: clipboard,
|
||||||
|
sync: sync
|
||||||
)
|
)
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
@@ -71,9 +80,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
|
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
|
||||||
// Check if we already have this IPSW in cache
|
// Check if we already have this IPSW in cache
|
||||||
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
|
var headRequest = URLRequest(url: remoteURL)
|
||||||
|
headRequest.httpMethod = "HEAD"
|
||||||
|
let (_, headResponse) = try await Fetcher.fetch(headRequest, viaFile: false)
|
||||||
|
|
||||||
if let hash = response.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
|
if let hash = headResponse.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
|
||||||
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
|
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: ipswLocation.path) {
|
if FileManager.default.fileExists(atPath: ipswLocation.path) {
|
||||||
@@ -87,10 +98,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
// Download the IPSW
|
// Download the IPSW
|
||||||
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
|
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
|
||||||
|
|
||||||
let progress = Progress(totalUnitCount: response.expectedContentLength)
|
let downloadProgress = Progress(totalUnitCount: 100)
|
||||||
ProgressObserver(progress).log(defaultLogger)
|
ProgressObserver(downloadProgress).log(defaultLogger)
|
||||||
|
|
||||||
|
let request = URLRequest(url: remoteURL)
|
||||||
|
let (channel, response) = try await Fetcher.fetch(request, viaFile: true, progress: downloadProgress)
|
||||||
|
|
||||||
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw")
|
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw")
|
||||||
|
defaultLogger.appendNewLine("Computing digest for \(temporaryLocation.path)...")
|
||||||
|
let digestProgress = Progress(totalUnitCount: response.expectedContentLength)
|
||||||
|
ProgressObserver(digestProgress).log(defaultLogger)
|
||||||
|
|
||||||
FileManager.default.createFile(atPath: temporaryLocation.path, contents: nil)
|
FileManager.default.createFile(atPath: temporaryLocation.path, contents: nil)
|
||||||
let lock = try FileLock(lockURL: temporaryLocation)
|
let lock = try FileLock(lockURL: temporaryLocation)
|
||||||
try lock.lock()
|
try lock.lock()
|
||||||
@@ -102,7 +120,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
let chunkAsData = Data(chunk)
|
let chunkAsData = Data(chunk)
|
||||||
fileHandle.write(chunkAsData)
|
fileHandle.write(chunkAsData)
|
||||||
digest.update(chunkAsData)
|
digest.update(chunkAsData)
|
||||||
progress.completedUnitCount += Int64(chunk.count)
|
digestProgress.completedUnitCount += Int64(chunk.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
try fileHandle.close()
|
try fileHandle.close()
|
||||||
@@ -112,18 +130,6 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
return try FileManager.default.replaceItemAt(finalLocation, withItemAt: temporaryLocation)!
|
return try FileManager.default.replaceItemAt(finalLocation, withItemAt: temporaryLocation)!
|
||||||
}
|
}
|
||||||
|
|
||||||
static func latestIPSWURL() async throws -> URL {
|
|
||||||
defaultLogger.appendNewLine("Looking up the latest supported IPSW...")
|
|
||||||
|
|
||||||
let image = try await withCheckedThrowingContinuation { continuation in
|
|
||||||
VZMacOSRestoreImage.fetchLatestSupported() { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return image.url
|
|
||||||
}
|
|
||||||
|
|
||||||
var inFinalState: Bool {
|
var inFinalState: Bool {
|
||||||
get {
|
get {
|
||||||
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
||||||
@@ -133,82 +139,90 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
#if arch(arm64)
|
||||||
vmDir: VMDirectory,
|
init(
|
||||||
ipswURL: URL,
|
vmDir: VMDirectory,
|
||||||
diskSizeGB: UInt16,
|
ipswURL: URL,
|
||||||
network: Network = NetworkShared(),
|
diskSizeGB: UInt16,
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
network: Network = NetworkShared(),
|
||||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||||
serialPorts: [VZSerialPortConfiguration] = []
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||||
) async throws {
|
serialPorts: [VZSerialPortConfiguration] = []
|
||||||
var ipswURL = ipswURL
|
) async throws {
|
||||||
|
var ipswURL = ipswURL
|
||||||
|
|
||||||
if !ipswURL.isFileURL {
|
if !ipswURL.isFileURL {
|
||||||
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
||||||
}
|
|
||||||
|
|
||||||
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
|
|
||||||
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
|
|
||||||
// cannot deal with paths that contain symlinks, so expand them here first.
|
|
||||||
ipswURL.resolveSymlinksInPath()
|
|
||||||
|
|
||||||
// Load the restore image and try to get the requirements
|
|
||||||
// that match both the image and our platform
|
|
||||||
let image = try await withCheckedThrowingContinuation { continuation in
|
|
||||||
VZMacOSRestoreImage.load(from: ipswURL) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
guard let requirements = image.mostFeaturefulSupportedConfiguration else {
|
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
|
||||||
throw UnsupportedRestoreImageError()
|
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
|
||||||
}
|
// cannot deal with paths that contain symlinks, so expand them here first.
|
||||||
|
ipswURL.resolveSymlinksInPath()
|
||||||
|
|
||||||
// Create NVRAM
|
// Load the restore image and try to get the requirements
|
||||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
// that match both the image and our platform
|
||||||
|
let image = try await withCheckedThrowingContinuation { continuation in
|
||||||
// Create disk
|
VZMacOSRestoreImage.load(from: ipswURL) { result in
|
||||||
try vmDir.resizeDisk(diskSizeGB)
|
|
||||||
|
|
||||||
name = vmDir.name
|
|
||||||
// Create config
|
|
||||||
config = VMConfig(
|
|
||||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
|
||||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
|
||||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
|
||||||
)
|
|
||||||
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
|
||||||
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
|
||||||
try config.save(toURL: vmDir.configURL)
|
|
||||||
|
|
||||||
// Initialize the virtual machine and its configuration
|
|
||||||
self.network = network
|
|
||||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
|
||||||
vmConfig: config, network: network,
|
|
||||||
additionalDiskAttachments: additionalDiskAttachments,
|
|
||||||
directorySharingDevices: directorySharingDevices,
|
|
||||||
serialPorts: serialPorts
|
|
||||||
)
|
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
|
||||||
|
|
||||||
super.init()
|
|
||||||
virtualMachine.delegate = self
|
|
||||||
|
|
||||||
// Run automated installation
|
|
||||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
||||||
DispatchQueue.main.async { [ipswURL] in
|
|
||||||
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: ipswURL)
|
|
||||||
|
|
||||||
defaultLogger.appendNewLine("Installing OS...")
|
|
||||||
ProgressObserver(installer.progress).log(defaultLogger)
|
|
||||||
|
|
||||||
installer.install { result in
|
|
||||||
continuation.resume(with: result)
|
continuation.resume(with: result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let requirements = image.mostFeaturefulSupportedConfiguration else {
|
||||||
|
throw UnsupportedRestoreImageError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create NVRAM
|
||||||
|
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||||
|
|
||||||
|
// Create disk
|
||||||
|
try vmDir.resizeDisk(diskSizeGB)
|
||||||
|
|
||||||
|
name = vmDir.name
|
||||||
|
// Create config
|
||||||
|
config = VMConfig(
|
||||||
|
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||||
|
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||||
|
memorySizeMin: requirements.minimumSupportedMemorySize
|
||||||
|
)
|
||||||
|
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
||||||
|
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
||||||
|
try config.save(toURL: vmDir.configURL)
|
||||||
|
|
||||||
|
// Initialize the virtual machine and its configuration
|
||||||
|
self.network = network
|
||||||
|
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||||
|
vmConfig: config, network: network,
|
||||||
|
additionalStorageDevices: additionalStorageDevices,
|
||||||
|
directorySharingDevices: directorySharingDevices,
|
||||||
|
serialPorts: serialPorts
|
||||||
|
)
|
||||||
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
virtualMachine.delegate = self
|
||||||
|
|
||||||
|
// Run automated installation
|
||||||
|
try await install(ipswURL)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@MainActor
|
||||||
|
private func install(_ url: URL) async throws {
|
||||||
|
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: url)
|
||||||
|
defaultLogger.appendNewLine("Installing OS...")
|
||||||
|
ProgressObserver(installer.progress).log(defaultLogger)
|
||||||
|
|
||||||
|
try await withTaskCancellationHandler(operation: {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
installer.install { result in
|
||||||
|
continuation.resume(with: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, onCancel: {
|
||||||
|
installer.progress.cancel()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@available(macOS 13, *)
|
@available(macOS 13, *)
|
||||||
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
||||||
@@ -236,16 +250,18 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
await withTaskCancellationHandler(operation: {
|
do {
|
||||||
// Wait for the VM to finish running
|
try await sema.waitUnlessCancelled()
|
||||||
// or for the exit condition
|
} catch is CancellationError {
|
||||||
sema.wait()
|
// Triggered by "tart stop", Ctrl+C, or closing the
|
||||||
}, onCancel: {
|
// VM window, so shut down the VM gracefully below.
|
||||||
sema.signal()
|
}
|
||||||
})
|
|
||||||
|
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
try await stop()
|
if (self.virtualMachine.state == VZVirtualMachine.State.running) {
|
||||||
|
print("Stopping VM...")
|
||||||
|
try await stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try await network.stop()
|
try await network.stop()
|
||||||
@@ -253,15 +269,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func start(_ recovery: Bool) async throws {
|
private func start(_ recovery: Bool) async throws {
|
||||||
if #available(macOS 13, *) {
|
#if arch(arm64)
|
||||||
// new API introduced in Ventura
|
|
||||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||||
startOptions.startUpFromMacOSRecovery = recovery
|
startOptions.startUpFromMacOSRecovery = recovery
|
||||||
try await virtualMachine.start(options: startOptions)
|
try await virtualMachine.start(options: startOptions)
|
||||||
} else {
|
#else
|
||||||
// use method that also available on Monterey
|
try await virtualMachine.start()
|
||||||
try await virtualMachine.start(recovery)
|
#endif
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -279,10 +293,14 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
nvramURL: URL,
|
nvramURL: URL,
|
||||||
vmConfig: VMConfig,
|
vmConfig: VMConfig,
|
||||||
network: Network = NetworkShared(),
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
additionalStorageDevices: [VZStorageDeviceConfiguration],
|
||||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||||
serialPorts: [VZSerialPortConfiguration],
|
serialPorts: [VZSerialPortConfiguration],
|
||||||
suspendable: Bool = false
|
suspendable: Bool = false,
|
||||||
|
nested: Bool = false,
|
||||||
|
audio: Bool = true,
|
||||||
|
clipboard: Bool = true,
|
||||||
|
sync: VZDiskImageSynchronizationMode = .full
|
||||||
) throws -> VZVirtualMachineConfiguration {
|
) throws -> VZVirtualMachineConfiguration {
|
||||||
let configuration = VZVirtualMachineConfiguration()
|
let configuration = VZVirtualMachineConfiguration()
|
||||||
|
|
||||||
@@ -294,22 +312,29 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
configuration.memorySize = vmConfig.memorySize
|
configuration.memorySize = vmConfig.memorySize
|
||||||
|
|
||||||
// Platform
|
// Platform
|
||||||
configuration.platform = try vmConfig.platform.platform(nvramURL: nvramURL)
|
configuration.platform = try vmConfig.platform.platform(nvramURL: nvramURL, needsNestedVirtualization: nested)
|
||||||
|
|
||||||
// Display
|
// Display
|
||||||
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
if !suspendable {
|
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
|
||||||
|
if audio && !suspendable {
|
||||||
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
||||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
|
||||||
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
|
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
|
||||||
|
|
||||||
|
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||||
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
|
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
|
||||||
|
|
||||||
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
|
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
|
||||||
configuration.audioDevices = [soundDeviceConfiguration]
|
} else {
|
||||||
|
// just a null speaker
|
||||||
|
soundDeviceConfiguration.streams = [VZVirtioSoundDeviceOutputStreamConfiguration()]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configuration.audioDevices = [soundDeviceConfiguration]
|
||||||
|
|
||||||
// Keyboard and mouse
|
// Keyboard and mouse
|
||||||
if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
|
if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
|
||||||
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
|
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
|
||||||
@@ -320,15 +345,32 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Networking
|
// Networking
|
||||||
let vio = VZVirtioNetworkDeviceConfiguration()
|
configuration.networkDevices = network.attachments().map {
|
||||||
vio.attachment = network.attachment()
|
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||||
vio.macAddress = vmConfig.macAddress
|
vio.attachment = $0
|
||||||
configuration.networkDevices = [vio]
|
vio.macAddress = vmConfig.macAddress
|
||||||
|
return vio
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clipboard sharing via Spice agent
|
||||||
|
if clipboard && vmConfig.os == .linux {
|
||||||
|
let spiceAgentConsoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||||
|
let spiceAgentPort = VZVirtioConsolePortConfiguration()
|
||||||
|
spiceAgentPort.name = VZSpiceAgentPortAttachment.spiceAgentPortName
|
||||||
|
spiceAgentPort.attachment = VZSpiceAgentPortAttachment()
|
||||||
|
spiceAgentConsoleDevice.ports[0] = spiceAgentPort
|
||||||
|
configuration.consoleDevices.append(spiceAgentConsoleDevice)
|
||||||
|
}
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
|
let attachment: VZDiskImageStorageDeviceAttachment = vmConfig.os == .linux ?
|
||||||
attachments.append(contentsOf: additionalDiskAttachments)
|
// Use "cached" caching mode for virtio drive to prevent fs corruption on linux
|
||||||
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
|
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .cached, synchronizationMode: sync) :
|
||||||
|
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .automatic, synchronizationMode: sync)
|
||||||
|
|
||||||
|
var devices: [VZStorageDeviceConfiguration] = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]
|
||||||
|
devices.append(contentsOf: additionalStorageDevices)
|
||||||
|
configuration.storageDevices = devices
|
||||||
|
|
||||||
// Entropy
|
// Entropy
|
||||||
if !suspendable {
|
if !suspendable {
|
||||||
@@ -345,7 +387,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
//
|
//
|
||||||
// A dummy console device useful for implementing
|
// A dummy console device useful for implementing
|
||||||
// host feature checks in the guest agent software.
|
// host feature checks in the guest agent software.
|
||||||
if #available(macOS 13, *) {
|
if !suspendable {
|
||||||
let consolePort = VZVirtioConsolePortConfiguration()
|
let consolePort = VZVirtioConsolePortConfiguration()
|
||||||
consolePort.name = "tart-version-\(CI.version)"
|
consolePort.name = "tart-version-\(CI.version)"
|
||||||
|
|
||||||
|
|||||||
@@ -95,13 +95,16 @@ struct VMConfig: Codable {
|
|||||||
arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
|
arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
|
||||||
switch os {
|
switch os {
|
||||||
case .darwin:
|
case .darwin:
|
||||||
platform = try Darwin(from: decoder)
|
#if arch(arm64)
|
||||||
|
platform = try Darwin(from: decoder)
|
||||||
|
#else
|
||||||
|
throw DecodingError.dataCorruptedError(
|
||||||
|
forKey: .os,
|
||||||
|
in: container,
|
||||||
|
debugDescription: "Darwin VMs are only supported on Apple Silicon hosts")
|
||||||
|
#endif
|
||||||
case .linux:
|
case .linux:
|
||||||
if #available(macOS 13, *) {
|
platform = try Linux(from: decoder)
|
||||||
platform = try Linux(from: decoder)
|
|
||||||
} else {
|
|
||||||
throw UnsupportedOSError("Linux VMs", "are")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
|
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
|
||||||
cpuCount = try container.decode(Int.self, forKey: .cpuCount)
|
cpuCount = try container.decode(Int.self, forKey: .cpuCount)
|
||||||
@@ -136,20 +139,30 @@ struct VMConfig: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mutating func setCPU(cpuCount: Int) throws {
|
mutating func setCPU(cpuCount: Int) throws {
|
||||||
if cpuCount < cpuCountMin {
|
if os == .darwin && cpuCount < cpuCountMin {
|
||||||
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
||||||
+ " at minimum (requested \(cpuCount))")
|
+ " at minimum (requested \(cpuCount))")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cpuCount < VZVirtualMachineConfiguration.minimumAllowedCPUCount {
|
||||||
|
throw LessThanMinimalResourcesError("VM should have \(VZVirtualMachineConfiguration.minimumAllowedCPUCount) CPU cores"
|
||||||
|
+ " at minimum (requested \(cpuCount))")
|
||||||
|
}
|
||||||
|
|
||||||
self.cpuCount = cpuCount
|
self.cpuCount = cpuCount
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func setMemory(memorySize: UInt64) throws {
|
mutating func setMemory(memorySize: UInt64) throws {
|
||||||
if memorySize < memorySizeMin {
|
if os == .darwin && memorySize < memorySizeMin {
|
||||||
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
||||||
+ " of memory at minimum (requested \(memorySize))")
|
+ " of memory at minimum (requested \(memorySize))")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if memorySize < VZVirtualMachineConfiguration.minimumAllowedMemorySize {
|
||||||
|
throw LessThanMinimalResourcesError("VM should have \(VZVirtualMachineConfiguration.minimumAllowedMemorySize) bytes"
|
||||||
|
+ " of memory at minimum (requested \(memorySize))")
|
||||||
|
}
|
||||||
|
|
||||||
self.memorySize = memorySize
|
self.memorySize = memorySize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,20 @@
|
|||||||
import Foundation
|
|
||||||
import Compression
|
import Compression
|
||||||
|
import Foundation
|
||||||
import Sentry
|
import Sentry
|
||||||
|
|
||||||
enum OCIError: Error {
|
enum OCIError: Error {
|
||||||
case ShouldBeExactlyOneLayer
|
case ShouldBeExactlyOneLayer
|
||||||
case ShouldBeAtLeastOneLayer
|
case ShouldBeAtLeastOneLayer
|
||||||
case FailedToCreateVmFile
|
case FailedToCreateVmFile
|
||||||
|
case LayerIsMissingUncompressedSizeAnnotation
|
||||||
|
case LayerIsMissingUncompressedDigestAnnotation
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VMDirectory {
|
extension VMDirectory {
|
||||||
private static let bufferSizeBytes = 64 * 1024 * 1024
|
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws {
|
||||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
|
||||||
|
|
||||||
private static let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
|
|
||||||
private static let diskMediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
|
||||||
private static let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
|
||||||
|
|
||||||
func pullFromRegistry(registry: Registry, reference: String) async throws {
|
|
||||||
defaultLogger.appendNewLine("pulling manifest...")
|
|
||||||
|
|
||||||
let (manifest, _) = try await registry.pullManifest(reference: reference)
|
|
||||||
|
|
||||||
return try await pullFromRegistry(registry: registry, manifest: manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pullFromRegistry(registry: Registry, manifest: OCIManifest) async throws {
|
|
||||||
// Pull VM's config file layer and re-serialize it into a config file
|
// Pull VM's config file layer and re-serialize it into a config file
|
||||||
let configLayers = manifest.layers.filter {
|
let configLayers = manifest.layers.filter {
|
||||||
$0.mediaType == Self.configMediaType
|
$0.mediaType == configMediaType
|
||||||
}
|
}
|
||||||
if configLayers.count != 1 {
|
if configLayers.count != 1 {
|
||||||
throw OCIError.ShouldBeExactlyOneLayer
|
throw OCIError.ShouldBeExactlyOneLayer
|
||||||
@@ -41,50 +28,48 @@ extension VMDirectory {
|
|||||||
}
|
}
|
||||||
try configFile.close()
|
try configFile.close()
|
||||||
|
|
||||||
// Pull VM's disk layers and decompress them sequentially into a disk file
|
// Pull VM's disk layers and decompress them into a disk file
|
||||||
let diskLayers = manifest.layers.filter {
|
let diskImplType: Disk.Type
|
||||||
$0.mediaType == Self.diskMediaType
|
let layers: [OCIManifestLayer]
|
||||||
}
|
|
||||||
if diskLayers.isEmpty {
|
if manifest.layers.contains(where: { $0.mediaType == diskV1MediaType }) {
|
||||||
|
diskImplType = DiskV1.self
|
||||||
|
layers = manifest.layers.filter { $0.mediaType == diskV1MediaType }
|
||||||
|
} else if manifest.layers.contains(where: { $0.mediaType == diskV2MediaType }) {
|
||||||
|
diskImplType = DiskV2.self
|
||||||
|
layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
|
||||||
|
} else {
|
||||||
throw OCIError.ShouldBeAtLeastOneLayer
|
throw OCIError.ShouldBeAtLeastOneLayer
|
||||||
}
|
}
|
||||||
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
|
||||||
throw OCIError.FailedToCreateVmFile
|
|
||||||
}
|
|
||||||
let disk = try FileHandle(forWritingTo: diskURL)
|
|
||||||
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
|
||||||
if let data = data {
|
|
||||||
disk.write(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Progress
|
let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +)
|
||||||
let diskCompressedSize: Int64 = Int64(diskLayers.map {
|
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte)
|
||||||
$0.size
|
|
||||||
}
|
|
||||||
.reduce(0) {
|
|
||||||
$0 + $1
|
|
||||||
})
|
|
||||||
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
||||||
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
||||||
|
|
||||||
let progress = Progress(totalUnitCount: diskCompressedSize)
|
let progress = Progress(totalUnitCount: diskCompressedSize)
|
||||||
ProgressObserver(progress).log(defaultLogger)
|
ProgressObserver(progress).log(defaultLogger)
|
||||||
|
|
||||||
for diskLayer in diskLayers {
|
do {
|
||||||
try await registry.pullBlob(diskLayer.digest) { data in
|
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
|
||||||
try filter.write(data)
|
concurrency: concurrency, progress: progress,
|
||||||
progress.completedUnitCount += Int64(data.count)
|
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)
|
||||||
}
|
}
|
||||||
try filter.finalize()
|
|
||||||
try disk.close()
|
|
||||||
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte);
|
|
||||||
|
|
||||||
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
||||||
defaultLogger.appendNewLine("pulling NVRAM...")
|
defaultLogger.appendNewLine("pulling NVRAM...")
|
||||||
|
|
||||||
let nvramLayers = manifest.layers.filter {
|
let nvramLayers = manifest.layers.filter {
|
||||||
$0.mediaType == Self.nvramMediaType
|
$0.mediaType == nvramMediaType
|
||||||
}
|
}
|
||||||
if nvramLayers.count != 1 {
|
if nvramLayers.count != 1 {
|
||||||
throw OCIError.ShouldBeExactlyOneLayer
|
throw OCIError.ShouldBeExactlyOneLayer
|
||||||
@@ -97,9 +82,12 @@ extension VMDirectory {
|
|||||||
nvram.write(data)
|
nvram.write(data)
|
||||||
}
|
}
|
||||||
try nvram.close()
|
try nvram.close()
|
||||||
|
|
||||||
|
// Serialize VM's manifest to enable better deduplication on subsequent "tart pull"'s
|
||||||
|
try manifest.toJSON().write(to: manifestURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int) async throws -> RemoteName {
|
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String, concurrency: UInt) async throws -> RemoteName {
|
||||||
var layers = Array<OCIManifestLayer>()
|
var layers = Array<OCIManifestLayer>()
|
||||||
|
|
||||||
// Read VM's config and push it as blob
|
// Read VM's config and push it as blob
|
||||||
@@ -107,32 +95,22 @@ extension VMDirectory {
|
|||||||
let configJSON = try JSONEncoder().encode(config)
|
let configJSON = try JSONEncoder().encode(config)
|
||||||
defaultLogger.appendNewLine("pushing config...")
|
defaultLogger.appendNewLine("pushing config...")
|
||||||
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
||||||
layers.append(OCIManifestLayer(mediaType: Self.configMediaType, size: configJSON.count, digest: configDigest))
|
layers.append(OCIManifestLayer(mediaType: configMediaType, size: configJSON.count, digest: configDigest))
|
||||||
|
|
||||||
// Progress
|
// 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
|
let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64
|
||||||
|
|
||||||
defaultLogger.appendNewLine("pushing disk... this will take a while...")
|
defaultLogger.appendNewLine("pushing disk... this will take a while...")
|
||||||
let progress = Progress(totalUnitCount: diskSize)
|
let progress = Progress(totalUnitCount: diskSize)
|
||||||
ProgressObserver(progress).log(defaultLogger)
|
ProgressObserver(progress).log(defaultLogger)
|
||||||
|
|
||||||
// Read VM's compressed disk as chunks
|
switch diskFormat {
|
||||||
// and sequentially upload them as blobs
|
case "v1":
|
||||||
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
layers.append(contentsOf: try await DiskV1.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
|
||||||
let mappedDiskSize = mappedDisk.count
|
case "v2":
|
||||||
var mappedDiskReadOffset = 0
|
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
|
||||||
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
default:
|
||||||
let bytesRead = min(length, mappedDiskSize - mappedDiskReadOffset)
|
throw RuntimeError.OCIUnsupportedDiskFormat(diskFormat)
|
||||||
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
|
||||||
mappedDiskReadOffset += bytesRead
|
|
||||||
|
|
||||||
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
while let compressedLayerData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
|
||||||
let layerDigest = try await registry.pushBlob(fromData: compressedLayerData, chunkSizeMb: chunkSizeMb)
|
|
||||||
layers.append(OCIManifestLayer(mediaType: Self.diskMediaType, size: compressedLayerData.count, digest: layerDigest))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read VM's NVRAM and push it as blob
|
// Read VM's NVRAM and push it as blob
|
||||||
@@ -140,7 +118,7 @@ extension VMDirectory {
|
|||||||
|
|
||||||
let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()!
|
let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()!
|
||||||
let nvramDigest = try await registry.pushBlob(fromData: nvram, chunkSizeMb: chunkSizeMb)
|
let nvramDigest = try await registry.pushBlob(fromData: nvram, chunkSizeMb: chunkSizeMb)
|
||||||
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
|
layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest))
|
||||||
|
|
||||||
// Craft a stub OCI config for Docker Hub compatibility
|
// Craft a stub OCI config for Docker Hub compatibility
|
||||||
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
||||||
@@ -148,7 +126,7 @@ extension VMDirectory {
|
|||||||
let manifest = OCIManifest(
|
let manifest = OCIManifest(
|
||||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||||
layers: layers,
|
layers: layers,
|
||||||
uncompressedDiskSize: UInt64(mappedDiskReadOffset),
|
uncompressedDiskSize: UInt64(diskSize),
|
||||||
uploadDate: Date()
|
uploadDate: Date()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
struct VMDirectory: Prunable {
|
struct VMDirectory: Prunable {
|
||||||
|
enum State: String {
|
||||||
|
case Running = "running"
|
||||||
|
case Suspended = "suspended"
|
||||||
|
case Stopped = "stopped"
|
||||||
|
}
|
||||||
|
|
||||||
var baseURL: URL
|
var baseURL: URL
|
||||||
|
|
||||||
var configURL: URL {
|
var configURL: URL {
|
||||||
@@ -16,6 +23,9 @@ struct VMDirectory: Prunable {
|
|||||||
var stateURL: URL {
|
var stateURL: URL {
|
||||||
baseURL.appendingPathComponent("state.vzvmsave")
|
baseURL.appendingPathComponent("state.vzvmsave")
|
||||||
}
|
}
|
||||||
|
var manifestURL: URL {
|
||||||
|
baseURL.appendingPathComponent("manifest.json")
|
||||||
|
}
|
||||||
|
|
||||||
var explicitlyPulledMark: URL {
|
var explicitlyPulledMark: URL {
|
||||||
baseURL.appendingPathComponent(".explicitly-pulled")
|
baseURL.appendingPathComponent(".explicitly-pulled")
|
||||||
@@ -29,26 +39,30 @@ struct VMDirectory: Prunable {
|
|||||||
baseURL
|
baseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lock() throws -> PIDLock {
|
||||||
|
try PIDLock(lockURL: configURL)
|
||||||
|
}
|
||||||
|
|
||||||
func running() throws -> Bool {
|
func running() throws -> Bool {
|
||||||
// The most common reason why PIDLock() instantiation fails is a race with "tart delete" (ENOENT),
|
// The most common reason why PIDLock() instantiation fails is a race with "tart delete" (ENOENT),
|
||||||
// which is fine to report as "not running".
|
// which is fine to report as "not running".
|
||||||
//
|
//
|
||||||
// The other reasons are unlikely and the cost of getting a false positive is way less than
|
// The other reasons are unlikely and the cost of getting a false positive is way less than
|
||||||
// the cost of crashing with an exception when calling "tart list" on a busy machine, for example.
|
// the cost of crashing with an exception when calling "tart list" on a busy machine, for example.
|
||||||
guard let lock = try? PIDLock(lockURL: configURL) else {
|
guard let lock = try? lock() else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return try lock.pid() != 0
|
return try lock.pid() != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func state() throws -> String {
|
func state() throws -> State {
|
||||||
if try running() {
|
if try running() {
|
||||||
return "running"
|
return State.Running
|
||||||
} else if FileManager.default.fileExists(atPath: stateURL.path) {
|
} else if FileManager.default.fileExists(atPath: stateURL.path) {
|
||||||
return "suspended"
|
return State.Suspended
|
||||||
} else {
|
} else {
|
||||||
return "stopped"
|
return State.Stopped
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +73,17 @@ struct VMDirectory: Prunable {
|
|||||||
return VMDirectory(baseURL: tmpDir)
|
return VMDirectory(baseURL: tmpDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Create tmp directory with hashing
|
||||||
|
static func temporaryDeterministic(key: String) throws -> VMDirectory {
|
||||||
|
let keyData = Data(key.utf8)
|
||||||
|
let hash = Insecure.MD5.hash(data: keyData)
|
||||||
|
// Convert hash to string
|
||||||
|
let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()
|
||||||
|
let tmpDir = try Config().tartTmpDir.appendingPathComponent(hashString)
|
||||||
|
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
|
||||||
|
return VMDirectory(baseURL: tmpDir)
|
||||||
|
}
|
||||||
|
|
||||||
var initialized: Bool {
|
var initialized: Bool {
|
||||||
FileManager.default.fileExists(atPath: configURL.path) &&
|
FileManager.default.fileExists(atPath: configURL.path) &&
|
||||||
FileManager.default.fileExists(atPath: diskURL.path) &&
|
FileManager.default.fileExists(atPath: diskURL.path) &&
|
||||||
@@ -118,20 +143,53 @@ struct VMDirectory: Prunable {
|
|||||||
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
||||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
||||||
// macOS considers kilo being 1000 and not 1024
|
let currentDiskFileLength = try diskFileHandle.seekToEnd()
|
||||||
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
|
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||||
|
if desiredDiskFileLength < currentDiskFileLength {
|
||||||
|
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength))
|
||||||
|
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength))
|
||||||
|
throw RuntimeError.InvalidDiskSize("new disk size of \(desiredLengthHuman) should be larger " +
|
||||||
|
"than the current disk size of \(currentLengthHuman)")
|
||||||
|
} else if desiredDiskFileLength > currentDiskFileLength {
|
||||||
|
try diskFileHandle.truncate(atOffset: desiredDiskFileLength)
|
||||||
|
}
|
||||||
try diskFileHandle.close()
|
try diskFileHandle.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete() throws {
|
func delete() throws {
|
||||||
|
let lock = try lock()
|
||||||
|
|
||||||
|
if try !lock.trylock() {
|
||||||
|
throw RuntimeError.VMIsRunning(name)
|
||||||
|
}
|
||||||
|
|
||||||
try FileManager.default.removeItem(at: baseURL)
|
try FileManager.default.removeItem(at: baseURL)
|
||||||
|
|
||||||
|
try lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func accessDate() throws -> Date {
|
func accessDate() throws -> Date {
|
||||||
try baseURL.accessDate()
|
try baseURL.accessDate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func allocatedSizeBytes() throws -> Int {
|
||||||
|
try configURL.allocatedSizeBytes() + diskURL.allocatedSizeBytes() + nvramURL.allocatedSizeBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func allocatedSizeGB() throws -> Int {
|
||||||
|
try allocatedSizeBytes() / 1000 / 1000 / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
func deduplicatedSizeBytes() throws -> Int {
|
||||||
|
try configURL.deduplicatedSizeBytes() + diskURL.deduplicatedSizeBytes() + nvramURL.deduplicatedSizeBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deduplicatedSizeGB() throws -> Int {
|
||||||
|
try deduplicatedSizeBytes() / 1000 / 1000 / 1000
|
||||||
|
}
|
||||||
|
|
||||||
func sizeBytes() throws -> Int {
|
func sizeBytes() throws -> Int {
|
||||||
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class VMStorageHelper {
|
|||||||
private static func missingVMWrap<R: Any>(_ name: String, closure: () throws -> R) throws -> R {
|
private static func missingVMWrap<R: Any>(_ name: String, closure: () throws -> R) throws -> R {
|
||||||
do {
|
do {
|
||||||
return try closure()
|
return try closure()
|
||||||
|
} catch RuntimeError.PIDLockMissing {
|
||||||
|
throw RuntimeError.VMDoesNotExist(name: name)
|
||||||
} catch {
|
} catch {
|
||||||
if error.isFileNotFound() {
|
if error.isFileNotFound() {
|
||||||
throw RuntimeError.VMDoesNotExist(name: name)
|
throw RuntimeError.VMDoesNotExist(name: name)
|
||||||
@@ -34,9 +36,15 @@ class VMStorageHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension NSError {
|
||||||
|
func isFileNotFound() -> Bool {
|
||||||
|
return self.code == NSFileNoSuchFileError || self.code == NSFileReadNoSuchFileError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension Error {
|
extension Error {
|
||||||
func isFileNotFound() -> Bool {
|
func isFileNotFound() -> Bool {
|
||||||
(self as NSError).code == NSFileReadNoSuchFileError
|
(self as NSError).isFileNotFound() || (self as NSError).underlyingErrors.contains(where: { $0.isFileNotFound() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,21 +52,29 @@ enum RuntimeError : Error {
|
|||||||
case VMConfigurationError(_ message: String)
|
case VMConfigurationError(_ message: String)
|
||||||
case VMDoesNotExist(name: String)
|
case VMDoesNotExist(name: String)
|
||||||
case VMMissingFiles(_ message: String)
|
case VMMissingFiles(_ message: String)
|
||||||
case VMNotRunning(_ message: String)
|
case VMIsRunning(_ name: String)
|
||||||
|
case VMNotRunning(_ name: String)
|
||||||
case VMAlreadyRunning(_ message: String)
|
case VMAlreadyRunning(_ message: String)
|
||||||
case NoIPAddressFound(_ message: String)
|
case NoIPAddressFound(_ message: String)
|
||||||
case DiskAlreadyInUse(_ message: String)
|
case DiskAlreadyInUse(_ message: String)
|
||||||
|
case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
|
||||||
|
case InvalidDiskSize(_ message: String)
|
||||||
case FailedToUpdateAccessDate(_ message: String)
|
case FailedToUpdateAccessDate(_ message: String)
|
||||||
case PIDLockFailed(_ message: String)
|
case PIDLockFailed(_ message: String)
|
||||||
|
case PIDLockMissing(_ message: String)
|
||||||
case FailedToParseRemoteName(_ message: String)
|
case FailedToParseRemoteName(_ message: String)
|
||||||
case VMTerminationFailed(_ message: String)
|
case VMTerminationFailed(_ message: String)
|
||||||
|
case ImproperlyFormattedHost(_ host: String, _ hint: String)
|
||||||
case InvalidCredentials(_ message: String)
|
case InvalidCredentials(_ message: String)
|
||||||
case VMDirectoryAlreadyInitialized(_ message: String)
|
case VMDirectoryAlreadyInitialized(_ message: String)
|
||||||
case ExportFailed(_ message: String)
|
case ExportFailed(_ message: String)
|
||||||
case ImportFailed(_ message: String)
|
case ImportFailed(_ message: String)
|
||||||
case SoftnetFailed(_ message: String)
|
case SoftnetFailed(_ message: String)
|
||||||
case OCIStorageError(_ message: String)
|
case OCIStorageError(_ message: String)
|
||||||
|
case OCIUnsupportedDiskFormat(_ format: String)
|
||||||
case SuspendFailed(_ message: String)
|
case SuspendFailed(_ message: String)
|
||||||
|
case PullFailed(_ message: String)
|
||||||
|
case VirtualMachineLimitExceeded(_ hint: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol HasExitCode {
|
protocol HasExitCode {
|
||||||
@@ -74,22 +90,32 @@ extension RuntimeError : CustomStringConvertible {
|
|||||||
return "the specified VM \"\(name)\" does not exist"
|
return "the specified VM \"\(name)\" does not exist"
|
||||||
case .VMMissingFiles(let message):
|
case .VMMissingFiles(let message):
|
||||||
return message
|
return message
|
||||||
case .VMNotRunning(let message):
|
case .VMIsRunning(let name):
|
||||||
return message
|
return "VM \"\(name)\" is running"
|
||||||
|
case .VMNotRunning(let name):
|
||||||
|
return "VM \"\(name)\" is not running"
|
||||||
case .VMAlreadyRunning(let message):
|
case .VMAlreadyRunning(let message):
|
||||||
return message
|
return message
|
||||||
case .NoIPAddressFound(let message):
|
case .NoIPAddressFound(let message):
|
||||||
return message
|
return message
|
||||||
case .DiskAlreadyInUse(let message):
|
case .DiskAlreadyInUse(let message):
|
||||||
return message
|
return message
|
||||||
|
case .FailedToOpenBlockDevice(let path, let explanation):
|
||||||
|
return "failed to open block device \(path): \(explanation)"
|
||||||
|
case .InvalidDiskSize(let message):
|
||||||
|
return message
|
||||||
case .FailedToUpdateAccessDate(let message):
|
case .FailedToUpdateAccessDate(let message):
|
||||||
return message
|
return message
|
||||||
case .PIDLockFailed(let message):
|
case .PIDLockFailed(let message):
|
||||||
return message
|
return message
|
||||||
|
case .PIDLockMissing(let message):
|
||||||
|
return message
|
||||||
case .FailedToParseRemoteName(let cause):
|
case .FailedToParseRemoteName(let cause):
|
||||||
return "failed to parse remote name: \(cause)"
|
return "failed to parse remote name: \(cause)"
|
||||||
case .VMTerminationFailed(let message):
|
case .VMTerminationFailed(let message):
|
||||||
return message
|
return message
|
||||||
|
case .ImproperlyFormattedHost(let host, let hint):
|
||||||
|
return "improperly formatted host \"\(host)\" was provided\(hint)"
|
||||||
case .InvalidCredentials(let message):
|
case .InvalidCredentials(let message):
|
||||||
return message
|
return message
|
||||||
case .VMDirectoryAlreadyInitialized(let message):
|
case .VMDirectoryAlreadyInitialized(let message):
|
||||||
@@ -102,8 +128,14 @@ extension RuntimeError : CustomStringConvertible {
|
|||||||
return "Softnet failed: \(message)"
|
return "Softnet failed: \(message)"
|
||||||
case .OCIStorageError(let message):
|
case .OCIStorageError(let message):
|
||||||
return "OCI storage error: \(message)"
|
return "OCI storage error: \(message)"
|
||||||
|
case .OCIUnsupportedDiskFormat(let format):
|
||||||
|
return "OCI disk format \(format) is not supported by this version of Tart"
|
||||||
case .SuspendFailed(let message):
|
case .SuspendFailed(let message):
|
||||||
return "Failed to suspend the VM: \(message)"
|
return "Failed to suspend the VM: \(message)"
|
||||||
|
case .PullFailed(let message):
|
||||||
|
return message
|
||||||
|
case .VirtualMachineLimitExceeded(let hint):
|
||||||
|
return "The number of VMs exceeds the system limit\(hint)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +143,8 @@ extension RuntimeError : CustomStringConvertible {
|
|||||||
extension RuntimeError : HasExitCode {
|
extension RuntimeError : HasExitCode {
|
||||||
var exitCode: Int32 {
|
var exitCode: Int32 {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .VMDoesNotExist:
|
||||||
|
return 2
|
||||||
case .VMNotRunning:
|
case .VMNotRunning:
|
||||||
return 2
|
return 2
|
||||||
case .VMAlreadyRunning:
|
case .VMAlreadyRunning:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class VMStorageLocal: PrunableStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func delete(_ name: String) throws {
|
func delete(_ name: String) throws {
|
||||||
try FileManager.default.removeItem(at: vmURL(name))
|
try VMDirectory(baseURL: vmURL(name)).delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
func list() throws -> [(String, VMDirectory)] {
|
func list() throws -> [(String, VMDirectory)] {
|
||||||
@@ -66,7 +66,7 @@ class VMStorageLocal: PrunableStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func prunables() throws -> [Prunable] {
|
func prunables() throws -> [Prunable] {
|
||||||
try list().map { (_, vmDir) in vmDir }
|
try list().map { (_, vmDir) in vmDir }.filter { try !$0.running() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Sentry
|
import Sentry
|
||||||
|
import Retry
|
||||||
|
|
||||||
class VMStorageOCI: PrunableStorage {
|
class VMStorageOCI: PrunableStorage {
|
||||||
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||||
@@ -112,6 +113,10 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split the relative VM's path at the last component
|
||||||
|
// and figure out which character should be used
|
||||||
|
// to join them together, either ":" for tags or
|
||||||
|
// "@" for hashes
|
||||||
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@@ -122,6 +127,9 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
name = parts.joined(separator: "@")
|
name = parts.joined(separator: "@")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the percent-encoding, if any
|
||||||
|
name = percentDecode(name)
|
||||||
|
|
||||||
result.append((name, vmDir, isSymlink))
|
result.append((name, vmDir, isSymlink))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,9 +140,9 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
||||||
}
|
}
|
||||||
|
|
||||||
func pull(_ name: RemoteName, registry: Registry) async throws {
|
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
|
||||||
SentrySDK.configureScope { scope in
|
SentrySDK.configureScope { scope in
|
||||||
scope.setContext(value: ["imageName": name], key: "OCI")
|
scope.setContext(value: ["imageName": name.description], key: "OCI")
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultLogger.appendNewLine("pulling manifest...")
|
defaultLogger.appendNewLine("pulling manifest...")
|
||||||
@@ -170,7 +178,7 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
|
|
||||||
if !exists(digestName) {
|
if !exists(digestName) {
|
||||||
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
||||||
let tmpVMDir = try VMDirectory.temporary()
|
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
|
||||||
|
|
||||||
// Lock the temporary VM directory to prevent it's garbage collection
|
// Lock the temporary VM directory to prevent it's garbage collection
|
||||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||||
@@ -188,7 +196,31 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
try await withTaskCancellationHandler(operation: {
|
||||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
try await retry(maxAttempts: 5, backoff: .exponentialWithFullJitter(baseDelay: .seconds(5), maxDelay: .seconds(60))) {
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
if deduplicate {
|
||||||
|
defaultLogger.appendNewLine("found an image \(llc.name) that will allow us to deduplicate \(deduplicatedHuman), using it as a base...")
|
||||||
|
} else {
|
||||||
|
defaultLogger.appendNewLine("found an image \(llc.name) that will allow us to avoid fetching \(deduplicatedHuman), will try use it...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache, deduplicate: deduplicate)
|
||||||
|
} recoverFromFailure: { error in
|
||||||
|
if error is URLError {
|
||||||
|
print("Error: \(error.localizedDescription)")
|
||||||
|
print("Attempting to re-try...")
|
||||||
|
|
||||||
|
return .retry
|
||||||
|
}
|
||||||
|
|
||||||
|
return .throw
|
||||||
|
}
|
||||||
try move(digestName, from: tmpVMDir)
|
try move(digestName, from: tmpVMDir)
|
||||||
transaction.finish()
|
transaction.finish()
|
||||||
}, onCancel: {
|
}, onCancel: {
|
||||||
@@ -207,6 +239,9 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
// are excluded from garbage collection
|
// are excluded from garbage collection
|
||||||
VMDirectory(baseURL: vmURL(name)).markExplicitlyPulled()
|
VMDirectory(baseURL: vmURL(name)).markExplicitlyPulled()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// to explicitly set the image as being accessed so it won't get pruned immediately
|
||||||
|
_ = try VMStorageOCI().open(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func linked(from: RemoteName, to: RemoteName) -> Bool {
|
func linked(from: RemoteName, to: RemoteName) -> Bool {
|
||||||
@@ -219,21 +254,72 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func link(from: RemoteName, to: RemoteName) throws {
|
func link(from: RemoteName, to: RemoteName) throws {
|
||||||
if FileManager.default.fileExists(atPath: vmURL(from).path) {
|
try? FileManager.default.removeItem(at: vmURL(from))
|
||||||
try FileManager.default.removeItem(at: vmURL(from))
|
|
||||||
}
|
|
||||||
|
|
||||||
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
||||||
|
|
||||||
try gc()
|
try gc()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chooseLocalLayerCache(_ name: RemoteName, _ manifest: OCIManifest, _ registry: Registry) async throws -> LocalLayerCache? {
|
||||||
|
// Establish a closure that will calculate how much bytes
|
||||||
|
// we'll deduplicate if we re-use the given manifest
|
||||||
|
let target = Swift.Set(manifest.layers)
|
||||||
|
|
||||||
|
let calculateDeduplicatedBytes = { (manifest: OCIManifest) -> UInt64 in
|
||||||
|
target.intersection(manifest.layers).map({ UInt64($0.size) }).reduce(0, +)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load OCI VM images and their manifests (if present)
|
||||||
|
var candidates: [(name: String, vmDir: VMDirectory, manifest: OCIManifest, deduplicatedBytes: UInt64)] = []
|
||||||
|
|
||||||
|
for (name, vmDir, isSymlink) in try list() {
|
||||||
|
if isSymlink {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let manifestJSON = try? Data(contentsOf: vmDir.manifestURL) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let manifest = try? OCIManifest(fromJSON: manifestJSON) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.append((name, vmDir, manifest, calculateDeduplicatedBytes(manifest)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Previously we haven't stored the OCI VM image manifests, but still fetched the VM image manifest if
|
||||||
|
// what the user was trying to pull was a tagged image, and we already had that image in the OCI VM cache
|
||||||
|
//
|
||||||
|
// Keep supporting this behavior for backwards comaptibility, but only communicate
|
||||||
|
// with the registry if we haven't already retrieved the manifest for that OCI VM image.
|
||||||
|
if name.reference.type == .Tag,
|
||||||
|
let vmDir = try? open(name),
|
||||||
|
let digest = try? digest(name),
|
||||||
|
try !candidates.contains(where: {try $0.manifest.digest() == digest}),
|
||||||
|
let (manifest, _) = try? await registry.pullManifest(reference: digest) {
|
||||||
|
candidates.append((name.description, vmDir, manifest, calculateDeduplicatedBytes(manifest)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now, find the best match based on how many bytes we'll deduplicate
|
||||||
|
let choosen = candidates.filter {
|
||||||
|
$0.deduplicatedBytes > 1024 * 1024 * 1024 // save at least 1GB
|
||||||
|
}.max { left, right in
|
||||||
|
return left.deduplicatedBytes < right.deduplicatedBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return try choosen.flatMap({ choosen in
|
||||||
|
try LocalLayerCache(choosen.name, choosen.deduplicatedBytes, choosen.vmDir.diskURL, choosen.manifest)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension URL {
|
extension URL {
|
||||||
func appendingRemoteName(_ name: RemoteName) -> URL {
|
func appendingRemoteName(_ name: RemoteName) -> URL {
|
||||||
var result: URL = self
|
var result: URL = self
|
||||||
|
|
||||||
for pathComponent in (name.host + "/" + name.namespace + "/" + name.reference.value).split(separator: "/") {
|
for pathComponent in (percentEncode(name.host) + "/" + name.namespace + "/" + name.reference.value).split(separator: "/") {
|
||||||
result = result.appendingPathComponent(String(pathComponent))
|
result = result.appendingPathComponent(String(pathComponent))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +327,23 @@ extension URL {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appendingHost(_ name: RemoteName) -> URL {
|
func appendingHost(_ name: RemoteName) -> URL {
|
||||||
self.appendingPathComponent(name.host, isDirectory: true)
|
self.appendingPathComponent(percentEncode(name.host), isDirectory: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Work around a pretty inane Swift's URL behavior where calling
|
||||||
|
// appendingPathComponent() or deletingLastPathComponent() on a
|
||||||
|
// URL like URL(filePath: "example.com:8080") (note the "filePath")
|
||||||
|
// will flip its isFileURL from "true" to "false" and discard its
|
||||||
|
// absolute path infromation (if any).
|
||||||
|
//
|
||||||
|
// The same kind of operations won't do anything to a URL like
|
||||||
|
// URL(filePath: "127.0.0.1:8080"), which makes things even more
|
||||||
|
// ridiculous.
|
||||||
|
private func percentEncode(_ s: String) -> String {
|
||||||
|
return s.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: ":").inverted)!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func percentDecode(_ s: String) -> String {
|
||||||
|
s.removingPercentEncoding!
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class FullFledgedVNC: VNC {
|
|||||||
vnc.start()
|
vnc.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForURL() async throws -> URL {
|
func waitForURL(netBridged: Bool) async throws -> URL {
|
||||||
while true {
|
while true {
|
||||||
// Port is 0 shortly after start(),
|
// Port is 0 shortly after start(),
|
||||||
// but will be initialized later
|
// but will be initialized later
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ class ScreenSharingVNC: VNC {
|
|||||||
self.vmConfig = vmConfig
|
self.vmConfig = vmConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForURL() async throws -> URL {
|
func waitForURL(netBridged: Bool) async throws -> URL {
|
||||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||||
let ip = try await IP.resolveIP(vmMACAddress, secondsToWait: 60)
|
let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: netBridged ? .arp : .dhcp, secondsToWait: 60)
|
||||||
|
|
||||||
if let ip = ip {
|
if let ip = ip {
|
||||||
return URL(string: "vnc://\(ip)")!
|
return URL(string: "vnc://\(ip)")!
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol VNC {
|
protocol VNC {
|
||||||
func waitForURL() async throws -> URL
|
func waitForURL(netBridged: Bool) async throws -> URL
|
||||||
func stop() throws
|
func stop() throws
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
final class DirectoryShareTests: XCTestCase {
|
final class DirectoryShareTests: XCTestCase {
|
||||||
func testNamedParsing() throws {
|
func testNamedParsing() throws {
|
||||||
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build")
|
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build")
|
||||||
@@ -29,4 +31,44 @@ final class DirectoryShareTests: XCTestCase {
|
|||||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
XCTAssertTrue(share.readOnly)
|
XCTAssertTrue(share.readOnly)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testMountTagParsing() throws {
|
||||||
|
let share = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar")
|
||||||
|
XCTAssertNil(share.name)
|
||||||
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertFalse(share.readOnly)
|
||||||
|
XCTAssertEqual(share.mountTag, "foo-bar")
|
||||||
|
|
||||||
|
let roShare = try DirectoryShare(parseFrom: "/Users/admin/build:ro,tag=foo-bar")
|
||||||
|
XCTAssertNil(roShare.name)
|
||||||
|
XCTAssertEqual(roShare.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertTrue(roShare.readOnly)
|
||||||
|
XCTAssertEqual(roShare.mountTag, "foo-bar")
|
||||||
|
|
||||||
|
let inverseRoShare = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar,ro")
|
||||||
|
XCTAssertNil(inverseRoShare.name)
|
||||||
|
XCTAssertEqual(inverseRoShare.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertTrue(inverseRoShare.readOnly)
|
||||||
|
XCTAssertEqual(inverseRoShare.mountTag, "foo-bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testURL() throws {
|
||||||
|
let archiveWithoutNameOrOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz")
|
||||||
|
XCTAssertNil(archiveWithoutNameOrOptions.name)
|
||||||
|
XCTAssertEqual(archiveWithoutNameOrOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||||
|
XCTAssertFalse(archiveWithoutNameOrOptions.readOnly)
|
||||||
|
XCTAssertEqual(archiveWithoutNameOrOptions.mountTag, VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag)
|
||||||
|
|
||||||
|
let archiveWithOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz:ro,tag=sometag")
|
||||||
|
XCTAssertNil(archiveWithOptions.name)
|
||||||
|
XCTAssertEqual(archiveWithOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||||
|
XCTAssertTrue(archiveWithOptions.readOnly)
|
||||||
|
XCTAssertEqual(archiveWithOptions.mountTag, "sometag")
|
||||||
|
|
||||||
|
let archiveWithNameAndOptions = try DirectoryShare(parseFrom: "somename:https://example.com/archive.tar.gz:ro,tag=sometag")
|
||||||
|
XCTAssertEqual(archiveWithNameAndOptions.name, "somename")
|
||||||
|
XCTAssertEqual(archiveWithNameAndOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||||
|
XCTAssertTrue(archiveWithNameAndOptions.readOnly)
|
||||||
|
XCTAssertEqual(archiveWithNameAndOptions.mountTag, "sometag")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class DockerConfigTests: XCTestCase {
|
||||||
|
func testHelpers() throws {
|
||||||
|
let config = DockerConfig(credHelpers: [
|
||||||
|
"(.*).dkr.ecr.(.*).amazonaws.com": "ecr-login",
|
||||||
|
"gcr.io": "gcloud"
|
||||||
|
])
|
||||||
|
|
||||||
|
XCTAssertEqual(try config.findCredHelper(host: "gcr.io"), "gcloud")
|
||||||
|
XCTAssertEqual(try config.findCredHelper(host: "123.dkr.ecr.eu-west-1.amazonaws.com"), "ecr-login")
|
||||||
|
XCTAssertEqual(try config.findCredHelper(host: "456.dkr.ecr.us-east-1.amazonaws.com"), "ecr-login")
|
||||||
|
XCTAssertNil(try config.findCredHelper(host: "ghcr.io"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class LayerizerTests: XCTestCase {
|
||||||
|
var registryRunner: RegistryRunner?
|
||||||
|
|
||||||
|
var registry: Registry {
|
||||||
|
registryRunner!.registry
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
try await super.setUp()
|
||||||
|
|
||||||
|
do {
|
||||||
|
registryRunner = try await RegistryRunner()
|
||||||
|
} catch {
|
||||||
|
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
try await super.tearDown()
|
||||||
|
|
||||||
|
registryRunner = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDiskV1() async throws {
|
||||||
|
// Original disk file to be pushed to the registry
|
||||||
|
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
|
||||||
|
addTeardownBlock {
|
||||||
|
try FileManager.default.removeItem(at: originalDiskFileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk file to be pulled from the registry
|
||||||
|
// and compared against the original disk file
|
||||||
|
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
|
||||||
|
print("pushing disk...")
|
||||||
|
let diskLayers = try await DiskV1.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, concurrency: 4, progress: Progress())
|
||||||
|
|
||||||
|
print("pulling disk...")
|
||||||
|
try await DiskV1.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
|
||||||
|
|
||||||
|
print("comparing disks...")
|
||||||
|
try XCTAssertEqual(Digest.hash(originalDiskFileURL), Digest.hash(pulledDiskFileURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDiskV2() async throws {
|
||||||
|
// Original disk file to be pushed to the registry
|
||||||
|
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
|
||||||
|
addTeardownBlock {
|
||||||
|
try FileManager.default.removeItem(at: originalDiskFileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk file to be pulled from the registry
|
||||||
|
// and compared against the original disk file
|
||||||
|
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
|
||||||
|
print("pushing disk...")
|
||||||
|
let diskLayers = try await 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())
|
||||||
|
|
||||||
|
print("comparing disks...")
|
||||||
|
try XCTAssertEqual(Digest.hash(originalDiskFileURL), Digest.hash(pulledDiskFileURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fileWithRandomData(sizeBytes: Int) throws -> URL {
|
||||||
|
let devUrandom = try FileHandle(forReadingFrom: URL(filePath: "/dev/urandom"))
|
||||||
|
|
||||||
|
let temporaryFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
FileManager.default.createFile(atPath: temporaryFileURL.path, contents: nil)
|
||||||
|
let temporaryFile = try FileHandle(forWritingTo: temporaryFileURL)
|
||||||
|
|
||||||
|
var remainingBytes = sizeBytes
|
||||||
|
|
||||||
|
while remainingBytes > 0 {
|
||||||
|
let randomData = try devUrandom.read(upToCount: min(64 * 1024 * 1024, remainingBytes))!
|
||||||
|
remainingBytes -= randomData.count
|
||||||
|
try temporaryFile.write(contentsOf: randomData)
|
||||||
|
}
|
||||||
|
|
||||||
|
try devUrandom.close()
|
||||||
|
|
||||||
|
try temporaryFile.close()
|
||||||
|
|
||||||
|
return temporaryFileURL
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
import Network
|
||||||
|
import SwiftRadix
|
||||||
|
|
||||||
|
final class LeaseTests: XCTestCase {
|
||||||
|
func testCorrectTimezone() throws {
|
||||||
|
let lease = Lease(fromRawLease: [
|
||||||
|
"hw_address": "1,11:22:33:44:55:66",
|
||||||
|
"ip_address": "1.2.3.4",
|
||||||
|
"lease": "0x6565da9e",
|
||||||
|
])
|
||||||
|
|
||||||
|
XCTAssertNotNil(lease)
|
||||||
|
XCTAssertEqual(lease!.expiresAt.toISO(), "2023-11-28T12:18:38Z")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
import Network
|
||||||
|
import SwiftDate
|
||||||
|
|
||||||
|
final class LeasesTests: XCTestCase {
|
||||||
|
func testNoExpired() throws {
|
||||||
|
let macAddress = MACAddress(fromString: "11:22:33:44:55:66")!
|
||||||
|
|
||||||
|
let leases = try Leases("""
|
||||||
|
{
|
||||||
|
name=whatever
|
||||||
|
ip_address=66.66.66.66
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() - 1.seconds).timeIntervalSince1970).hex)
|
||||||
|
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name=whatever
|
||||||
|
ip_address=1.2.3.4
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() + 10.minutes).timeIntervalSince1970).hex)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name=whatever
|
||||||
|
ip_address=66.66.66.66
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() - 1.seconds).timeIntervalSince1970).hex)
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
XCTAssertEqual(IPv4Address("1.2.3.4"), leases.ResolveMACAddress(macAddress: macAddress))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDuplicateYetNotExpiredLeases() throws {
|
||||||
|
let macAddress = MACAddress(fromString: "11:22:33:44:55:66")!
|
||||||
|
|
||||||
|
let leases = try Leases("""
|
||||||
|
{
|
||||||
|
name=debian
|
||||||
|
ip_address=192.168.64.1
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() + 10.minutes).timeIntervalSince1970).hex)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name=debian
|
||||||
|
ip_address=192.168.64.2
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() + 5.minutes).timeIntervalSince1970).hex)
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
XCTAssertEqual(IPv4Address("192.168.64.1"), leases.ResolveMACAddress(macAddress: macAddress))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@ final class MACAddressResolverTests: XCTestCase {
|
|||||||
{
|
{
|
||||||
ip_address=1.2.3.4
|
ip_address=1.2.3.4
|
||||||
hw_address=1,00:11:22:33:44:55
|
hw_address=1,00:11:22:33:44:55
|
||||||
|
lease=0x7fffffff
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMultipleEntries() throws {
|
func testMultipleEntries() throws {
|
||||||
@@ -20,16 +21,18 @@ final class MACAddressResolverTests: XCTestCase {
|
|||||||
{
|
{
|
||||||
ip_address=1.2.3.4
|
ip_address=1.2.3.4
|
||||||
hw_address=1,00:11:22:33:44:55
|
hw_address=1,00:11:22:33:44:55
|
||||||
|
lease=0x7fffffff
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
ip_address=5.6.7.8
|
ip_address=5.6.7.8
|
||||||
hw_address=1,AA:BB:CC:DD:EE:FF
|
hw_address=1,AA:BB:CC:DD:EE:FF
|
||||||
|
lease=0x7fffffff
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||||
XCTAssertEqual(IPv4Address("5.6.7.8"),
|
XCTAssertEqual(IPv4Address("5.6.7.8"),
|
||||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,26 @@ import XCTest
|
|||||||
|
|
||||||
final class TokenResponseTests: XCTestCase {
|
final class TokenResponseTests: XCTestCase {
|
||||||
func testBasic() throws {
|
func testBasic() throws {
|
||||||
|
var expectedTokenExpiresAtRange = DateInterval()
|
||||||
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
||||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||||
|
|
||||||
XCTAssertEqual(tokenResponse.token, "some token")
|
XCTAssertEqual(tokenResponse.token, "some token")
|
||||||
|
|
||||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
|
expectedTokenExpiresAtRange.end = Date().addingTimeInterval(60)
|
||||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||||
|
|
||||||
XCTAssertTrue(tokenResponse.isValid())
|
XCTAssertTrue(tokenResponse.isValid())
|
||||||
}
|
}
|
||||||
|
|
||||||
func testExpirationBasic() throws {
|
func testExpirationBasic() throws {
|
||||||
|
var expectedTokenExpiresAtRange = DateInterval()
|
||||||
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":2}".utf8)
|
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":2}".utf8)
|
||||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||||
|
|
||||||
XCTAssertEqual(tokenResponse.expiresIn, 2)
|
XCTAssertEqual(tokenResponse.expiresIn, 2)
|
||||||
|
|
||||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
|
expectedTokenExpiresAtRange.end = Date().addingTimeInterval(2)
|
||||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||||
|
|
||||||
XCTAssertTrue(tokenResponse.isValid())
|
XCTAssertTrue(tokenResponse.isValid())
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class RegistryRunner {
|
|||||||
let port = try Self.dockerCmd("inspect", containerID, "--format", "{{(index (index .NetworkSettings.Ports \"5000/tcp\") 0).HostPort}}")
|
let port = try Self.dockerCmd("inspect", containerID, "--format", "{{(index (index .NetworkSettings.Ports \"5000/tcp\") 0).HostPort}}")
|
||||||
.trimmingCharacters(in: CharacterSet.newlines)
|
.trimmingCharacters(in: CharacterSet.newlines)
|
||||||
|
|
||||||
registry = try Registry(urlComponents: URLComponents(string: "http://127.0.0.1:\(port)/v2/")!,
|
registry = try Registry(baseURL: URL(string: "http://127.0.0.1:\(port)/v2/")!,
|
||||||
namespace: "vm-image")
|
namespace: "vm-image")
|
||||||
|
|
||||||
// Wait for the Docker Registry to start
|
// Wait for the Docker Registry to start
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
root = true
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
|
||||||
|
linters:
|
||||||
|
enable-all: true
|
||||||
|
|
||||||
|
disable:
|
||||||
|
# Messages like "struct of size 104 bytes could be of size 96 bytes" from a package
|
||||||
|
# that was last updated 2 years ago[1] are barely helpful.
|
||||||
|
#
|
||||||
|
# After all, we're writing the code for other people, so let's trust the compiler here (that's
|
||||||
|
# constantly evolving compared to this linter) and revisit this if memory usage becomes a problem.
|
||||||
|
#
|
||||||
|
# [1]: https://github.com/mdempsky/maligned/commit/6e39bd26a8c8b58c5a22129593044655a9e25959
|
||||||
|
- maligned
|
||||||
|
|
||||||
|
# We don't have high-performance requirements at this moment, so sacrificing
|
||||||
|
# the code readability for marginal performance gains is not worth it.
|
||||||
|
- prealloc
|
||||||
|
|
||||||
|
# New linters that require a lot of codebase churn and noise, but perhaps we can enable them in the future.
|
||||||
|
- nlreturn
|
||||||
|
- wrapcheck
|
||||||
|
- errorlint
|
||||||
|
|
||||||
|
# Unfortunately, we use globals due to how spf13/cobra works.
|
||||||
|
- gochecknoglobals
|
||||||
|
|
||||||
|
# That's fine that some Proto objects don't have all fields initialized
|
||||||
|
- exhaustivestruct
|
||||||
|
|
||||||
|
# Style linters that are total nuts.
|
||||||
|
- wsl
|
||||||
|
- gofumpt
|
||||||
|
- goimports
|
||||||
|
- funlen
|
||||||
|
|
||||||
|
# This conflicts with the Protocol Buffers Version 3 design,
|
||||||
|
# which is largely based on default values for struct fields.
|
||||||
|
- exhaustivestruct
|
||||||
|
|
||||||
|
# Enough parallelism for now.
|
||||||
|
- paralleltest
|
||||||
|
|
||||||
|
# Ill-based assumptions about identifiers like fmt.Println without taking context into account.
|
||||||
|
- forbidigo
|
||||||
|
|
||||||
|
# Advantages of using t.Helper() are too small to waste developer's cognitive stamina on it.
|
||||||
|
- thelper
|
||||||
|
|
||||||
|
# Too restrictive defaults, plus there's already a gocyclo linter in place.
|
||||||
|
- cyclop
|
||||||
|
|
||||||
|
# Gives false positives for textbook examples[1][2]
|
||||||
|
# [1]: https://github.com/charithe/durationcheck/issues/7
|
||||||
|
# [2]: https://golang.org/pkg/time/ (see "To convert an integer number of units to a Duration, multiply:")
|
||||||
|
- durationcheck
|
||||||
|
|
||||||
|
# No way to disable the "exported" check for the whole project[1]
|
||||||
|
# [1]: https://github.com/mgechev/revive/issues/244#issuecomment-560512162
|
||||||
|
- revive
|
||||||
|
|
||||||
|
# Unfortunately too much false-positives, e.g. for a 0700 umask or number 10 when using strconv.FormatInt()
|
||||||
|
- gomnd
|
||||||
|
|
||||||
|
# Needs package whitelists
|
||||||
|
- depguard
|
||||||
|
|
||||||
|
# Generates absolutely useless errors, e.g.
|
||||||
|
# "string `.yml` has 3 occurrences, make it a constant"
|
||||||
|
- goconst
|
||||||
|
|
||||||
|
# It's OK to not sort imports
|
||||||
|
- gci
|
||||||
|
|
||||||
|
# It's OK to not initialize some struct fields
|
||||||
|
- exhaustruct
|
||||||
|
|
||||||
|
# This is not a library, so it's OK to use dynamic errors
|
||||||
|
- goerr113
|
||||||
|
|
||||||
|
# fmt.Sprintf() looks a bit nicer than string addition
|
||||||
|
- perfsprint
|
||||||
|
|
||||||
|
# We can control this ourselves
|
||||||
|
- varnamelen
|
||||||
|
- contextcheck
|
||||||
|
|
||||||
|
issues:
|
||||||
|
# Don't hide multiple issues that belong to one class since GitHub annotations can handle them all nicely.
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Benchmark
|
||||||
|
|
||||||
|
Tart comes with a Golang-based benchmarking utility that allows one to easily compare host and guest performance.
|
||||||
|
|
||||||
|
Currently, only Flexible I/O tester workloads are supported. To run them, [install Golang](https://go.dev/) and run the following command from this (`benchmark/`) directory:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
go run cmd/main.go fio --image ghcr.io/cirruslabs/macos-sonoma-base:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also enable the debugging output to diagnose issues:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
go run cmd/main.go fio --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
Host:
|
||||||
|
|
||||||
|
* Hardware: Mac mini (Apple M2 Pro, 8 performance and 4 efficiency cores, 32 GB RAM, `Mac14,12`)
|
||||||
|
* OS: macOS Sonoma 14.4.1
|
||||||
|
|
||||||
|
Guest:
|
||||||
|
|
||||||
|
* Hardware: [Virtualization.Framework](https://developer.apple.com/documentation/virtualization)
|
||||||
|
* OS: macOS Sonoma 14.4.1
|
||||||
|
|
||||||
|
```
|
||||||
|
Name Executor Bandwidth I/O operations
|
||||||
|
Random writing of 1MB local 2.6 GB/s 649.35 kIOPS
|
||||||
|
Random writing of 1MB Tart 2.5 GB/s 620.22 kIOPS
|
||||||
|
Random writing of 10MB local 2.6 GB/s 651.74 kIOPS
|
||||||
|
Random writing of 10MB Tart 2.5 GB/s 615.52 kIOPS
|
||||||
|
Random writing of 100MB local 1.9 GB/s 481.51 kIOPS
|
||||||
|
Random writing of 100MB Tart 2.0 GB/s 493.31 kIOPS
|
||||||
|
Random writing of 1000MB local 1.7 GB/s 414.89 kIOPS
|
||||||
|
Random writing of 1000MB Tart 1.1 GB/s 287.4 kIOPS
|
||||||
|
```
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/cirruslabs/tart/benchmark/internal/command"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Set up a signal-interruptible context
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
|
||||||
|
// Run the root command
|
||||||
|
if err := command.NewCommand().ExecuteContext(ctx); err != nil {
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
module github.com/cirruslabs/tart/benchmark
|
||||||
|
|
||||||
|
go 1.22.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/avast/retry-go/v4 v4.5.1
|
||||||
|
github.com/dustin/go-humanize v1.0.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gosuri/uitable v0.0.4
|
||||||
|
github.com/spf13/cobra v1.8.0
|
||||||
|
github.com/stretchr/testify v1.9.0
|
||||||
|
go.uber.org/zap v1.27.0
|
||||||
|
golang.org/x/crypto v0.21.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
golang.org/x/sys v0.18.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
github.com/avast/retry-go/v4 v4.5.1 h1:AxIx0HGi4VZ3I02jr78j5lZ3M6x1E0Ivxa6b0pUUh7o=
|
||||||
|
github.com/avast/retry-go/v4 v4.5.1/go.mod h1:/sipNsvNB3RRuT5iNcb6h73nw3IBmXJ/H3XrCQYSOpc=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
|
||||||
|
github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||||
|
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||||
|
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
||||||
|
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package fio
|
||||||
|
|
||||||
|
type Benchmark struct {
|
||||||
|
Name string
|
||||||
|
Command string
|
||||||
|
}
|
||||||
|
|
||||||
|
var benchmarks = []Benchmark{
|
||||||
|
{
|
||||||
|
Name: "Random writing of 1MB",
|
||||||
|
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||||
|
"--size 1MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Random writing of 10MB",
|
||||||
|
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||||
|
"--size 10MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Random writing of 100MB",
|
||||||
|
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||||
|
"--size 100MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Random writing of 1000MB",
|
||||||
|
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||||
|
"--size 1000MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package fio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/cirruslabs/tart/benchmark/internal/executor"
|
||||||
|
"github.com/cirruslabs/tart/benchmark/internal/executor/local"
|
||||||
|
"github.com/cirruslabs/tart/benchmark/internal/executor/tart"
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/gosuri/uitable"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var debug bool
|
||||||
|
var image string
|
||||||
|
|
||||||
|
func NewCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "fio",
|
||||||
|
Short: "run Flexible I/O tester (fio) benchmarks",
|
||||||
|
RunE: run,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging")
|
||||||
|
cmd.Flags().StringVar(&image, "image", "ghcr.io/cirruslabs/macos-sonoma-base:latest", "image to use for testing")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(cmd *cobra.Command, args []string) error {
|
||||||
|
config := zap.NewProductionConfig()
|
||||||
|
if debug {
|
||||||
|
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
|
||||||
|
}
|
||||||
|
logger, err := config.Build()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = logger.Sync()
|
||||||
|
}()
|
||||||
|
|
||||||
|
executors, err := initializeExecutors(cmd.Context(), logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
errs := []error{err}
|
||||||
|
|
||||||
|
for _, executor := range executors {
|
||||||
|
if err := executor.Close(); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to close executor %s: %w", executor.Name(), err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = errors.Join(errs...)
|
||||||
|
}()
|
||||||
|
|
||||||
|
table := uitable.New()
|
||||||
|
table.AddRow("Name", "Executor", "Bandwidth", "I/O operations")
|
||||||
|
|
||||||
|
for _, benchmark := range benchmarks {
|
||||||
|
for _, executor := range executors {
|
||||||
|
logger.Sugar().Infof("running benchmark %q on %s executor", benchmark.Name, executor.Name())
|
||||||
|
|
||||||
|
stdout, err := executor.Run(cmd.Context(), benchmark.Command)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var fioResult Result
|
||||||
|
|
||||||
|
if err := json.Unmarshal(stdout, &fioResult); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fioResult.Jobs) != 1 {
|
||||||
|
return fmt.Errorf("expected exactly 1 job from fio's JSON output, got %d",
|
||||||
|
len(fioResult.Jobs))
|
||||||
|
}
|
||||||
|
|
||||||
|
job := fioResult.Jobs[0]
|
||||||
|
|
||||||
|
writeBandwidth := humanize.Bytes(uint64(job.Write.BW)*humanize.KByte) + "/s"
|
||||||
|
writeIOPS := humanize.SIWithDigits(job.Write.IOPS, 2, "IOPS")
|
||||||
|
|
||||||
|
logger.Sugar().Infof("write bandwidth: %s, write IOPS: %s\n", writeBandwidth, writeIOPS)
|
||||||
|
|
||||||
|
table.AddRow(benchmark.Name, executor.Name(), writeBandwidth, writeIOPS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(table.String())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializeExecutors(ctx context.Context, logger *zap.Logger) ([]executor.Executor, error) {
|
||||||
|
var result []executor.Executor
|
||||||
|
|
||||||
|
logger.Info("initializing local executor")
|
||||||
|
|
||||||
|
local, err := local.New(logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, local)
|
||||||
|
|
||||||
|
logger.Info("local executor initialized")
|
||||||
|
|
||||||
|
logger.Info("initializing Tart executor")
|
||||||
|
|
||||||
|
tart, err := tart.New(ctx, image, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, tart)
|
||||||
|
|
||||||
|
logger.Info("Tart executor initialized")
|
||||||
|
|
||||||
|
for _, executor := range result {
|
||||||
|
logger.Sugar().Infof("installing Flexible I/O tester (fio) on %s executor", executor.Name())
|
||||||
|
|
||||||
|
if _, err := executor.Run(ctx, "brew install fio"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package fio
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Jobs []Job `json:"jobs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
Name string `json:"jobname"`
|
||||||
|
Write Write `json:"write"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Write struct {
|
||||||
|
BW float64 `json:"bw"`
|
||||||
|
IOPS float64 `json:"iops"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/cirruslabs/tart/benchmark/internal/command/fio"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "benchmark",
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
fio.NewCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||