Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
704811e671 | ||
|
|
d4fcecd47c | ||
|
|
33ca96e1a0 | ||
|
|
4ce06279ff | ||
|
|
fa97adfc9e | ||
|
|
6c377029d6 | ||
|
|
93a1b70ecb | ||
|
|
cf9a3a9221 | ||
|
|
ad566faa23 | ||
|
|
1afaa7ec7a | ||
|
|
826e508646 | ||
|
|
8653ca4115 | ||
|
|
63b74f407b | ||
|
|
3a2cba6929 | ||
|
|
7592b86663 | ||
|
|
e8dbb86fc0 | ||
|
|
d2ed4ef801 | ||
|
|
2014de7dac | ||
|
|
1f23b24920 | ||
|
|
6ce4a06089 | ||
|
|
1a2f187ac8 | ||
|
|
285bf9b6c2 | ||
|
|
415ed3388d | ||
|
|
870b414994 | ||
|
|
be7011bf11 | ||
|
|
859050cb42 | ||
|
|
4f321ec264 | ||
|
|
1b53ce42f8 | ||
|
|
e89ef32a83 | ||
|
|
62a34bf89f | ||
|
|
0608b2b9d1 | ||
|
|
91e859de9b | ||
|
|
c79da6a12b | ||
|
|
2b7ca12324 | ||
|
|
9016fcfdd4 | ||
|
|
546238d9df | ||
|
|
2b6818c493 | ||
|
|
cf49fd10b6 | ||
|
|
59b3e0c0fb | ||
|
|
7bbdfc06e7 | ||
|
|
637c54e1d1 | ||
|
|
e611d97b69 | ||
|
|
8e11bbe1cd | ||
|
|
6de31de6bf | ||
|
|
37ae7888e6 | ||
|
|
64482f4345 | ||
|
|
4f70d01dd6 | ||
|
|
9098eaf024 | ||
|
|
337d95ac95 | ||
|
|
3eb8ae2aa5 | ||
|
|
b03408f856 | ||
|
|
c749bdeaf1 | ||
|
|
8e75a59d54 | ||
|
|
1d3aa5ac81 | ||
|
|
261c1806df | ||
|
|
92a4b3164d | ||
|
|
3fdf82079a | ||
|
|
a05684157e | ||
|
|
e62e921eec | ||
|
|
e1bb565c3b | ||
|
|
9b26d30c42 | ||
|
|
ab32cb7e60 | ||
|
|
ea6fd814f5 | ||
|
|
e72fcd9b19 | ||
|
|
33a51b7344 | ||
|
|
5da1a085d3 | ||
|
|
f62949b6f4 | ||
|
|
d7561cab0b | ||
|
|
066f585c53 | ||
|
|
d8ac36b3bd | ||
|
|
4a454a3115 | ||
|
|
94e9425db7 | ||
|
|
0ab0fe23c5 | ||
|
|
0a970462e8 | ||
|
|
5b8d1d1168 |
@@ -5,8 +5,10 @@ set -e
|
|||||||
export VERSION="${CIRRUS_TAG:-0}"
|
export VERSION="${CIRRUS_TAG:-0}"
|
||||||
|
|
||||||
mkdir -p .ci/pkg/
|
mkdir -p .ci/pkg/
|
||||||
cp .build/arm64-apple-macosx/debug/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
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
use_compute_credits: true
|
use_compute_credits: true
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Test on Ventura
|
name: Test on Sonoma
|
||||||
alias: test
|
alias: test
|
||||||
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
|
||||||
@@ -24,11 +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
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
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:
|
||||||
@@ -37,24 +47,28 @@ task:
|
|||||||
format: swiftformat
|
format: swiftformat
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Build
|
|
||||||
alias: build
|
|
||||||
only_if: $CIRRUS_TAG == ''
|
only_if: $CIRRUS_TAG == ''
|
||||||
|
env:
|
||||||
|
matrix:
|
||||||
|
BUILD_ARCH: arm64
|
||||||
|
BUILD_ARCH: x86_64
|
||||||
|
name: Build ($BUILD_ARCH)
|
||||||
|
alias: build
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
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:
|
||||||
name: Release (Dry Run)
|
|
||||||
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
||||||
|
name: Release (Dry Run)
|
||||||
depends_on:
|
depends_on:
|
||||||
- lint
|
- lint
|
||||||
- build
|
- build
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
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]
|
||||||
@@ -70,18 +84,16 @@ 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/*"
|
||||||
pkg_artifacts:
|
|
||||||
path: ".ci/*.pkg"
|
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Release
|
name: Release
|
||||||
@@ -91,7 +103,7 @@ task:
|
|||||||
- test
|
- test
|
||||||
- build
|
- build
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
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]
|
||||||
@@ -99,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
|
||||||
@@ -118,13 +130,15 @@ task:
|
|||||||
- swift -version
|
- swift -version
|
||||||
release_script: goreleaser
|
release_script: goreleaser
|
||||||
upload_sentry_debug_files_script:
|
upload_sentry_debug_files_script:
|
||||||
- cd .build/arm64-apple-macosx/debug/
|
- cd .build/arm64-apple-macosx/release/
|
||||||
# Generate and upload symbols
|
# Generate and upload symbols
|
||||||
- dsymutil tart
|
- dsymutil tart
|
||||||
- sentry-cli debug-files upload tart.dSYM/
|
- sentry-cli debug-files upload tart.dSYM/
|
||||||
|
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.dSYM/
|
||||||
# Bundle and upload sources
|
# Bundle and upload sources
|
||||||
- sentry-cli debug-files bundle-sources tart.dSYM
|
- sentry-cli debug-files bundle-sources tart.dSYM
|
||||||
- sentry-cli debug-files upload tart.src.zip
|
- sentry-cli debug-files upload tart.src.zip
|
||||||
|
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.src.zip
|
||||||
create_sentry_release_script:
|
create_sentry_release_script:
|
||||||
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
|
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
|
||||||
- sentry-cli releases new $SENTRY_RELEASE
|
- sentry-cli releases new $SENTRY_RELEASE
|
||||||
|
|||||||
@@ -16,3 +16,6 @@ dist/
|
|||||||
|
|
||||||
# mkdocs
|
# mkdocs
|
||||||
.cache
|
.cache
|
||||||
|
|
||||||
|
# mkdocs-material
|
||||||
|
site
|
||||||
|
|||||||
@@ -3,58 +3,55 @@ project_name: tart
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- .ci/set-version.sh
|
- .ci/set-version.sh
|
||||||
- swift build -c debug --product tart
|
- swift build --arch x86_64 -c release --product tart
|
||||||
- xattr -cr .build/arm64-apple-macosx/debug/tart
|
- swift build --arch arm64 -c release --product tart
|
||||||
- gon gon.hcl
|
- gon gon.hcl
|
||||||
- codesign --verify --deep --strict .build/arm64-apple-macosx/debug/tart
|
|
||||||
- ./.ci/create-pkg.sh
|
|
||||||
|
|
||||||
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
|
||||||
prebuilt:
|
prebuilt:
|
||||||
path: .build/arm64-apple-macosx/debug/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 }}"
|
||||||
format: zip
|
|
||||||
files:
|
files:
|
||||||
- src: Resources/embedded.provisionprofile
|
- src: Resources/embedded.provisionprofile
|
||||||
|
dst: tart.app/Contents
|
||||||
strip_parent: true
|
strip_parent: true
|
||||||
hooks:
|
- src: Resources/Info.plist
|
||||||
after:
|
dst: tart.app/Contents
|
||||||
- xcrun notarytool submit "dist/tart.zip" --keychain-profile "notarytool" --wait
|
strip_parent: true
|
||||||
|
- src: Resources/AppIcon.png
|
||||||
|
dst: tart.app/Contents/Resources
|
||||||
|
strip_parent: true
|
||||||
|
- LICENSE
|
||||||
|
|
||||||
release:
|
release:
|
||||||
prerelease: auto
|
prerelease: auto
|
||||||
extra_files:
|
|
||||||
- glob: ./.ci/Tart-{{ .Tag }}.pkg
|
|
||||||
|
|
||||||
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: |
|
||||||
mkdir_p libexec/"tart.app/Contents/MacOS/"
|
libexec.install Dir["*"]
|
||||||
libexec.install "tart" => "tart.app/Contents/MacOS/tart"
|
|
||||||
libexec.install "embedded.provisionprofile" => "tart.app/Contents/embedded.provisionprofile"
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="sign debug" type="ShConfigurationType">
|
|
||||||
<option name="SCRIPT_TEXT" value="codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart" />
|
|
||||||
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
|
||||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/sign.sh" />
|
|
||||||
<option name="SCRIPT_OPTIONS" value="" />
|
|
||||||
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
|
||||||
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
|
||||||
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
|
||||||
<option name="INTERPRETER_PATH" value="/bin/zsh" />
|
|
||||||
<option name="INTERPRETER_OPTIONS" value="" />
|
|
||||||
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
|
||||||
<option name="EXECUTE_SCRIPT_FILE" value="false" />
|
|
||||||
<envs />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="tart create" type="SwiftPackageManagerRunConfiguration" factoryName="Swift Package Run" PROGRAM_PARAMS="create latest --from-ipsw=latest" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" PASS_PARENT_ENVS_2="true" PROJECT_NAME="tart" TARGET_NAME="tart" CONFIG_NAME="tart" RUN_TARGET_PROJECT_NAME="tart" RUN_TARGET_NAME="tart" WAS_MODIFIED="">
|
|
||||||
<method v="2">
|
|
||||||
<option name="SPM.BUILD_TASK_PROVIDER" enabled="true" />
|
|
||||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="sign debug" run_configuration_type="ShConfigurationType" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="tart run" type="SwiftPackageManagerRunConfiguration" factoryName="Swift Package Run" PROGRAM_PARAMS="run latest" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" PASS_PARENT_ENVS_2="true" PROJECT_NAME="tart" TARGET_NAME="tart" CONFIG_NAME="tart" RUN_TARGET_PROJECT_NAME="tart" RUN_TARGET_NAME="tart" WAS_MODIFIED="">
|
|
||||||
<method v="2">
|
|
||||||
<option name="SPM.BUILD_TASK_PROVIDER" enabled="true" />
|
|
||||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="sign debug" run_configuration_type="ShConfigurationType" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"originHash" : "a10a6363a6d2cd2761653d1587b81f5e3d55b1f981b7d6a2964b34da91addf13",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "antlr4",
|
"identity" : "antlr4",
|
||||||
@@ -6,7 +7,7 @@
|
|||||||
"location" : "https://github.com/antlr/antlr4",
|
"location" : "https://github.com/antlr/antlr4",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "dev",
|
"branch" : "dev",
|
||||||
"revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2"
|
"revision" : "2a7904a595479954ccfb1c78124fc3d7f5bebcb1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -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" : "c3c19e29f775ee95b77aa3168f3e2fd6c20deba6",
|
"revision" : "5575af93efb776414f243e93d6af9f6258dc539a",
|
||||||
"version" : "7.31.3"
|
"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,40 @@
|
|||||||
"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",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/sersoft-gmbh/swift-sysctl.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a91be36de6803ebe48f678699dfd0694c2200d2f",
|
||||||
|
"version" : "1.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-xattr",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/jozefizso/swift-xattr",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "f8605af7b3290dbb235fb182ec6e9035d0c8c3ac",
|
||||||
|
"version" : "3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swiftdate",
|
"identity" : "swiftdate",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -95,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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -109,5 +164,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 2
|
"version" : 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +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", branch: "dev"),
|
||||||
.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: "7.31.3"),
|
.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.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: [
|
||||||
@@ -32,6 +37,11 @@ let package = Package(
|
|||||||
.product(name: "Atomics", package: "swift-atomics"),
|
.product(name: "Atomics", package: "swift-atomics"),
|
||||||
.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: "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",
|
||||||
|
|||||||
@@ -6,49 +6,68 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
|||||||
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722).
|
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722).
|
||||||
* Push/Pull virtual machines from any OCI-compatible container registry.
|
* Push/Pull virtual machines from any OCI-compatible container registry.
|
||||||
* Use Tart Packer Plugin to automate VM creation.
|
* Use Tart Packer Plugin to automate VM creation.
|
||||||
* Built-in CI integration.
|
* Easily integrates with any CI system.
|
||||||
|
|
||||||
*Tart* is already adopted by several automation services:
|
Tart powers [Cirrus Runners](https://cirrus-runners.app/)
|
||||||
|
service — a drop-in replacement for the standard GitHub-hosted runners, offering 2-3 times better performance for a fraction of the price.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://cirrus-ci.org/guide/macOS/" 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/Users/CirrusCI.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
|
||||||
</a>
|
|
||||||
<a href="https://codemagic.io/" target=_blank>
|
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Codemagic.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>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Many more companies are using Tart in their internal setups. Here are a few of them:
|
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://ahrefs.com/" target=_blank>
|
<a href="https://atlassian.com/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Atlassian.png" height="65"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://www.figma.com/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Figma.png" height="65"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://mullvad.net/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://krisp.ai/" target=_blank>
|
<a href="https://krisp.ai/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://suran.com/" target=_blank>
|
<a href="https://testingbot.com/" 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/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"/>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://transloadit.com/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://cirrus-ci.org/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://www.pitsdatarecovery.net/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
|
||||||
|
</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 |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
<?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>CFBundleIdentifier</key>
|
||||||
|
<string>org.cirruslabs.tart</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>tart</string>
|
||||||
|
<key>LSBackgroundOnly</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>CFBundleIconFiles</key>
|
||||||
|
<array>
|
||||||
|
<string>AppIcon.png</string>
|
||||||
|
</array>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
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: 7.0 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
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>
|
||||||
|
|||||||
@@ -3,9 +3,22 @@ import Foundation
|
|||||||
import SystemConfiguration
|
import SystemConfiguration
|
||||||
|
|
||||||
struct Clone: AsyncParsableCommand {
|
struct Clone: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Clone a VM")
|
static var configuration = CommandConfiguration(
|
||||||
|
abstract: "Clone a VM",
|
||||||
|
discussion: """
|
||||||
|
Creates a local virtual machine by cloning either a remote or another local virtual machine.
|
||||||
|
|
||||||
@Argument(help: "source VM name")
|
Due to copy-on-write magic in Apple File System, a cloned VM won't actually claim all the space right away.
|
||||||
|
Only changes to a cloned disk will be written and claim new space. By default, Tart checks available capacity
|
||||||
|
in Tart's home directory and checks if there is enough space for the worst possible scenario: when the whole disk
|
||||||
|
will be modified.
|
||||||
|
|
||||||
|
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable. This might be helpful
|
||||||
|
for use cases when the original image is very big and a workload is known to only modify a fraction of the cloned disk.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@Argument(help: "source VM name", completion: .custom(completeMachines))
|
||||||
var sourceName: String
|
var sourceName: String
|
||||||
|
|
||||||
@Argument(help: "new VM name")
|
@Argument(help: "new VM name")
|
||||||
@@ -14,10 +27,17 @@ 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
|
||||||
|
|
||||||
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 {
|
||||||
@@ -27,11 +47,10 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||||
|
|
||||||
let tmpVMDir = try VMDirectory.temporary()
|
let tmpVMDir = try VMDirectory.temporary()
|
||||||
|
|
||||||
// Lock the temporary VM directory to prevent it's garbage collection
|
// Lock the temporary VM directory to prevent it's garbage collection
|
||||||
@@ -44,10 +63,17 @@ 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
|
||||||
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)
|
||||||
|
|
||||||
try lock.unlock()
|
try lock.unlock()
|
||||||
|
|
||||||
|
// APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
|
||||||
|
// is not actually claiming new space until the VM is started and it writes something to disk.
|
||||||
|
// So once we clone the VM let's try to claim a little bit of space for the VM to run.
|
||||||
|
try Prune.reclaimIfNeeded(UInt64(sourceVM.allocatedSizeBytes()), sourceVM)
|
||||||
}, 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 {
|
||||||
|
|||||||
@@ -1,16 +1,44 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
|
||||||
struct Export: AsyncParsableCommand {
|
struct Export: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Export VM to a 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.")
|
||||||
var path: String
|
var path: String?
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
|
let correctedPath: String
|
||||||
|
|
||||||
|
if let path = path {
|
||||||
|
correctedPath = path
|
||||||
|
} else {
|
||||||
|
correctedPath = "\(name).tvm"
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: correctedPath) {
|
||||||
|
while true {
|
||||||
|
if userWantsOverwrite(correctedPath) {
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
print("exporting...")
|
print("exporting...")
|
||||||
try VMStorageHelper.open(name).exportToArchive(path: path)
|
|
||||||
|
try VMStorageHelper.open(name).exportToArchive(path: correctedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func userWantsOverwrite(_ filename: String) -> Bool {
|
||||||
|
print("file \(filename) already exists, are you sure you want to overwrite it? (yes, [no])? ", terminator: "")
|
||||||
|
|
||||||
|
let answer = readLine()!
|
||||||
|
|
||||||
|
return answer == "yes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,17 +2,20 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
||||||
@@ -21,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 running = try PIDLock(lockURL: vmDir.configURL).pid() > 0
|
|
||||||
|
|
||||||
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb, Display: vmConfig.display.description, Running: running)
|
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)
|
||||||
print(format.renderSingle(info))
|
print(format.renderSingle(info))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,55 +4,66 @@ import Network
|
|||||||
import SystemConfiguration
|
import SystemConfiguration
|
||||||
import Sentry
|
import Sentry
|
||||||
|
|
||||||
|
enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
||||||
|
case dhcp, arp
|
||||||
|
|
||||||
|
private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"}
|
||||||
|
}
|
||||||
|
|
||||||
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")
|
||||||
var wait: UInt16 = 0
|
var wait: UInt16 = 0
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Strategy for resolving IP address: dhcp or arp",
|
||||||
|
discussion: """
|
||||||
|
By default, Tart is looking up and parsing DHCP lease file to determine the IP of the VM.\n
|
||||||
|
This method is fast and the most reliable but only returns local IP adresses.\n
|
||||||
|
Alternatively, Tart can call external `arp` executable and parse it's output.\n
|
||||||
|
In case of enabled Bridged Networking this method will return VM's IP address on the network interface used for Bridged Networking.\n
|
||||||
|
Note that `arp` strategy won't work for VMs using `--net-softnet`.
|
||||||
|
"""))
|
||||||
|
var resolver: IPResolutionStrategy = .dhcp
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||||
|
|
||||||
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait) else {
|
||||||
throw RuntimeError.NoIPAddressFound("no IP address found, is your VM running?")
|
var message = "no IP address found"
|
||||||
}
|
|
||||||
|
|
||||||
let arpCache = try ARPCache()
|
if try !vmDir.running() {
|
||||||
|
message += ", is your VM running?"
|
||||||
if let ipViaARP = try arpCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
|
||||||
// Capture the warning into Sentry
|
|
||||||
SentrySDK.capture(message: "DHCP lease and ARP cache entries for a single MAC address differ") { scope in
|
|
||||||
scope.setLevel(.warning)
|
|
||||||
|
|
||||||
scope.setContext(value: [
|
|
||||||
"MAC address": vmMACAddress,
|
|
||||||
"IP via ARP": ipViaARP,
|
|
||||||
"IP via DHCP": ipViaDHCP,
|
|
||||||
], key: "Address conflict details")
|
|
||||||
|
|
||||||
scope.add(Attachment(path: "/var/db/dhcpd_leases", filename: "dhcpd_leases.txt", contentType: "text/plain"))
|
|
||||||
scope.add(Attachment(data: arpCache.arpCommandOutput, filename: "arp-an-output.txt", contentType: "text/plain"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
if (vmConfig.os == .linux && resolver == .arp) {
|
||||||
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
message += " (not all Linux distributions are compatible with the ARP resolver)"
|
||||||
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
}
|
||||||
|
|
||||||
|
throw RuntimeError.NoIPAddressFound(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
print(ipViaDHCP)
|
print(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
|
static public func resolveIP(_ vmMACAddress: MACAddress, resolutionStrategy: IPResolutionStrategy = .dhcp, secondsToWait: UInt16 = 0) async throws -> IPv4Address? {
|
||||||
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
||||||
|
|
||||||
repeat {
|
repeat {
|
||||||
if let leases = try Leases(), let ip = try leases.resolveMACAddress(macAddress: vmMACAddress) {
|
switch resolutionStrategy {
|
||||||
return ip
|
case .arp:
|
||||||
|
if let ip = try ARPCache().ResolveMACAddress(macAddress: vmMACAddress) {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
case .dhcp:
|
||||||
|
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wait a second
|
// wait a second
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import ArgumentParser
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Import: AsyncParsableCommand {
|
struct Import: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Import VM from a file")
|
static var configuration = CommandConfiguration(abstract: "Import VM from a compressed .tvm file")
|
||||||
|
|
||||||
@Argument(help: "Path to a file created with \"tart export\".")
|
@Argument(help: "Path to a file created with \"tart export\".")
|
||||||
var path: String
|
var path: String
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ 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 State: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct List: AsyncParsableCommand {
|
struct List: AsyncParsableCommand {
|
||||||
@@ -32,17 +36,19 @@ struct List: AsyncParsableCommand {
|
|||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
var infos: [VMInfo] = []
|
var infos: [VMInfo] = []
|
||||||
|
|
||||||
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())
|
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())
|
try VMInfo(Source: "OCI", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quiet) {
|
if (quiet) {
|
||||||
for info in infos {
|
for info in infos {
|
||||||
print(info.Name)
|
print(info.Name)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -35,6 +38,9 @@ struct Login: AsyncParsableCommand {
|
|||||||
|
|
||||||
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
||||||
password = String(decoding: passwordData, as: UTF8.self)
|
password = String(decoding: passwordData, as: UTF8.self)
|
||||||
|
|
||||||
|
// Support "echo $PASSWORD | tart login --username $USERNAME --password-stdin $REGISTRY"
|
||||||
|
password.trimSuffix { c in c.isNewline }
|
||||||
} else {
|
} else {
|
||||||
(user, password) = try StdinCredentials.retrieve()
|
(user, password) = try StdinCredentials.retrieve()
|
||||||
}
|
}
|
||||||
@@ -42,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,23 +5,40 @@ import SwiftUI
|
|||||||
import SwiftDate
|
import SwiftDate
|
||||||
|
|
||||||
struct Prune: AsyncParsableCommand {
|
struct Prune: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
|
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs")
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("Remove cache entries last accessed more than n days ago",
|
@Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs."))
|
||||||
|
var entries: String = "caches"
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago",
|
||||||
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
|
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
|
||||||
valueName: "n"))
|
valueName: "n"))
|
||||||
var olderThan: UInt?
|
var olderThan: UInt?
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("Remove least recently used cache entries that do not fit the specified cache size budget n, expressed in gigabytes",
|
@Option(help: .hidden)
|
||||||
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
|
|
||||||
valueName: "n"))
|
|
||||||
var cacheBudget: UInt?
|
var cacheBudget: UInt?
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Remove the least recently used entries that do not fit the specified space size budget n, expressed in gigabytes",
|
||||||
|
discussion: "For example, --space-budget=50 will effectively shrink all entries to a total size of 50 gigabytes.",
|
||||||
|
valueName: "n"))
|
||||||
|
var spaceBudget: UInt?
|
||||||
|
|
||||||
@Flag(help: .hidden)
|
@Flag(help: .hidden)
|
||||||
var gc: Bool = false
|
var gc: Bool = false
|
||||||
|
|
||||||
func validate() throws {
|
mutating func validate() throws {
|
||||||
if olderThan == nil && cacheBudget == nil && !gc {
|
// --cache-budget deprecation logic
|
||||||
|
if let cacheBudget = cacheBudget {
|
||||||
|
fputs("--cache-budget is deprecated, please use --space-budget\n", stderr)
|
||||||
|
|
||||||
|
if spaceBudget != nil {
|
||||||
|
throw ValidationError("--cache-budget is deprecated, please use --space-budget")
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceBudget = cacheBudget
|
||||||
|
}
|
||||||
|
|
||||||
|
if olderThan == nil && spaceBudget == nil && !gc {
|
||||||
throw ValidationError("at least one pruning criteria must be specified")
|
throw ValidationError("at least one pruning criteria must be specified")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,43 +48,53 @@ struct Prune: AsyncParsableCommand {
|
|||||||
try VMStorageOCI().gc()
|
try VMStorageOCI().gc()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a list of prunable storages that we're going to prune based on user's request
|
||||||
|
let prunableStorages: [PrunableStorage]
|
||||||
|
|
||||||
|
switch entries {
|
||||||
|
case "caches":
|
||||||
|
prunableStorages = [VMStorageOCI(), try IPSWCache()]
|
||||||
|
case "vms":
|
||||||
|
prunableStorages = [VMStorageLocal()]
|
||||||
|
default:
|
||||||
|
throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"")
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up cache entries based on last accessed date
|
// Clean up cache entries based on last accessed date
|
||||||
if let olderThan = olderThan {
|
if let olderThan = olderThan {
|
||||||
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
||||||
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
|
let olderThanDate = Date() - olderThanInterval
|
||||||
|
|
||||||
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
|
try Prune.pruneOlderThan(prunableStorages: prunableStorages, olderThanDate: olderThanDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
||||||
if let cacheBudget = cacheBudget {
|
if let spaceBudget = spaceBudget {
|
||||||
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
|
try Prune.pruneSpaceBudget(prunableStorages: prunableStorages, spaceBudgetBytes: UInt64(spaceBudget) * 1024 * 1024 * 1024)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func pruneOlderThan(olderThanDate: Date) throws {
|
static func pruneOlderThan(prunableStorages: [PrunableStorage], olderThanDate: Date) throws {
|
||||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
|
||||||
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
|
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
|
||||||
|
|
||||||
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
|
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
|
static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
|
||||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
|
||||||
let prunables: [Prunable] = try prunableStorages
|
let prunables: [Prunable] = try prunableStorages
|
||||||
.flatMap { try $0.prunables() }
|
.flatMap { try $0.prunables() }
|
||||||
.sorted { try $0.accessDate() > $1.accessDate() }
|
.sorted { try $0.accessDate() > $1.accessDate() }
|
||||||
|
|
||||||
var cacheBudgetBytes = cacheBudgetBytes
|
var spaceBudgetBytes = spaceBudgetBytes
|
||||||
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 <= cacheBudgetBytes {
|
if prunableSizeBytes <= spaceBudgetBytes {
|
||||||
// Don't mark for deletion as
|
// Don't mark for deletion as
|
||||||
// there's a budget available
|
// there's a budget available
|
||||||
cacheBudgetBytes -= prunableSizeBytes
|
spaceBudgetBytes -= prunableSizeBytes
|
||||||
} else {
|
} else {
|
||||||
// Mark for deletion
|
// Mark for deletion
|
||||||
prunablesToDelete.append(prunable)
|
prunablesToDelete.append(prunable)
|
||||||
@@ -77,14 +104,61 @@ struct Prune: AsyncParsableCommand {
|
|||||||
try prunablesToDelete.forEach { try $0.delete() }
|
try prunablesToDelete.forEach { try $0.delete() }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func pruneReclaim(reclaimBytes: UInt64) throws {
|
static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
||||||
|
if ProcessInfo.processInfo.environment.keys.contains("TART_NO_AUTO_PRUNE") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SentrySDK.configureScope { scope in
|
||||||
|
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Figure out how much disk space is available
|
||||||
|
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [
|
||||||
|
.volumeAvailableCapacityKey,
|
||||||
|
.volumeAvailableCapacityForImportantUsageKey
|
||||||
|
])
|
||||||
|
let volumeAvailableCapacityCalculated = max(
|
||||||
|
UInt64(attrs.volumeAvailableCapacity!),
|
||||||
|
UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
|
||||||
|
)
|
||||||
|
|
||||||
|
SentrySDK.configureScope { scope in
|
||||||
|
scope.setContext(value: [
|
||||||
|
"volumeAvailableCapacity": attrs.volumeAvailableCapacity!,
|
||||||
|
"volumeAvailableCapacityForImportantUsage": attrs.volumeAvailableCapacityForImportantUsage!,
|
||||||
|
"volumeAvailableCapacityCalculated": volumeAvailableCapacityCalculated
|
||||||
|
], key: "Prune")
|
||||||
|
}
|
||||||
|
|
||||||
|
if volumeAvailableCapacityCalculated <= 0 {
|
||||||
|
SentrySDK.capture(message: "Zero volume capacity reported") { scope in
|
||||||
|
scope.setLevel(.warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now that we know how much free space is left,
|
||||||
|
// check if we even need to reclaim anything
|
||||||
|
if requiredBytes < volumeAvailableCapacityCalculated {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated, initiator)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
||||||
|
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
|
||||||
|
defer { transaction.finish() }
|
||||||
|
|
||||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||||
let prunables: [Prunable] = try prunableStorages
|
let prunables: [Prunable] = try prunableStorages
|
||||||
.flatMap { try $0.prunables() }
|
.flatMap { try $0.prunables() }
|
||||||
.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
|
||||||
}
|
}
|
||||||
@@ -98,10 +172,16 @@ struct Prune: AsyncParsableCommand {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
|
||||||
try prunable.delete()
|
// do not prune the initiator
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
try SentrySDK.span?.setExtra(value: prunable.sizeBytes(), key: prunable.url.path);
|
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path)
|
||||||
|
|
||||||
|
cacheReclaimedBytes += try prunable.allocatedSizeBytes()
|
||||||
|
|
||||||
|
try prunable.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||||
|
|||||||
@@ -3,7 +3,16 @@ import Dispatch
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct Pull: AsyncParsableCommand {
|
struct Pull: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Pull a VM from a registry")
|
static var configuration = CommandConfiguration(
|
||||||
|
abstract: "Pull a VM from a registry",
|
||||||
|
discussion: """
|
||||||
|
Pulls a virtual machine from a remote OCI-compatible registry. Supports authorization via Keychain (see "tart login --help"),
|
||||||
|
Docker credential helpers defined in ~/.docker/config.json or via TART_REGISTRY_USERNAME/TART_REGISTRY_PASSWORD environment variables.
|
||||||
|
|
||||||
|
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image to fit via "tart prune".
|
||||||
|
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
@Argument(help: "remote VM name")
|
@Argument(help: "remote VM name")
|
||||||
var remoteName: String
|
var remoteName: String
|
||||||
@@ -11,6 +20,15 @@ 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
|
||||||
|
|
||||||
|
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
|
||||||
@@ -25,6 +43,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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,12 +26,20 @@ 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
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let localVMDir = try VMStorageLocal().open(localName)
|
let ociStorage = VMStorageOCI()
|
||||||
|
let localVMDir = try VMStorageHelper.open(localName)
|
||||||
|
let lock = try localVMDir.lock()
|
||||||
|
if try !lock.trylock() {
|
||||||
|
throw RuntimeError.VMIsRunning(localName)
|
||||||
|
}
|
||||||
|
|
||||||
// Parse remote names supplied by the user
|
// Parse remote names supplied by the user
|
||||||
let remoteNames = try remoteNames.map{
|
let remoteNames = try remoteNames.map{
|
||||||
@@ -53,23 +64,57 @@ struct Push: AsyncParsableCommand {
|
|||||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||||
|
|
||||||
let pushedRemoteName = try await localVMDir.pushToRegistry(
|
let references = remoteNamesForRegistry.map{ $0.reference.value }
|
||||||
registry: registry,
|
|
||||||
references: remoteNamesForRegistry.map{ $0.reference.value },
|
|
||||||
chunkSizeMb: chunkSize
|
|
||||||
)
|
|
||||||
|
|
||||||
// Populate the local cache (if requested)
|
let pushedRemoteName: RemoteName
|
||||||
|
// If we're pushing a local OCI VM, check if points to an already existing registry manifest
|
||||||
|
// and if so, only upload manifests (without config, disk and NVRAM) to the user-specified references
|
||||||
|
if let remoteName = try? RemoteName(localName) {
|
||||||
|
pushedRemoteName = try await lightweightPushToRegistry(
|
||||||
|
registry: registry,
|
||||||
|
remoteName: remoteName,
|
||||||
|
references: references
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||||
|
registry: registry,
|
||||||
|
references: references,
|
||||||
|
chunkSizeMb: chunkSize,
|
||||||
|
diskFormat: diskFormat,
|
||||||
|
concurrency: concurrency
|
||||||
|
)
|
||||||
|
// Populate the local cache (if requested)
|
||||||
|
if populateCache {
|
||||||
|
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
||||||
|
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// link the rest remote names
|
||||||
if populateCache {
|
if populateCache {
|
||||||
let ociStorage = VMStorageOCI()
|
|
||||||
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
|
||||||
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
|
||||||
for remoteName in remoteNamesForRegistry {
|
for remoteName in remoteNamesForRegistry {
|
||||||
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lightweightPushToRegistry(registry: Registry, remoteName: RemoteName, references: [String]) async throws -> RemoteName {
|
||||||
|
// Is the local OCI VM already present in the registry?
|
||||||
|
let digest = try VMStorageOCI().digest(remoteName)
|
||||||
|
|
||||||
|
let (remoteManifest, _) = try await registry.pullManifest(reference: digest)
|
||||||
|
|
||||||
|
// Overwrite registry's references with the retrieved manifest
|
||||||
|
for reference in references {
|
||||||
|
defaultLogger.appendNewLine("pushing manifest for \(reference)...")
|
||||||
|
|
||||||
|
_ = try await registry.pushManifest(reference: reference, manifest: remoteManifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RemoteName(host: registry.host!, namespace: registry.namespace,
|
||||||
|
reference: Reference(digest: digest))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Collection where Element == RemoteName {
|
extension Collection where Element == RemoteName {
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -14,12 +14,27 @@ struct Stop: AsyncParsableCommand {
|
|||||||
|
|
||||||
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)
|
switch try vmDir.state() {
|
||||||
|
case .Suspended:
|
||||||
|
try stopSuspended(vmDir)
|
||||||
|
case .Running:
|
||||||
|
try await stopRunning(vmDir)
|
||||||
|
case .Stopped:
|
||||||
|
throw RuntimeError.VMNotRunning(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopSuspended(_ vmDir: VMDirectory) throws {
|
||||||
|
try? FileManager.default.removeItem(at: vmDir.stateURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRunning(_ vmDir: VMDirectory) async throws {
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import System
|
||||||
|
import SwiftDate
|
||||||
|
|
||||||
|
struct Suspend: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM")
|
||||||
|
|
||||||
|
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
|
let lock = try vmDir.lock()
|
||||||
|
|
||||||
|
// Find the VM's PID
|
||||||
|
let pid = try lock.pid()
|
||||||
|
if pid == 0 {
|
||||||
|
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell the "tart run" process to suspend the VM
|
||||||
|
let ret = kill(pid, SIGUSR1)
|
||||||
|
if ret != 0 {
|
||||||
|
throw RuntimeError.SuspendFailed("failed to send SIGUSR1 signal to the \"tart run\" process running VM \"\(name)\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,13 +41,18 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
|
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
let outputData = try outPipe.fileHandleForReading.readToEnd()
|
||||||
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 +64,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 {
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import Foundation
|
|||||||
|
|
||||||
class EnvironmentCredentialsProvider: CredentialsProvider {
|
class EnvironmentCredentialsProvider: CredentialsProvider {
|
||||||
func retrieve(host: String) throws -> (String, String)? {
|
func retrieve(host: String) throws -> (String, String)? {
|
||||||
|
if let tartRegistryHostname = ProcessInfo.processInfo.environment["TART_REGISTRY_HOSTNAME"],
|
||||||
|
tartRegistryHostname != host {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
let username = ProcessInfo.processInfo.environment["TART_REGISTRY_USERNAME"]
|
let username = ProcessInfo.processInfo.environment["TART_REGISTRY_USERNAME"]
|
||||||
let password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"]
|
let password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"]
|
||||||
if let username = username, let password = password {
|
if let username = username, let password = password {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Foundation
|
||||||
|
import Sysctl
|
||||||
|
|
||||||
|
class DeviceInfo {
|
||||||
|
private static var osMemoized: String? = nil
|
||||||
|
private static var modelMemoized: String? = nil
|
||||||
|
|
||||||
|
static var os: String {
|
||||||
|
if let os = osMemoized {
|
||||||
|
return os
|
||||||
|
}
|
||||||
|
|
||||||
|
osMemoized = getOS()
|
||||||
|
|
||||||
|
return osMemoized!
|
||||||
|
}
|
||||||
|
|
||||||
|
static var model: String {
|
||||||
|
if let model = modelMemoized {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
modelMemoized = getModel()
|
||||||
|
|
||||||
|
return modelMemoized!
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func getOS() -> String {
|
||||||
|
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
|
|
||||||
|
return "macOS \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func getModel() -> String {
|
||||||
|
return SystemControl().hardware.model
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -26,10 +26,16 @@ enum Format: String, ExpressibleByArgument, CaseIterable {
|
|||||||
}
|
}
|
||||||
let table = TextTable<T> { (item: T) in
|
let table = TextTable<T> { (item: T) in
|
||||||
let mirroredObject = Mirror(reflecting: item)
|
let mirroredObject = Mirror(reflecting: item)
|
||||||
return mirroredObject.children.enumerated().map { (_, element) in
|
return mirroredObject.children.enumerated()
|
||||||
let fieldName = element.label!
|
.filter {(_, element) in
|
||||||
return Column(title: fieldName, value: element.value)
|
// Deprecate the "Running" field: only make it available
|
||||||
}
|
// from JSON for backwards-compatibility
|
||||||
|
element.label! != "Running"
|
||||||
|
}
|
||||||
|
.map { (_, element) in
|
||||||
|
let fieldName = element.label!
|
||||||
|
return Column(title: fieldName, value: element.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
case .json:
|
case .json:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ struct ARPCache {
|
|||||||
self.arpCommandOutput = arpCommandOutput
|
self.arpCommandOutput = arpCommandOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||||
let lines = String(decoding: arpCommandOutput, as: UTF8.self)
|
let lines = String(decoding: arpCommandOutput, as: UTF8.self)
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.components(separatedBy: "\n")
|
.components(separatedBy: "\n")
|
||||||
@@ -95,11 +95,6 @@ struct ARPCache {
|
|||||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
|
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
|
||||||
}
|
}
|
||||||
|
|
||||||
let interface = try match.getCaptureGroup(name: "interface", for: line)
|
|
||||||
if bridgeOnly && !interface.starts(with: "bridge") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if macAddress == mac {
|
if macAddress == mac {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?) 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) 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,270 @@
|
|||||||
|
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) async throws {
|
||||||
|
// Support resumable pulls
|
||||||
|
let pullResumed = FileManager.default.fileExists(atPath: diskURL.path)
|
||||||
|
|
||||||
|
if !pullResumed {
|
||||||
|
if 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 localLayerCache != nil {
|
||||||
|
try FileHandle(forReadingFrom: diskURL)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we already have this layer contents in the local layer cache
|
||||||
|
if let localLayerCache = localLayerCache, let localLayerInfo = localLayerCache.findInfo(digest: diskLayer.digest, offsetHint: diskWritingOffset) {
|
||||||
|
// indicates that the locally cloned disk image has the same content at the given offset
|
||||||
|
let localHit = localLayerInfo.uncompressedContentDigest == uncompressedLayerContentDigest
|
||||||
|
&& localLayerInfo.range.lowerBound == diskWritingOffset
|
||||||
|
// doesn't seem that localHit can ever be false if the localLayerCache is not nil
|
||||||
|
// but let's just add extra safety here and check it
|
||||||
|
if !localHit {
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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,10 +1,22 @@
|
|||||||
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"
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -13,15 +25,21 @@ struct OCIManifest: Codable, Equatable {
|
|||||||
var layers: [OCIManifestLayer] = Array()
|
var layers: [OCIManifestLayer] = Array()
|
||||||
var annotations: Dictionary<String, String>?
|
var annotations: Dictionary<String, String>?
|
||||||
|
|
||||||
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil) {
|
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil, uploadDate: Date? = nil) {
|
||||||
self.config = config
|
self.config = config
|
||||||
self.layers = layers
|
self.layers = layers
|
||||||
|
|
||||||
|
var annotations: [String: String] = [:]
|
||||||
|
|
||||||
if let uncompressedDiskSize = uncompressedDiskSize {
|
if let uncompressedDiskSize = uncompressedDiskSize {
|
||||||
annotations = [
|
annotations[uncompressedDiskSizeAnnotation] = String(uncompressedDiskSize)
|
||||||
uncompressedDiskSizeAnnotation: String(uncompressedDiskSize)
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let uploadDate = uploadDate {
|
||||||
|
annotations[uploadTimeAnnotation] = uploadDate.toISO()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.annotations = annotations
|
||||||
}
|
}
|
||||||
|
|
||||||
init(fromJSON: Data) throws {
|
init(fromJSON: Data) throws {
|
||||||
@@ -60,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 {
|
||||||
|
|||||||
@@ -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,10 +44,8 @@ extension AsyncThrowingChannel<Data, Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TokenResponse: Decodable, Authentication {
|
struct TokenResponse: Decodable, Authentication {
|
||||||
let defaultIssuedAt = Date()
|
var token: String?
|
||||||
let defaultExpiresIn = 60
|
var accessToken: String?
|
||||||
|
|
||||||
var token: String
|
|
||||||
var expiresIn: Int?
|
var expiresIn: Int?
|
||||||
var issuedAt: Date?
|
var issuedAt: Date?
|
||||||
|
|
||||||
@@ -65,7 +65,14 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
return dateFormatter.date(from: dateString) ?? Date()
|
return dateFormatter.date(from: dateString) ?? Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
return 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 {
|
||||||
|
throw DecodingError.keyNotFound(CodingKeys.token, .init(codingPath: [], debugDescription: "Missing token or access_token. One must be present."))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenExpiresAt: Date {
|
var tokenExpiresAt: Date {
|
||||||
@@ -78,12 +85,12 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func header() -> (String, String) {
|
func header() -> (String, String) {
|
||||||
("Authorization", "Bearer \(token)")
|
return ("Authorization", "Bearer \(token ?? accessToken ?? "")")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isValid() -> Bool {
|
func isValid() -> Bool {
|
||||||
@@ -92,17 +99,26 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Registry {
|
class Registry {
|
||||||
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? {
|
||||||
|
guard let host = baseURL.host else { return nil }
|
||||||
|
|
||||||
init(urlComponents: URLComponents,
|
if let port = baseURL.port {
|
||||||
|
return "\(host):\(port)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -116,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 {
|
||||||
@@ -165,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"])
|
||||||
@@ -177,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
|
||||||
@@ -225,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()
|
||||||
@@ -236,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,11 +327,6 @@ class Registry {
|
|||||||
request.httpBody = body
|
request.httpBody = body
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate token if it has expired
|
|
||||||
if currentAuthToken?.isValid() == false {
|
|
||||||
currentAuthToken = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||||
|
|
||||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||||
@@ -314,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
|
||||||
@@ -361,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)? {
|
||||||
@@ -382,11 +416,13 @@ class Registry {
|
|||||||
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
var request = request
|
var request = request
|
||||||
|
|
||||||
if let token = currentAuthToken {
|
if let (name, value) = await authenticationKeeper.header() {
|
||||||
let (name, value) = token.header()
|
|
||||||
request.addValue(value, forHTTPHeaderField: name)
|
request.addValue(value, forHTTPHeaderField: name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))",
|
||||||
|
forHTTPHeaderField: "User-Agent")
|
||||||
|
|
||||||
return try await Fetcher.fetch(request, viaFile: viaFile)
|
return try await Fetcher.fetch(request, viaFile: viaFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ class PIDLock {
|
|||||||
init(lockURL: URL) throws {
|
init(lockURL: URL) throws {
|
||||||
url = lockURL
|
url = lockURL
|
||||||
fd = open(lockURL.path, O_RDWR)
|
fd = open(lockURL.path, O_RDWR)
|
||||||
|
if fd == -1 {
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError.PIDLockFailed("failed to open lock file \(url): \(details)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
|||||||
@@ -1,97 +1,136 @@
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
struct Darwin: Platform {
|
struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||||
var ecid: VZMacMachineIdentifier
|
var description: String {
|
||||||
var hardwareModel: VZMacHardwareModel
|
"error: host macOS version is outdated to run this virtual machine"
|
||||||
|
|
||||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
|
||||||
self.ecid = ecid
|
|
||||||
self.hardwareModel = hardwareModel
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
#if arch(arm64)
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
struct Darwin: PlatformSuspendable {
|
||||||
guard let data = Data.init(base64Encoded: encodedECID) else {
|
var ecid: VZMacMachineIdentifier
|
||||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
var hardwareModel: VZMacHardwareModel
|
||||||
in: container,
|
|
||||||
debugDescription: "failed to initialize Data using the provided value")
|
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||||
|
self.ecid = ecid
|
||||||
|
self.hardwareModel = hardwareModel
|
||||||
}
|
}
|
||||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
init(from decoder: Decoder) throws {
|
||||||
in: container,
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
self.ecid = ecid
|
|
||||||
|
|
||||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
func encode(to encoder: Encoder) throws {
|
||||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
|
||||||
|
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||||
|
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||||
}
|
}
|
||||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
func os() -> OS {
|
||||||
|
.darwin
|
||||||
}
|
}
|
||||||
self.hardwareModel = hardwareModel
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
VZMacOSBootLoader()
|
||||||
|
}
|
||||||
|
|
||||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
|
||||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
let result = VZMacPlatformConfiguration()
|
||||||
}
|
|
||||||
|
|
||||||
func os() -> OS {
|
result.machineIdentifier = ecid
|
||||||
.darwin
|
result.auxiliaryStorage = VZMacAuxiliaryStorage(url: nvramURL)
|
||||||
}
|
|
||||||
|
|
||||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
if !hardwareModel.isSupported {
|
||||||
VZMacOSBootLoader()
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
result.hardwareModel = hardwareModel
|
||||||
let result = VZMacPlatformConfiguration()
|
|
||||||
|
|
||||||
result.machineIdentifier = ecid
|
return result
|
||||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
}
|
||||||
result.hardwareModel = hardwareModel
|
|
||||||
|
|
||||||
return result
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||||
}
|
let result = VZMacGraphicsDeviceConfiguration()
|
||||||
|
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
if let hostMainScreen = NSScreen.main {
|
||||||
let result = VZMacGraphicsDeviceConfiguration()
|
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 keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||||
}
|
if #available(macOS 14, *) {
|
||||||
|
return [VZMacKeyboardConfiguration()]
|
||||||
|
} else {
|
||||||
|
// 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 starting with macOS Ventura
|
[VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()]
|
||||||
// macOS Monterey will continue using a USB device == .darwin
|
}
|
||||||
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
|
||||||
} else {
|
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
if #available(macOS 14, *) {
|
||||||
|
return [VZMacTrackpadConfiguration()]
|
||||||
|
} else {
|
||||||
|
// fallback to the regular configuration
|
||||||
|
return pointingDevices()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ struct Linux: Platform {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
|
||||||
VZGenericPlatformConfiguration()
|
VZGenericPlatformConfiguration()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +31,10 @@ struct Linux: Platform {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func keyboards() -> [VZKeyboardConfiguration] {
|
||||||
|
[VZUSBKeyboardConfiguration()]
|
||||||
|
}
|
||||||
|
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ 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) -> VZPlatformConfiguration
|
func platform(nvramURL: URL) throws -> VZPlatformConfiguration
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||||
|
func keyboards() -> [VZKeyboardConfiguration]
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protocol PlatformSuspendable: Platform {
|
||||||
|
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration]
|
||||||
|
func keyboardsSuspendable() -> [VZKeyboardConfiguration]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -57,6 +63,11 @@ struct Root: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add commands that are only available on specific macOS versions
|
||||||
|
if #available(macOS 14, *) {
|
||||||
|
configuration.subcommands.append(Suspend.self)
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure the default SIGINT handled is disabled,
|
// Ensure the default SIGINT handled is disabled,
|
||||||
// otherwise there's a race between two handlers
|
// otherwise there's a race between two handlers
|
||||||
signal(SIGINT, SIG_IGN);
|
signal(SIGINT, SIG_IGN);
|
||||||
@@ -76,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 {
|
||||||
@@ -94,7 +107,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
|
|
||||||
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
|
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
|
||||||
if let errorWithExitCode = error as? HasExitCode {
|
if let errorWithExitCode = error as? HasExitCode {
|
||||||
print(error)
|
fputs("\(error)\n", stderr)
|
||||||
|
|
||||||
Foundation.exit(errorWithExitCode.exitCode)
|
Foundation.exit(errorWithExitCode.exitCode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
func createPTY() -> Int32 {
|
||||||
|
var tty_fd: Int32 = -1
|
||||||
|
var sfd: Int32 = -1
|
||||||
|
var termios_ = termios()
|
||||||
|
let tty_path = UnsafeMutablePointer<CChar>.allocate(capacity: 1024)
|
||||||
|
|
||||||
|
var res = openpty(&tty_fd, &sfd, tty_path, nil, nil);
|
||||||
|
if (res < 0) {
|
||||||
|
perror("openpty error")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// close slave file descriptor
|
||||||
|
close(sfd)
|
||||||
|
|
||||||
|
res = fcntl(tty_fd, F_GETFL)
|
||||||
|
if (res < 0) {
|
||||||
|
perror("fcntl F_GETFL error")
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// set serial nonblocking
|
||||||
|
res = fcntl(tty_fd, F_SETFL, res | O_NONBLOCK)
|
||||||
|
if (res < 0) {
|
||||||
|
perror("fcntl F_SETFL O_NONBLOCK error")
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// set baudrate to 115200
|
||||||
|
tcgetattr(tty_fd, &termios_)
|
||||||
|
cfsetispeed(&termios_, speed_t(B115200))
|
||||||
|
cfsetospeed(&termios_, speed_t(B115200))
|
||||||
|
if (tcsetattr(tty_fd, TCSANOW, &termios_) != 0) {
|
||||||
|
perror("tcsetattr error")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Successfully open pty \(String(cString: tty_path))")
|
||||||
|
|
||||||
|
tty_path.deallocate()
|
||||||
|
return tty_fd
|
||||||
|
}
|
||||||
@@ -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 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,34 +5,26 @@ import Dynamic
|
|||||||
// Kudos to @saagarjha's VirtualApple for finding about _VZVirtualMachineStartOptions
|
// Kudos to @saagarjha's VirtualApple for finding about _VZVirtualMachineStartOptions
|
||||||
|
|
||||||
extension VZVirtualMachine {
|
extension VZVirtualMachine {
|
||||||
@available(macOS 12, *)
|
@MainActor @available(macOS 12, *)
|
||||||
func start(_ recovery: Bool) async throws {
|
func start(_ recovery: Bool) async throws {
|
||||||
if !recovery {
|
if !recovery {
|
||||||
// just use the regular API
|
// just use the regular API
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
return try await self.start()
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.start(completionHandler: { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// use some private stuff only for recovery
|
// use some private stuff only for recovery
|
||||||
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||||
DispatchQueue.main.async {
|
let handler: @convention(block) (_ result: Any?) -> Void = { result in
|
||||||
let handler: @convention(block) (_ result: Any?) -> Void = { result in
|
if let error = result as? Error {
|
||||||
if let error = result as? Error {
|
continuation.resume(throwing: error)
|
||||||
continuation.resume(throwing: error)
|
} else {
|
||||||
} else {
|
continuation.resume(returning: ())
|
||||||
continuation.resume(returning: ())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// dynamic magic
|
|
||||||
let options = Dynamic._VZVirtualMachineStartOptions()
|
|
||||||
options.bootMacOSRecovery = recovery
|
|
||||||
Dynamic(self)._start(withOptions: options, completionHandler: handler)
|
|
||||||
}
|
}
|
||||||
|
// dynamic magic
|
||||||
|
let options = Dynamic._VZVirtualMachineStartOptions()
|
||||||
|
options.bootMacOSRecovery = recovery
|
||||||
|
Dynamic(self)._start(withOptions: options, completionHandler: handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Virtualization
|
import Virtualization
|
||||||
import AsyncAlgorithms
|
import AsyncAlgorithms
|
||||||
|
import Semaphore
|
||||||
|
|
||||||
struct UnsupportedRestoreImageError: Error {
|
struct UnsupportedRestoreImageError: Error {
|
||||||
}
|
}
|
||||||
@@ -26,8 +27,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
// Virtualization.Framework's virtual machine
|
// Virtualization.Framework's virtual machine
|
||||||
@Published var virtualMachine: VZVirtualMachine
|
@Published var virtualMachine: VZVirtualMachine
|
||||||
|
|
||||||
|
// Virtualization.Framework's virtual machine configuration
|
||||||
|
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
|
||||||
@@ -39,8 +43,13 @@ 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] = [],
|
||||||
|
suspendable: 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)
|
||||||
@@ -51,10 +60,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
// Initialize the virtual machine and its configuration
|
// Initialize the virtual machine and its configuration
|
||||||
self.network = network
|
self.network = network
|
||||||
let 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,
|
||||||
|
suspendable: suspendable,
|
||||||
|
audio: audio,
|
||||||
|
clipboard: clipboard,
|
||||||
|
sync: sync
|
||||||
)
|
)
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
@@ -64,9 +78,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) {
|
||||||
@@ -80,10 +96,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()
|
||||||
@@ -95,7 +118,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()
|
||||||
@@ -105,18 +128,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 ||
|
||||||
@@ -126,80 +137,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] = [],
|
||||||
) async throws {
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||||
var ipswURL = ipswURL
|
serialPorts: [VZSerialPortConfiguration] = []
|
||||||
|
) 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
|
|
||||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
|
||||||
vmConfig: config, network: network,
|
|
||||||
additionalDiskAttachments: additionalDiskAttachments,
|
|
||||||
directorySharingDevices: directorySharingDevices
|
|
||||||
)
|
|
||||||
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 {
|
||||||
@@ -216,52 +237,67 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
return try VM(vmDir: vmDir)
|
return try VM(vmDir: vmDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(_ recovery: Bool) async throws {
|
func start(recovery: Bool, resume shouldResume: Bool) async throws {
|
||||||
try network.run(sema)
|
try network.run(sema)
|
||||||
|
|
||||||
let startTask = DispatchQueue.main.sync {
|
if shouldResume {
|
||||||
Task {
|
try await resume()
|
||||||
if #available(macOS 13, *) {
|
} else {
|
||||||
// new API introduced in Ventura
|
try await start(recovery)
|
||||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
}
|
||||||
startOptions.startUpFromMacOSRecovery = recovery
|
}
|
||||||
try await virtualMachine.start(options: startOptions)
|
|
||||||
} else {
|
func run() async throws {
|
||||||
// use method that also available on Monterey
|
do {
|
||||||
try await virtualMachine.start(recovery)
|
try await sema.waitUnlessCancelled()
|
||||||
}
|
} catch is CancellationError {
|
||||||
}
|
// Triggered by "tart stop", Ctrl+C, or closing the
|
||||||
|
// VM window, so shut down the VM gracefully below.
|
||||||
}
|
}
|
||||||
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
|
||||||
// Await on VZVirtualMachine.start() result
|
|
||||||
_ = try await startTask.value
|
|
||||||
|
|
||||||
// Wait for the VM to finish running
|
|
||||||
// or for the exit condition
|
|
||||||
sema.wait()
|
|
||||||
}, onCancel: {
|
|
||||||
sema.signal()
|
|
||||||
})
|
|
||||||
|
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
DispatchQueue.main.sync {
|
if (self.virtualMachine.state == VZVirtualMachine.State.running) {
|
||||||
Task {
|
print("Stopping VM...")
|
||||||
try await self.virtualMachine.stop()
|
try await stop()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try await network.stop()
|
try await network.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func start(_ recovery: Bool) async throws {
|
||||||
|
#if arch(arm64)
|
||||||
|
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||||
|
startOptions.startUpFromMacOSRecovery = recovery
|
||||||
|
try await virtualMachine.start(options: startOptions)
|
||||||
|
#else
|
||||||
|
try await virtualMachine.start()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func resume() async throws {
|
||||||
|
try await virtualMachine.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func stop() async throws {
|
||||||
|
try await self.virtualMachine.stop()
|
||||||
|
}
|
||||||
|
|
||||||
static func craftConfiguration(
|
static func craftConfiguration(
|
||||||
diskURL: URL,
|
diskURL: URL,
|
||||||
nvramURL: URL,
|
nvramURL: URL,
|
||||||
vmConfig: VMConfig,
|
vmConfig: VMConfig,
|
||||||
network: Network = NetworkShared(),
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
additionalStorageDevices: [VZStorageDeviceConfiguration],
|
||||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration]
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||||
|
serialPorts: [VZSerialPortConfiguration],
|
||||||
|
suspendable: Bool = false,
|
||||||
|
audio: Bool = true,
|
||||||
|
clipboard: Bool = true,
|
||||||
|
sync: VZDiskImageSynchronizationMode = .full
|
||||||
) throws -> VZVirtualMachineConfiguration {
|
) throws -> VZVirtualMachineConfiguration {
|
||||||
let configuration = VZVirtualMachineConfiguration()
|
let configuration = VZVirtualMachineConfiguration()
|
||||||
|
|
||||||
@@ -273,41 +309,97 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
configuration.memorySize = vmConfig.memorySize
|
configuration.memorySize = vmConfig.memorySize
|
||||||
|
|
||||||
// Platform
|
// Platform
|
||||||
configuration.platform = vmConfig.platform.platform(nvramURL: nvramURL)
|
configuration.platform = try vmConfig.platform.platform(nvramURL: nvramURL)
|
||||||
|
|
||||||
// Display
|
// Display
|
||||||
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||||
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
|
||||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
if audio && !suspendable {
|
||||||
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
|
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
||||||
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
|
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
|
||||||
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
|
|
||||||
|
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||||
|
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
|
||||||
|
|
||||||
|
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
|
||||||
|
} else {
|
||||||
|
// just a null speaker
|
||||||
|
soundDeviceConfiguration.streams = [VZVirtioSoundDeviceOutputStreamConfiguration()]
|
||||||
|
}
|
||||||
|
|
||||||
configuration.audioDevices = [soundDeviceConfiguration]
|
configuration.audioDevices = [soundDeviceConfiguration]
|
||||||
|
|
||||||
// Keyboard and mouse
|
// Keyboard and mouse
|
||||||
configuration.keyboards = [VZUSBKeyboardConfiguration()]
|
if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
|
||||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
|
||||||
|
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
|
||||||
|
} else {
|
||||||
|
configuration.keyboards = vmConfig.platform.keyboards()
|
||||||
|
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||||
|
}
|
||||||
|
|
||||||
// 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 device: VZStorageDeviceConfiguration
|
||||||
|
if #available(macOS 14, *), vmConfig.os == .linux {
|
||||||
|
device = VZNVMExpressControllerDeviceConfiguration(attachment: attachment)
|
||||||
|
} else {
|
||||||
|
device = VZVirtioBlockDeviceConfiguration(attachment: attachment)
|
||||||
|
}
|
||||||
|
var devices: [VZStorageDeviceConfiguration] = [device]
|
||||||
|
devices.append(contentsOf: additionalStorageDevices)
|
||||||
|
configuration.storageDevices = devices
|
||||||
|
|
||||||
// Entropy
|
// Entropy
|
||||||
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
if !suspendable {
|
||||||
|
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||||
|
}
|
||||||
|
|
||||||
// Directory sharing devices
|
// Directory sharing devices
|
||||||
configuration.directorySharingDevices = directorySharingDevices
|
configuration.directorySharingDevices = directorySharingDevices
|
||||||
|
|
||||||
|
// Serial Port
|
||||||
|
configuration.serialPorts = serialPorts
|
||||||
|
|
||||||
|
// Version console device
|
||||||
|
//
|
||||||
|
// A dummy console device useful for implementing
|
||||||
|
// host feature checks in the guest agent software.
|
||||||
|
if !suspendable {
|
||||||
|
let consolePort = VZVirtioConsolePortConfiguration()
|
||||||
|
consolePort.name = "tart-version-\(CI.version)"
|
||||||
|
|
||||||
|
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||||
|
consoleDevice.ports[0] = consolePort
|
||||||
|
|
||||||
|
configuration.consoleDevices.append(consoleDevice)
|
||||||
|
}
|
||||||
|
|
||||||
try configuration.validate()
|
try configuration.validate()
|
||||||
|
|
||||||
return configuration
|
return configuration
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ extension VMDirectory {
|
|||||||
try? decodeStream.close()
|
try? decodeStream.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let extractStream = ArchiveStream.extractStream(extractingTo: FilePath(baseURL.path)) else {
|
guard let extractStream = ArchiveStream.extractStream(extractingTo: FilePath(baseURL.path),
|
||||||
|
flags: [.ignoreOperationNotPermitted]) else {
|
||||||
let details = Errno(rawValue: CInt(errno))
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
throw RuntimeError.ImportFailed("ArchiveStream.extractStream() failed: \(details)")
|
throw RuntimeError.ImportFailed("ArchiveStream.extractStream() failed: \(details)")
|
||||||
|
|||||||
@@ -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?) 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,47 @@ 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)
|
||||||
}
|
} catch let error where error is FilterError {
|
||||||
|
throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if 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 +81,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 +94,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 +117,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 +125,8 @@ 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()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manifest
|
// Manifest
|
||||||
@@ -159,7 +137,7 @@ extension VMDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pushedReference = Reference(digest: try manifest.digest())
|
let pushedReference = Reference(digest: try manifest.digest())
|
||||||
return RemoteName(host: registry.baseURL.host!, namespace: registry.namespace, reference: pushedReference)
|
return RemoteName(host: registry.host!, namespace: registry.namespace, reference: pushedReference)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -13,6 +20,12 @@ struct VMDirectory: Prunable {
|
|||||||
var nvramURL: URL {
|
var nvramURL: URL {
|
||||||
baseURL.appendingPathComponent("nvram.bin")
|
baseURL.appendingPathComponent("nvram.bin")
|
||||||
}
|
}
|
||||||
|
var stateURL: URL {
|
||||||
|
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")
|
||||||
@@ -26,6 +39,33 @@ struct VMDirectory: Prunable {
|
|||||||
baseURL
|
baseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lock() throws -> PIDLock {
|
||||||
|
try PIDLock(lockURL: configURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func running() throws -> Bool {
|
||||||
|
// The most common reason why PIDLock() instantiation fails is a race with "tart delete" (ENOENT),
|
||||||
|
// 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 cost of crashing with an exception when calling "tart list" on a busy machine, for example.
|
||||||
|
guard let lock = try? lock() else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return try lock.pid() != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func state() throws -> State {
|
||||||
|
if try running() {
|
||||||
|
return State.Running
|
||||||
|
} else if FileManager.default.fileExists(atPath: stateURL.path) {
|
||||||
|
return State.Suspended
|
||||||
|
} else {
|
||||||
|
return State.Stopped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func temporary() throws -> VMDirectory {
|
static func temporary() throws -> VMDirectory {
|
||||||
let tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString)
|
let tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString)
|
||||||
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
|
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
|
||||||
@@ -33,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) &&
|
||||||
@@ -51,9 +102,9 @@ struct VMDirectory: Prunable {
|
|||||||
try? FileManager.default.removeItem(at: nvramURL)
|
try? FileManager.default.removeItem(at: nvramURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate() throws {
|
func validate(userFriendlyName: String) throws {
|
||||||
if !FileManager.default.fileExists(atPath: baseURL.path) {
|
if !FileManager.default.fileExists(atPath: baseURL.path) {
|
||||||
throw RuntimeError.VMDoesNotExist(name: baseURL.lastPathComponent)
|
throw RuntimeError.VMDoesNotExist(name: userFriendlyName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !initialized {
|
if !initialized {
|
||||||
@@ -66,6 +117,7 @@ struct VMDirectory: Prunable {
|
|||||||
try FileManager.default.copyItem(at: configURL, to: to.configURL)
|
try FileManager.default.copyItem(at: configURL, to: to.configURL)
|
||||||
try FileManager.default.copyItem(at: nvramURL, to: to.nvramURL)
|
try FileManager.default.copyItem(at: nvramURL, to: to.nvramURL)
|
||||||
try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
|
try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
|
||||||
|
try? FileManager.default.copyItem(at: stateURL, to: to.stateURL)
|
||||||
|
|
||||||
// Re-generate MAC address
|
// Re-generate MAC address
|
||||||
if generateMAC {
|
if generateMAC {
|
||||||
@@ -81,6 +133,8 @@ struct VMDirectory: Prunable {
|
|||||||
var vmConfig = try VMConfig(fromURL: configURL)
|
var vmConfig = try VMConfig(fromURL: configURL)
|
||||||
|
|
||||||
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||||
|
// cleanup state if any
|
||||||
|
try? FileManager.default.removeItem(at: stateURL)
|
||||||
|
|
||||||
try vmConfig.save(toURL: configURL)
|
try vmConfig.save(toURL: configURL)
|
||||||
}
|
}
|
||||||
@@ -89,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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,28 +34,44 @@ 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() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RuntimeError : Error {
|
enum RuntimeError : Error {
|
||||||
|
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 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 OCIUnsupportedDiskFormat(_ format: String)
|
||||||
|
case SuspendFailed(_ message: String)
|
||||||
|
case PullFailed(_ message: String)
|
||||||
|
case VirtualMachineLimitExceeded(_ hint: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol HasExitCode {
|
protocol HasExitCode {
|
||||||
@@ -65,18 +81,26 @@ protocol HasExitCode {
|
|||||||
extension RuntimeError : CustomStringConvertible {
|
extension RuntimeError : CustomStringConvertible {
|
||||||
public var description: String {
|
public var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .VMConfigurationError(let message):
|
||||||
|
return message
|
||||||
case .VMDoesNotExist(let name):
|
case .VMDoesNotExist(let name):
|
||||||
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):
|
||||||
@@ -85,6 +109,8 @@ extension RuntimeError : CustomStringConvertible {
|
|||||||
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):
|
||||||
@@ -95,6 +121,16 @@ extension RuntimeError : CustomStringConvertible {
|
|||||||
return "VM import failed: \(message)"
|
return "VM import failed: \(message)"
|
||||||
case .SoftnetFailed(let message):
|
case .SoftnetFailed(let message):
|
||||||
return "Softnet failed: \(message)"
|
return "Softnet failed: \(message)"
|
||||||
|
case .OCIStorageError(let message):
|
||||||
|
return "OCI storage error: \(message)"
|
||||||
|
case .OCIUnsupportedDiskFormat(let format):
|
||||||
|
return "OCI disk format \(format) is not supported by this version of Tart"
|
||||||
|
case .SuspendFailed(let message):
|
||||||
|
return "Failed to suspend the VM: \(message)"
|
||||||
|
case .PullFailed(let message):
|
||||||
|
return message
|
||||||
|
case .VirtualMachineLimitExceeded(let hint):
|
||||||
|
return "The number of VMs exceeds the system limit\(hint)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,6 +138,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:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class VMStorageLocal {
|
class VMStorageLocal: PrunableStorage {
|
||||||
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||||
|
|
||||||
private func vmURL(_ name: String) -> URL {
|
private func vmURL(_ name: String) -> URL {
|
||||||
@@ -14,7 +14,9 @@ class VMStorageLocal {
|
|||||||
func open(_ name: String) throws -> VMDirectory {
|
func open(_ name: String) throws -> VMDirectory {
|
||||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||||
|
|
||||||
try vmDir.validate()
|
try vmDir.validate(userFriendlyName: name)
|
||||||
|
|
||||||
|
try vmDir.baseURL.updateAccessDate()
|
||||||
|
|
||||||
return vmDir
|
return vmDir
|
||||||
}
|
}
|
||||||
@@ -37,7 +39,7 @@ class VMStorageLocal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)] {
|
||||||
@@ -63,6 +65,10 @@ class VMStorageLocal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prunables() throws -> [Prunable] {
|
||||||
|
try list().map { (_, vmDir) in vmDir }.filter { try !$0.running() }
|
||||||
|
}
|
||||||
|
|
||||||
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
||||||
try list().contains { try $1.macAddress() == macAddress }
|
try list().contains { try $1.macAddress() == macAddress }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -16,10 +17,20 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
VMDirectory(baseURL: vmURL(name)).initialized
|
VMDirectory(baseURL: vmURL(name)).initialized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func digest(_ name: RemoteName) throws -> String {
|
||||||
|
let digest = vmURL(name).resolvingSymlinksInPath().lastPathComponent
|
||||||
|
|
||||||
|
if !digest.starts(with: "sha256:") {
|
||||||
|
throw RuntimeError.OCIStorageError("\(name) is not a digest and doesn't point to a digest")
|
||||||
|
}
|
||||||
|
|
||||||
|
return digest
|
||||||
|
}
|
||||||
|
|
||||||
func open(_ name: RemoteName) throws -> VMDirectory {
|
func open(_ name: RemoteName) throws -> VMDirectory {
|
||||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||||
|
|
||||||
try vmDir.validate()
|
try vmDir.validate(userFriendlyName: name.description)
|
||||||
|
|
||||||
try vmDir.baseURL.updateAccessDate()
|
try vmDir.baseURL.updateAccessDate()
|
||||||
|
|
||||||
@@ -102,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
|
||||||
|
|
||||||
@@ -112,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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +140,11 @@ 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) async throws {
|
||||||
|
SentrySDK.configureScope { scope in
|
||||||
|
scope.setContext(value: ["imageName": name.description], key: "OCI")
|
||||||
|
}
|
||||||
|
|
||||||
defaultLogger.appendNewLine("pulling manifest...")
|
defaultLogger.appendNewLine("pulling manifest...")
|
||||||
|
|
||||||
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
||||||
@@ -130,6 +152,12 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||||
reference: Reference(digest: Digest.hash(manifestData)))
|
reference: Reference(digest: Digest.hash(manifestData)))
|
||||||
|
|
||||||
|
if exists(name) && exists(digestName) && linked(from: name, to: digestName) {
|
||||||
|
// optimistically check if we need to do anything at all before locking
|
||||||
|
defaultLogger.appendNewLine("\(digestName) image is already cached and linked!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure that host directory for given RemoteName exists in OCI storage
|
// Ensure that host directory for given RemoteName exists in OCI storage
|
||||||
let hostDirectoryURL = hostDirectoryURL(digestName)
|
let hostDirectoryURL = hostDirectoryURL(digestName)
|
||||||
try FileManager.default.createDirectory(at: hostDirectoryURL, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: hostDirectoryURL, withIntermediateDirectories: true)
|
||||||
@@ -150,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)
|
||||||
@@ -158,40 +186,37 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
|
|
||||||
// Try to reclaim some cache space if we know the VM size in advance
|
// Try to reclaim some cache space if we know the VM size in advance
|
||||||
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
||||||
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
|
SentrySDK.configureScope { scope in
|
||||||
|
scope.setContext(value: ["imageUncompressedDiskSize": uncompressedDiskSize], key: "OCI")
|
||||||
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey])
|
|
||||||
let capacityImportant = attrs.volumeAvailableCapacityForImportantUsage!
|
|
||||||
let capacityAvailable = attrs.volumeAvailableCapacity!
|
|
||||||
let availableCapacityBytes = max(UInt64(capacityImportant), UInt64(capacityAvailable))
|
|
||||||
|
|
||||||
if capacityImportant == 0 || capacityAvailable == 0 {
|
|
||||||
SentrySDK.capture(message: "Zero capacity") { scope in
|
|
||||||
scope.setLevel(.warning)
|
|
||||||
|
|
||||||
scope.setContext(value: [
|
|
||||||
"volumeAvailableCapacityForImportantUsageKey": capacityImportant,
|
|
||||||
"volumeAvailableCapacityKey": capacityAvailable,
|
|
||||||
], key: "Attributes")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// There is a suspicious that occasionally capacity is returned as zero which can't be true.
|
let otherVMFilesSize: UInt64 = 128 * 1024 * 1024
|
||||||
// Let's validate to avoid unnecessary pruning.
|
|
||||||
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
|
|
||||||
let transaction = SentrySDK.startTransaction(name: "Automatically Pruning Cache", operation: "prune", bindToScope: true)
|
|
||||||
transaction.setData(value: name, key: "name")
|
|
||||||
transaction.setData(value: uncompressedDiskSize, key: "uncompressedDiskSize")
|
|
||||||
transaction.setData(value: availableCapacityBytes, key: "availableCapacity")
|
|
||||||
transaction.setData(value: requiredCapacityBytes, key: "requiredCapacity")
|
|
||||||
defer { transaction.finish() }
|
|
||||||
|
|
||||||
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
try Prune.reclaimIfNeeded(uncompressedDiskSize + otherVMFilesSize)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
defaultLogger.appendNewLine("found an image \(llc.name) that will allow us to deduplicate \(deduplicatedHuman), using it as a base...")
|
||||||
|
}
|
||||||
|
|
||||||
|
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache)
|
||||||
|
} recoverFromFailure: { error in
|
||||||
|
if error is Retryable {
|
||||||
|
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: {
|
||||||
@@ -212,22 +237,82 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func link(from: RemoteName, to: RemoteName) throws {
|
func linked(from: RemoteName, to: RemoteName) -> Bool {
|
||||||
if FileManager.default.fileExists(atPath: vmURL(from).path) {
|
do {
|
||||||
try FileManager.default.removeItem(at: vmURL(from))
|
let resolvedFrom = try FileManager.default.destinationOfSymbolicLink(atPath: vmURL(from).path)
|
||||||
|
return resolvedFrom == vmURL(to).path
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func link(from: RemoteName, to: RemoteName) throws {
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +320,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
final class DirectoryShareTests: XCTestCase {
|
||||||
|
func testNamedParsing() throws {
|
||||||
|
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build")
|
||||||
|
XCTAssertEqual(share.name, "build")
|
||||||
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertFalse(share.readOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNamedReadOnlyParsing() throws {
|
||||||
|
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build:ro")
|
||||||
|
XCTAssertEqual(share.name, "build")
|
||||||
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertTrue(share.readOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionalNameParsing() throws {
|
||||||
|
let share = try DirectoryShare(parseFrom: "/Users/admin/build")
|
||||||
|
XCTAssertNil(share.name)
|
||||||
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertFalse(share.readOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionalNameReadOnlyParsing() throws {
|
||||||
|
let share = try DirectoryShare(parseFrom: "/Users/admin/build:ro")
|
||||||
|
XCTAssertNil(share.name)
|
||||||
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||