Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
97c63bcc5b | ||
|
|
6409d53814 | ||
|
|
3ce58e8174 | ||
|
|
5e44116b69 | ||
|
|
fda5ec42e7 | ||
|
|
80e057008b | ||
|
|
dbc1632fb4 | ||
|
|
1232b2cced | ||
|
|
d497053145 | ||
|
|
d32a1b42e2 | ||
|
|
479f4b8de0 | ||
|
|
d714d52742 | ||
|
|
7abf324121 | ||
|
|
6311b8b32a | ||
|
|
4339a03102 | ||
|
|
8b76b12bd7 | ||
|
|
1f35a9b35e | ||
|
|
2be5eedeb6 | ||
|
|
9f1f3f1b40 | ||
|
|
b26398f51f | ||
|
|
78f7ba8f80 | ||
|
|
b242f27f49 | ||
|
|
b566d07bc4 | ||
|
|
d65f530bcb | ||
|
|
def779e20b | ||
|
|
92b35dbcf2 | ||
|
|
b8ff7474c3 | ||
|
|
f34aa5f072 | ||
|
|
ecc5de18be | ||
|
|
41af674a88 | ||
|
|
9c48d66674 | ||
|
|
31b106a67a | ||
|
|
abb1d7192a | ||
|
|
8b8faa2f1a | ||
|
|
c8c22e8c4d | ||
|
|
bd87e1a8b1 | ||
|
|
ba4f00fa78 | ||
|
|
1380ece108 | ||
|
|
9cdb7087f2 | ||
|
|
c3e37854c3 | ||
|
|
828351a8fb | ||
|
|
f9ac994e18 | ||
|
|
fdeaf979c2 | ||
|
|
d9f1c37cdd | ||
|
|
81253417a4 | ||
|
|
c9caeab098 | ||
|
|
6d7dc40c57 | ||
|
|
15b26f78ae | ||
|
|
d9f4f9e954 | ||
|
|
31635e59d7 | ||
|
|
e061d00afc | ||
|
|
4555dd5824 | ||
|
|
ad9c3c661e | ||
|
|
c27d4a089c | ||
|
|
70f9fcc12e | ||
|
|
456ebc1c7b | ||
|
|
14cbc727ad | ||
|
|
03e6d9345d | ||
|
|
c09cbefdd0 | ||
|
|
3896728eb9 | ||
|
|
de993fbf6d | ||
|
|
f08ddf3855 | ||
|
|
8524d93741 | ||
|
|
833c162187 | ||
|
|
31ba71dad7 | ||
|
|
5e77968989 | ||
|
|
f37372da28 | ||
|
|
e600d2f036 | ||
|
|
b21dbbe3a3 | ||
|
|
ee0fbdd83d | ||
|
|
8961c5189a | ||
|
|
555715588d | ||
|
|
8d096966b4 | ||
|
|
fb954b7cc1 | ||
|
|
8cbcd2285b | ||
|
|
3d0d889c99 | ||
|
|
0e77f14dd7 | ||
|
|
39e1b84423 | ||
|
|
af7530ee50 | ||
|
|
36bb68a72d | ||
|
|
afd707eedf | ||
|
|
c67efb88f3 | ||
|
|
667de2a199 | ||
|
|
44650e9713 | ||
|
|
62ee42de3b | ||
|
|
89301d114e | ||
|
|
dbb33d0651 | ||
|
|
166e3e570f | ||
|
|
7a2c20ba30 | ||
|
|
e90d53eceb | ||
|
|
afbc2e0764 | ||
|
|
0229138bd5 | ||
|
|
4d08e6365e | ||
|
|
8cd68ea8ef | ||
|
|
f9001304c8 | ||
|
|
4e20ea8f72 | ||
|
|
678ce0a55a | ||
|
|
8273ae66e1 | ||
|
|
4a9316a377 | ||
|
|
6321d547cf | ||
|
|
f241e21614 | ||
|
|
0eca923604 | ||
|
|
87f29cc11f | ||
|
|
90d1393137 | ||
|
|
625d431d10 |
@@ -5,7 +5,12 @@ 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/
|
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
|
||||||
pkgbuild --root .ci/pkg --version $VERSION --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Fedor Korotkov (9M2P8L4D89)" "./dist/Tart-$VERSION.pkg"
|
cp Resources/embedded.provisionprofile .ci/pkg/embedded.provisionprofile
|
||||||
xcrun notarytool submit "./dist/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
|
pkgbuild --root .ci/pkg/ --identifier com.github.cirruslabs.tart --version $VERSION \
|
||||||
xcrun stapler staple "./dist/Tart-$VERSION.pkg"
|
--scripts .ci/pkg/scripts \
|
||||||
|
--install-location "/Library/Application Support/Tart" \
|
||||||
|
--sign "Developer ID Installer: Cirrus Labs, Inc. (9M2P8L4D89)" \
|
||||||
|
"./.ci/Tart-$VERSION.pkg"
|
||||||
|
xcrun notarytool submit "./.ci/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
|
||||||
|
xcrun stapler staple "./.ci/Tart-$VERSION.pkg"
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# fix structure
|
||||||
|
mkdir -p "$2/tart.app/Contents/MacOS"
|
||||||
|
mv "$2/tart" "$2/tart.app/Contents/MacOS/tart"
|
||||||
|
mv "$2/embedded.provisionprofile" "$2/tart.app/Contents/embedded.provisionprofile"
|
||||||
|
|
||||||
|
echo "#!/bin/sh" > /usr/local/bin/tart
|
||||||
|
echo "exec '$2/tart.app/Contents/MacOS/tart' \"\$@\"" >> /usr/local/bin/tart
|
||||||
|
|
||||||
|
chmod +x /usr/local/bin/tart
|
||||||
@@ -1,28 +1,75 @@
|
|||||||
task:
|
use_compute_credits: true
|
||||||
name: Test on Ventura
|
|
||||||
persistent_worker:
|
env:
|
||||||
labels:
|
XCODE_TAG: 15
|
||||||
name: Mac-Mini-M1
|
|
||||||
build_script: swift test
|
|
||||||
test_script: swift test
|
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Build
|
name: Test on Sonoma
|
||||||
only_if: $CIRRUS_TAG == ''
|
alias: test
|
||||||
|
persistent_worker:
|
||||||
|
labels:
|
||||||
|
name: dev-mini
|
||||||
|
resources:
|
||||||
|
tart-vms: 1
|
||||||
|
test_script:
|
||||||
|
- swift test
|
||||||
|
integration_test_script:
|
||||||
|
# Build Tart
|
||||||
|
- swift build
|
||||||
|
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||||
|
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
|
||||||
|
# Run integration tests
|
||||||
|
- cd integration-tests
|
||||||
|
- HOMEBREW_NO_AUTO_UPDATE=1 brew install virtualenv
|
||||||
|
- virtualenv venv
|
||||||
|
- source venv/bin/activate
|
||||||
|
- pip install -r requirements.txt
|
||||||
|
- pytest --verbose --junit-xml=pytest-junit.xml
|
||||||
|
pytest_junit_result_artifacts:
|
||||||
|
path: "integration-tests/pytest-junit.xml"
|
||||||
|
format: junit
|
||||||
|
|
||||||
|
task:
|
||||||
|
name: Markdown Lint
|
||||||
|
only_if: $CIRRUS_BRANCH != 'gh-pages' && changesInclude('**.md')
|
||||||
|
container:
|
||||||
|
image: node:latest
|
||||||
|
install_script: npm install -g markdownlint-cli
|
||||||
|
lint_script: markdownlint --config=docs/.markdownlint.yml docs/
|
||||||
|
|
||||||
|
task:
|
||||||
|
name: Lint
|
||||||
|
alias: lint
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||||
|
lint_script:
|
||||||
|
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
||||||
|
always:
|
||||||
|
swiftformat_report_artifacts:
|
||||||
|
path: swiftformat.json
|
||||||
|
format: swiftformat
|
||||||
|
|
||||||
|
task:
|
||||||
|
only_if: $CIRRUS_TAG == ''
|
||||||
|
name: Build
|
||||||
|
alias: build
|
||||||
|
macos_instance:
|
||||||
|
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||||
build_script: swift build --product tart
|
build_script: swift build --product tart
|
||||||
sign_script: codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
|
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||||
binary_artifacts:
|
binary_artifacts:
|
||||||
path: .build/debug/tart
|
path: .build/debug/tart
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Release
|
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
||||||
only_if: $CIRRUS_TAG != ''
|
name: Release (Dry Run)
|
||||||
|
depends_on:
|
||||||
|
- lint
|
||||||
|
- build
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||||
env:
|
env:
|
||||||
MACOS_CERTIFICATE: ENCRYPTED[8a6930a8c1286e7e536ea41b7647ea40e99174ad15e9cfcc753754fea55a619b355415629dff515b54a8921643e314e5]
|
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||||
@@ -38,8 +85,76 @@ task:
|
|||||||
install_script:
|
install_script:
|
||||||
- brew install go goreleaser/tap/goreleaser-pro
|
- brew install go goreleaser/tap/goreleaser-pro
|
||||||
- brew install mitchellh/gon/gon
|
- brew install mitchellh/gon/gon
|
||||||
|
- curl -sL https://sentry.io/get-cli/ | sh
|
||||||
|
info_script:
|
||||||
|
- security find-identity -v
|
||||||
|
- xcodebuild -version
|
||||||
|
- swift -version
|
||||||
|
goreleaser_script: goreleaser release --skip-publish --snapshot --clean
|
||||||
|
always:
|
||||||
|
dist_artifacts:
|
||||||
|
path: "dist/*"
|
||||||
|
|
||||||
|
task:
|
||||||
|
name: Release
|
||||||
|
only_if: $CIRRUS_TAG != ''
|
||||||
|
depends_on:
|
||||||
|
- lint
|
||||||
|
- test
|
||||||
|
- build
|
||||||
|
macos_instance:
|
||||||
|
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||||
|
env:
|
||||||
|
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||||
|
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||||
|
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||||
|
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||||
|
SENTRY_ORG: cirrus-labs
|
||||||
|
SENTRY_PROJECT: persistent-workers
|
||||||
|
SENTRY_AUTH_TOKEN: ENCRYPTED[!c16a5cf7da5f856b4bc2f21fe8cb7aa2a6c981f851c094ed4d3025fd02ea59a58a86cee8b193a69a1fc20fa217e56ac3!]
|
||||||
|
setup_script:
|
||||||
|
- cd $HOME
|
||||||
|
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
||||||
|
- security create-keychain -p password101 build.keychain
|
||||||
|
- security default-keychain -s build.keychain
|
||||||
|
- security unlock-keychain -p password101 build.keychain
|
||||||
|
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
|
||||||
|
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
||||||
|
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||||
|
install_script:
|
||||||
|
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
|
||||||
|
- brew install mitchellh/gon/gon
|
||||||
info_script:
|
info_script:
|
||||||
- security find-identity -v
|
- security find-identity -v
|
||||||
- xcodebuild -version
|
- xcodebuild -version
|
||||||
- swift -version
|
- swift -version
|
||||||
release_script: goreleaser
|
release_script: goreleaser
|
||||||
|
upload_sentry_debug_files_script:
|
||||||
|
- cd .build/arm64-apple-macosx/release/
|
||||||
|
# Generate and upload symbols
|
||||||
|
- dsymutil tart
|
||||||
|
- sentry-cli debug-files upload tart.dSYM/
|
||||||
|
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.dSYM/
|
||||||
|
# Bundle and upload sources
|
||||||
|
- sentry-cli debug-files bundle-sources tart.dSYM
|
||||||
|
- sentry-cli debug-files upload tart.src.zip
|
||||||
|
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.src.zip
|
||||||
|
create_sentry_release_script:
|
||||||
|
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
|
||||||
|
- sentry-cli releases new $SENTRY_RELEASE
|
||||||
|
- sentry-cli releases set-commits $SENTRY_RELEASE --auto
|
||||||
|
- sentry-cli releases finalize $SENTRY_RELEASE
|
||||||
|
|
||||||
|
task:
|
||||||
|
name: Deploy Documentation
|
||||||
|
only_if: $CIRRUS_BRANCH == 'main'
|
||||||
|
container:
|
||||||
|
image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest
|
||||||
|
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
|
||||||
|
env:
|
||||||
|
DEPLOY_TOKEN: ENCRYPTED[!45ed45666558902ed1c2400add734ec063103bec31841847e8c8764802fca229bfa6d85c690e16ad159e047574b48793!]
|
||||||
|
deploy_script:
|
||||||
|
- git config --global user.name "Cirrus CI"
|
||||||
|
- git config --global user.name "hello@cirruslabs.org"
|
||||||
|
- git remote set-url origin https://$DEPLOY_TOKEN@github.com/cirruslabs/tart/
|
||||||
|
- mkdocs --verbose gh-deploy --force --remote-branch gh-pages
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
*.png filter=lfs diff=lfs merge=lfs -text
|
|
||||||
*.gif filter=lfs diff=lfs merge=lfs -text
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* @edigaryev @fkorotkov
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
github: [cirruslabs]
|
||||||
@@ -13,3 +13,6 @@ tart.xcodeproj/
|
|||||||
|
|
||||||
# GoReleaser
|
# GoReleaser
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# mkdocs
|
||||||
|
.cache
|
||||||
|
|||||||
@@ -1,50 +1,51 @@
|
|||||||
project_name: tart
|
project_name: tart
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- .ci/set-version.sh
|
||||||
|
- swift build -c release --product tart
|
||||||
|
- gon gon.hcl
|
||||||
|
- mkdir -p tart.app/Contents/MacOS
|
||||||
|
- cp .build/arm64-apple-macosx/release/tart tart.app/Contents/MacOS/
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- builder: prebuilt
|
- builder: prebuilt
|
||||||
goos:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
goarch:
|
goarch:
|
||||||
- arm64
|
- arm64
|
||||||
|
binary: tart.app/Contents/MacOS/tart
|
||||||
prebuilt:
|
prebuilt:
|
||||||
path: .build/arm64-apple-macosx/debug/tart
|
path: tart.app/Contents/MacOS/tart
|
||||||
hooks:
|
|
||||||
post:
|
|
||||||
- gon gon.hcl
|
|
||||||
- .ci/create-pkg.sh
|
|
||||||
|
|
||||||
before:
|
|
||||||
hooks:
|
|
||||||
- .ci/set-version.sh
|
|
||||||
- swift build -c debug --product tart
|
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: binary
|
- name_template: "{{ .ProjectName }}"
|
||||||
format: binary
|
files:
|
||||||
name_template: "{{ .ProjectName }}"
|
- src: Resources/embedded.provisionprofile
|
||||||
- id: regular
|
dst: tart.app/Contents
|
||||||
name_template: "{{ .ProjectName }}"
|
strip_parent: true
|
||||||
|
- LICENSE
|
||||||
|
|
||||||
release:
|
release:
|
||||||
prerelease: auto
|
prerelease: auto
|
||||||
extra_files:
|
|
||||||
- glob: ./dist/Tart-{{ .Tag }}.pkg
|
|
||||||
|
|
||||||
brews:
|
brews:
|
||||||
- name: tart
|
- name: tart
|
||||||
ids:
|
|
||||||
- regular
|
|
||||||
tap:
|
tap:
|
||||||
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"
|
||||||
description: Run macOS VMs on Apple Silicon
|
description: Run macOS VMs on Apple Silicon
|
||||||
skip_upload: auto
|
skip_upload: auto
|
||||||
dependencies:
|
dependencies:
|
||||||
- "cirruslabs/cli/softnet"
|
- "cirruslabs/cli/softnet"
|
||||||
|
install: |
|
||||||
|
libexec.install Dir["*"]
|
||||||
|
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
|
||||||
custom_block: |
|
custom_block: |
|
||||||
depends_on :macos => :monterey
|
depends_on :macos => :ventura
|
||||||
|
|
||||||
on_macos do
|
on_macos do
|
||||||
unless Hardware::CPU.arm?
|
unless Hardware::CPU.arm?
|
||||||
|
|||||||
@@ -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.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,5 @@
|
|||||||
|
--disable all
|
||||||
|
--enable indent
|
||||||
|
--indent 2
|
||||||
|
--exclude Sources/tart/OCI/Reference/Generated
|
||||||
|
--swiftversion 5.7
|
||||||
@@ -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,661 +1,45 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
Fair Source License, version 0.9
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2023 Cirrus Labs, Inc.
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
Licensor: Cirrus Labs, Inc.
|
||||||
|
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
Software: Tart
|
||||||
software and other kinds of works, specifically designed to ensure
|
|
||||||
cooperation with the community in the case of network server software.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
Use Limitation: 100 users. User is defined as a single core of a central processing unit (CPU) used by the product.
|
||||||
to take away your freedom to share and change the works. By contrast,
|
The Use Limitation does not apply to CPUs installed in devices used by a single individual.
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
|
||||||
share and change all versions of a program--to make sure it remains free
|
|
||||||
software for all its users.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
License Grant. Licensor hereby grants to each recipient of the
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
Software ("you") a non-exclusive, non-transferable, royalty-free and
|
||||||
have the freedom to distribute copies of free software (and charge for
|
fully-paid-up license, under all of the Licensor's copyright and
|
||||||
them if you wish), that you receive source code or can get it if you
|
patent rights, to use, copy, distribute, prepare derivative works of,
|
||||||
want it, that you can change the software or use pieces of it in new
|
publicly perform and display the Software, subject to the Use
|
||||||
free programs, and that you know you can do these things.
|
Limitation and the conditions set forth below.
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights
|
Use Limitation. The license granted above allows use by up to the
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
number of users per entity set forth above (the "Use Limitation"). For
|
||||||
you this License which gives you legal permission to copy, distribute
|
determining the number of users, "you" includes all affiliates,
|
||||||
and/or modify the software.
|
meaning legal entities controlling, controlled by, or under common
|
||||||
|
control with you. If you exceed the Use Limitation, your use is
|
||||||
|
subject to payment of Licensor's then-current list price for licenses.
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
Conditions. Redistribution in source code or other forms must include
|
||||||
improvements made in alternate versions of the program, if they
|
a copy of this license document to be provided in a reasonable
|
||||||
receive widespread use, become available for other developers to
|
manner. Any redistribution of the Software is only allowed subject to
|
||||||
incorporate. Many developers of free software are heartened and
|
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
|
||||||
software used on network servers, this result may fail to come about.
|
|
||||||
The GNU General Public License permits making a modified version and
|
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
|
||||||
ensure that, in such cases, the modified source code becomes available
|
|
||||||
to the community. It requires the operator of a network server to
|
|
||||||
provide the source code of the modified version running there to the
|
|
||||||
users of that server. Therefore, public use of a modified version, on
|
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
|
||||||
this license.
|
this license.
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
Trademarks. This license does not grant you any right in the
|
||||||
modification follow.
|
trademarks, service marks, brand names or logos of Licensor.
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
DISCLAIMER. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OR
|
||||||
|
CONDITION, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES
|
||||||
0. Definitions.
|
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. LICENSORS HEREBY DISCLAIM ALL LIABILITY, WHETHER IN
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
CONNECTION WITH THE SOFTWARE.
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
Termination. If you violate the terms of this license, your rights
|
||||||
|
will terminate automatically and will not be reinstated without the
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
prior written consent of Licensor. Any such termination will not
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
affect the right of others who may have received copies of the
|
||||||
"recipients" may be individuals or organizations.
|
Software from you.
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your version
|
|
||||||
supports such interaction) an opportunity to receive the Corresponding
|
|
||||||
Source of your version by providing access to the Corresponding Source
|
|
||||||
from a network server at no charge, through some standard or customary
|
|
||||||
means of facilitating copying of software. This Corresponding Source
|
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
|
||||||
following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the work with which it is combined will remain governed by version
|
|
||||||
3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU Affero General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published
|
|
||||||
by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
|
||||||
network, you should also make sure that it provides a way for users to
|
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
|
||||||
of the code. There are many ways you could offer source, and different
|
|
||||||
solutions will be better for different programs; see section 13 for the
|
|
||||||
specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
|
|||||||
@@ -5,17 +5,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/antlr/antlr4",
|
"location" : "https://github.com/antlr/antlr4",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "4.10.1",
|
"branch" : "dev",
|
||||||
"revision" : "44d87bc1d130c88aa452894aa5f7e2f710f68253"
|
"revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2"
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "async-http-client",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/swift-server/async-http-client",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "df87a860fdc41a595d5ca67f74cde9adbccc099a",
|
|
||||||
"version" : "1.11.4"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -27,6 +18,15 @@
|
|||||||
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "sentry-cocoa",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "d277532e1c8af813981ba01f591b15bbdd735615",
|
||||||
|
"version" : "8.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-algorithms",
|
"identity" : "swift-algorithms",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -45,6 +45,15 @@
|
|||||||
"version" : "1.1.2"
|
"version" : "1.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-async-algorithms",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-async-algorithms",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "f05e450f0b909c0e80670a47516c4b9700b9e5da"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-atomics",
|
"identity" : "swift-atomics",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -55,57 +64,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-log",
|
"identity" : "swift-collections",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-log.git",
|
"location" : "https://github.com/apple/swift-collections.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "5d66f7ba25daf4f94100e7022febf3c75e37a6c7",
|
"revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
|
||||||
"version" : "1.4.2"
|
"version" : "1.0.3"
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-nio",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-nio.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "124119f0bb12384cef35aa041d7c3a686108722d",
|
|
||||||
"version" : "2.40.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-nio-extras",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-nio-extras.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "8eea84ec6144167354387ef9244b0939f5852dc8",
|
|
||||||
"version" : "1.11.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-nio-http2",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-nio-http2.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "108ac15087ea9b79abb6f6742699cf31de0e8772",
|
|
||||||
"version" : "1.22.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-nio-ssl",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-nio-ssl.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "1750873bce84b4129b5303655cce2c3d35b9ed3a",
|
|
||||||
"version" : "2.19.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-nio-transport-services",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-nio-transport-services.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "1a4692acb88156e3da1b0c6732a8a38b2a744166",
|
|
||||||
"version" : "1.12.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -117,6 +81,15 @@
|
|||||||
"version" : "1.0.2"
|
"version" : "1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-sysctl",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/sersoft-gmbh/swift-sysctl.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "71fd64ee84819bb19fbecfb36d5a4503726b6fb7",
|
||||||
|
"version" : "1.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swiftdate",
|
"identity" : "swiftdate",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -125,6 +98,33 @@
|
|||||||
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
||||||
"version" : "6.3.1"
|
"version" : "6.3.1"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftformat",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/nicklockwood/SwiftFormat",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
|
||||||
|
"version" : "0.50.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftradix",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/orchetect/SwiftRadix",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a52c37a4c213403f7377ae77b4c68451bcab8330",
|
||||||
|
"version" : "1.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "texttable",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/cfilipov/TextTable",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "master",
|
||||||
|
"revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 2
|
"version" : 2
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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"])
|
||||||
@@ -12,19 +12,37 @@ let package = Package(
|
|||||||
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.1.2"),
|
||||||
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
||||||
.package(url: "https://github.com/swift-server/async-http-client", from: "1.11.4"),
|
|
||||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
|
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
|
||||||
|
.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: "6.3.1"),
|
||||||
.package(url: "https://github.com/antlr/antlr4", revision: "4.10.1")
|
.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/nicklockwood/SwiftFormat", from: "0.50.6"),
|
||||||
|
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.8.0"),
|
||||||
|
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||||
|
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.0.0"),
|
||||||
|
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.0")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(name: "tart", dependencies: [
|
.executableTarget(name: "tart", dependencies: [
|
||||||
.product(name: "Algorithms", package: "swift-algorithms"),
|
.product(name: "Algorithms", package: "swift-algorithms"),
|
||||||
|
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
.product(name: "AsyncHTTPClient", package: "async-http-client"),
|
|
||||||
.product(name: "Dynamic", package: "Dynamic"),
|
.product(name: "Dynamic", package: "Dynamic"),
|
||||||
.product(name: "SwiftDate", package: "SwiftDate"),
|
.product(name: "SwiftDate", package: "SwiftDate"),
|
||||||
.product(name: "Antlr4", package: "Antlr4"),
|
.product(name: "Antlr4Static", package: "Antlr4"),
|
||||||
|
.product(name: "Atomics", package: "swift-atomics"),
|
||||||
|
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||||
|
.product(name: "TextTable", package: "TextTable"),
|
||||||
|
.product(name: "Sysctl", package: "swift-sysctl"),
|
||||||
|
.product(name: "SwiftRadix", package: "SwiftRadix"),
|
||||||
|
], exclude: [
|
||||||
|
"OCI/Reference/Makefile",
|
||||||
|
"OCI/Reference/Reference.g4",
|
||||||
|
"OCI/Reference/Generated/Reference.interp",
|
||||||
|
"OCI/Reference/Generated/Reference.tokens",
|
||||||
|
"OCI/Reference/Generated/ReferenceLexer.interp",
|
||||||
|
"OCI/Reference/Generated/ReferenceLexer.tokens",
|
||||||
]),
|
]),
|
||||||
.testTarget(name: "TartTests", dependencies: ["tart"])
|
.testTarget(name: "TartTests", dependencies: ["tart"])
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||

|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
|
||||||
|
|
||||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
|
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon.
|
||||||
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
||||||
|
|
||||||
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/14966395?baseline=14966339).
|
* 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://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral)
|
||||||
|
service — a drop-in replacement for the standard GitHub-hosted runners, offering 2-3 times better performance for a fraction of the price.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Tart is also adopted by several other automation services:
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
|
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
|
||||||
@@ -22,249 +31,55 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
Many more companies are using Tart in their internal setups. Here are a few of them:
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://ahrefs.com/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://krisp.ai/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://mullvad.net/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://shape.dk/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/shape.png" height="65"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://suran.com/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://symflower.com/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
|
||||||
|
</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://uphold.com/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Uphold.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>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws?utm_source=github&utm_medium=referral" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/AWSMarkeplaceLogo.png" height="90"/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Try running a Tart VM on your Apple Silicon device running macOS 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):
|
||||||
|
|
||||||
```shell
|
```bash
|
||||||
brew install cirruslabs/cli/tart
|
brew install cirruslabs/cli/tart
|
||||||
tart clone ghcr.io/cirruslabs/macos-monterey-base:latest monterey-base
|
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
|
||||||
tart run monterey-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)
|
||||||
|
for remaining questions.
|
||||||
## CI Integration
|
|
||||||
|
|
||||||
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
|
|
||||||
also developed by Cirrus Labs. [Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is a command line tool with
|
|
||||||
one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
|
|
||||||
We built Cirrus CLI to solve "But it works on my machine!" problem.
|
|
||||||
|
|
||||||
Here is an example of a `.cirrus.yml` configuration file which will start a Tart VM, will copy over working directory and
|
|
||||||
will run scripts and [other instructions](https://cirrus-ci.org/guide/writing-tasks/#supported-instructions) inside the virtual machine:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
task:
|
|
||||||
name: hello
|
|
||||||
macos_instance:
|
|
||||||
# can be a remote or a local virtual machine
|
|
||||||
image: ghcr.io/cirruslabs/macos-monterey-base:latest
|
|
||||||
hello_script:
|
|
||||||
- echo "Hello from within a Tart VM!"
|
|
||||||
- echo "Here is my CPU info:"
|
|
||||||
- sysctl -n machdep.cpu.brand_string
|
|
||||||
- sleep 15
|
|
||||||
```
|
|
||||||
|
|
||||||
Put the above `.cirrus.yml` file in the root of your repository and run it with the following command:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
brew install cirruslabs/cli/cirrus
|
|
||||||
cirrus run
|
|
||||||
```
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
|
|
||||||
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
|
|
||||||
|
|
||||||
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
|
||||||
|
|
||||||
### Retrieving artifacts from within Tart VMs
|
|
||||||
|
|
||||||
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
|
|
||||||
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
|
|
||||||
exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tasks/#artifacts-instruction):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
task:
|
|
||||||
name: Build
|
|
||||||
macos_instance:
|
|
||||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
|
|
||||||
build_script: swift build --product tart
|
|
||||||
binary_artifacts:
|
|
||||||
path: .build/debug/tart
|
|
||||||
```
|
|
||||||
|
|
||||||
Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cirrus run --artifacts-dir artifacts
|
|
||||||
```
|
|
||||||
|
|
||||||
Note that all retrieved artifacts will be prefixed with the associated task name and `artifacts` instruction name.
|
|
||||||
For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/binary/.build/debug/tart`.
|
|
||||||
|
|
||||||
## Virtual Machine Management
|
|
||||||
|
|
||||||
### Creating from scratch
|
|
||||||
|
|
||||||
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regarding of the underlying OS a particular VM image has.
|
|
||||||
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
|
|
||||||
|
|
||||||
#### Creating a macOS VM image from scratch
|
|
||||||
|
|
||||||
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
|
|
||||||
use `latest` instead of a path to `*.ipsw` to download the latest available version:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
tart create --from-ipsw=latest monterey-vanilla
|
|
||||||
tart run monterey-vanilla
|
|
||||||
```
|
|
||||||
|
|
||||||
After the initial booting of the VM you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
|
|
||||||
|
|
||||||
1. Enable Auto-Login. Users & Groups -> Login Options -> Automatic login -> admin.
|
|
||||||
2. Allow SSH. Sharing -> Remote Login
|
|
||||||
3. Disable Lock Screen. Preferences -> Lock Screen -> disable "Require Password" after 5.
|
|
||||||
4. Disable Screen Saver.
|
|
||||||
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
|
|
||||||
|
|
||||||
#### Creating a Linux VM image from scratch
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a bare VM
|
|
||||||
tart create --linux ubuntu
|
|
||||||
|
|
||||||
# Install Ubuntu
|
|
||||||
tart run --disk focal-desktop-arm64.iso ubuntu
|
|
||||||
|
|
||||||
# Run VM
|
|
||||||
tart run ubuntu
|
|
||||||
```
|
|
||||||
|
|
||||||
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install -y openssh-server
|
|
||||||
sudo ufw allow ssh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuring a VM
|
|
||||||
|
|
||||||
By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command.
|
|
||||||
Please refer to `tart set --help` for additional details.
|
|
||||||
|
|
||||||
### Building with Packer
|
|
||||||
|
|
||||||
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
|
|
||||||
Here is an example of a template to build `monterey-base` local image based of a remote image:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"builders": [
|
|
||||||
{
|
|
||||||
"name": "tart",
|
|
||||||
"type": "tart-cli",
|
|
||||||
"vm_base_name": "tartvm/vanilla:latest",
|
|
||||||
"vm_name": "monterey-base",
|
|
||||||
"cpu_count": 4,
|
|
||||||
"memory_gb": 8,
|
|
||||||
"disk_size_gb": 32,
|
|
||||||
"ssh_username": "admin",
|
|
||||||
"ssh_password": "admin",
|
|
||||||
"ssh_timeout": "120s"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"provisioners": [
|
|
||||||
{
|
|
||||||
"inline": [
|
|
||||||
"echo 'Disabling spotlight indexing...'",
|
|
||||||
"sudo mdutil -a -i off"
|
|
||||||
],
|
|
||||||
"type": "shell"
|
|
||||||
},
|
|
||||||
# more provisioners
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Here is a [repository with Packer templates](https://github.com/cirruslabs/macos-image-templates) used to build [all the images managed by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
|
||||||
|
|
||||||
### Working with a Remote OCI Container Registry
|
|
||||||
|
|
||||||
For example, let's say you want to push/pull images to a registry hosted at https://acme.io/.
|
|
||||||
|
|
||||||
#### Registry Authorization
|
|
||||||
|
|
||||||
First, you need to log in and save credential for `acme.io` host via `tart login` command:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
tart login acme.io
|
|
||||||
```
|
|
||||||
|
|
||||||
Credentials are securely stored in Keychain.
|
|
||||||
|
|
||||||
#### Pushing a Local Image
|
|
||||||
|
|
||||||
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Pulling a Remote Image
|
|
||||||
|
|
||||||
You can either pull an image:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
tart pull acme.io/remoteorg/name:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
...or instantiate a VM from a remote image:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
tart clone acme.io/remoteorg/name:latest my-local-vm-name
|
|
||||||
```
|
|
||||||
|
|
||||||
This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>How Tart is different from Anka</summary>
|
|
||||||
|
|
||||||
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
|
|
||||||
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
|
|
||||||
|
|
||||||
Instead of Anka Registry, Tart can work with any OCI-compatible container registry.
|
|
||||||
|
|
||||||
Tart doesn't yet have an analogue of Anka Controller for managing long living VMs. Please take a look at [CI integration](#ci-integration)
|
|
||||||
section for an option to run ephemeral VMs for your needs.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Why Tart is free and open sourced?</summary>
|
|
||||||
|
|
||||||
Tart is a relatively small project, and it didn't feel right to try to monetize it.
|
|
||||||
Apple did all the heavy lifting with their `Virtualization.Framework`.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>How to change VM's disk size?</summary>
|
|
||||||
|
|
||||||
You can choose disk size upon creation of a virtual machine:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
tart create --from-ipsw=latest --disk-size=25 monterey-vanilla
|
|
||||||
```
|
|
||||||
|
|
||||||
For an existing VM please use [Packer Plugin](https://github.com/cirruslabs/packer-plugin-tart) which can increase
|
|
||||||
disk size for new virtual machines. Here is an example of [how to change disk size in a Packer template](https://github.com/cirruslabs/macos-image-templates/blob/fb0bcf68e0b093129136875c050205a66729b596/templates/base.pkr.hcl#L15).
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>VM location on disk</summary>
|
|
||||||
|
|
||||||
Tart stores all it's files in `~/.tart/` directory. Local images that you can run are stored in `~/.tart/vms/`.
|
|
||||||
Remote images are pulled into `~/.tart/vms/cache/OCIs/`.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Nested virtualization support?</summary>
|
|
||||||
|
|
||||||
Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
|
|
||||||
doesn't support nested virtualization.
|
|
||||||
</details>
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 131 B After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:8a3a324193c4bd7797102765ab16f44adf58e49ca615bac3963cefd0d3a10594
|
|
||||||
size 339678
|
|
||||||
|
Before Width: | Height: | Size: 131 B After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 131 B After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 589 B |
|
Before Width: | Height: | Size: 129 B After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 130 B After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
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: 14 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 130 B After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.virtualization</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.get-task-allow</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.virtualization</key>
|
<key>com.apple.security.virtualization</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.vm.networking</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -4,6 +4,10 @@ struct CI {
|
|||||||
static var version: String {
|
static var version: String {
|
||||||
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
|
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var release: String? {
|
||||||
|
rawVersion.expanded() ? "tart@\(rawVersion)" : nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension String {
|
private extension String {
|
||||||
|
|||||||
@@ -3,7 +3,20 @@ 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.
|
||||||
|
|
||||||
|
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")
|
@Argument(help: "source VM name")
|
||||||
var sourceName: String
|
var sourceName: String
|
||||||
@@ -14,56 +27,55 @@ 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 {
|
||||||
do {
|
|
||||||
let ociStorage = VMStorageOCI()
|
let ociStorage = VMStorageOCI()
|
||||||
let localStorage = VMStorageLocal()
|
let localStorage = VMStorageLocal()
|
||||||
|
|
||||||
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
|
||||||
|
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||||
|
try tmpVMDirLock.lock()
|
||||||
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
try await withTaskCancellationHandler(operation: {
|
||||||
|
// Acquire a global lock
|
||||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||||
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.sizeBytes()), sourceVM)
|
||||||
}, onCancel: {
|
}, onCancel: {
|
||||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||||
})
|
})
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate extension VMDirectory {
|
|
||||||
func macAddress() throws -> String {
|
|
||||||
try VMConfig(fromURL: configURL).macAddress.string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate extension VMStorageLocal {
|
|
||||||
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
|
||||||
try list().contains { try $1.macAddress() == macAddress }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ struct Create: AsyncParsableCommand {
|
|||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name")
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file (or \"latest\") to fetch the latest appropriate IPSW", valueName: "path"))
|
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file or URL (or \"latest\", to fetch the latest supported IPSW automatically)", valueName: "path"))
|
||||||
var fromIPSW: String?
|
var fromIPSW: String?
|
||||||
|
|
||||||
@Flag(help: "create a Linux VM")
|
@Flag(help: "create a Linux VM")
|
||||||
@@ -25,35 +25,34 @@ struct Create: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
|
||||||
let tmpVMDir = try VMDirectory.temporary()
|
let tmpVMDir = try VMDirectory.temporary()
|
||||||
|
|
||||||
|
// Lock the temporary VM directory to prevent it's garbage collection
|
||||||
|
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||||
|
try tmpVMDirLock.lock()
|
||||||
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
try await withTaskCancellationHandler(operation: {
|
||||||
if let fromIPSW = fromIPSW {
|
if let fromIPSW = fromIPSW {
|
||||||
|
let ipswURL: URL
|
||||||
|
|
||||||
if fromIPSW == "latest" {
|
if fromIPSW == "latest" {
|
||||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
|
ipswURL = try await VM.latestIPSWURL()
|
||||||
|
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||||
|
ipswURL = URL(string: fromIPSW)!
|
||||||
} else {
|
} else {
|
||||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW), diskSizeGB: diskSize)
|
ipswURL = URL(fileURLWithPath: fromIPSW)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = 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)
|
||||||
}, onCancel: {
|
}, onCancel: {
|
||||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||||
})
|
})
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,11 @@ 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")
|
||||||
var name: String
|
var name: [String]
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
for it in name {
|
||||||
try VMStorageHelper.delete(name)
|
try VMStorageHelper.delete(it)
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Export: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(abstract: "Export VM to a compressed .tvm file")
|
||||||
|
|
||||||
|
@Argument(help: "Source VM name.")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
@Argument(help: "Path to the destination file.")
|
||||||
|
var path: String?
|
||||||
|
|
||||||
|
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...")
|
||||||
|
|
||||||
|
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,32 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
fileprivate struct VMInfo: Encodable {
|
||||||
|
let CPU: Int
|
||||||
|
let Memory: UInt64
|
||||||
|
let Disk: Int
|
||||||
|
let Display: String
|
||||||
|
let Running: Bool
|
||||||
|
let State: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Get: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
||||||
|
|
||||||
|
@Argument(help: "VM name.")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
@Option(help: "Output format: text or json")
|
||||||
|
var format: Format = .text
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
|
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
let diskSizeInGb = try vmDir.sizeGB()
|
||||||
|
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||||
|
|
||||||
|
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb,
|
||||||
|
Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state())
|
||||||
|
print(format.renderSingle(info))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@ import ArgumentParser
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import SystemConfiguration
|
import SystemConfiguration
|
||||||
|
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")
|
||||||
@@ -12,43 +19,55 @@ struct IP: AsyncParsableCommand {
|
|||||||
@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 {
|
||||||
do {
|
|
||||||
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 {
|
||||||
print("no IP address found, is your VM running?")
|
var message = "no IP address found"
|
||||||
|
|
||||||
Foundation.exit(1)
|
if try !vmDir.running() {
|
||||||
|
message += ", is your VM running?"
|
||||||
}
|
}
|
||||||
|
|
||||||
if let ipViaARP = try ARPCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
if (vmConfig.os == .linux && resolver == .arp) {
|
||||||
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
message += " (not all Linux distributions are compatible with the ARP resolver)"
|
||||||
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
|
||||||
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
print(ipViaDHCP)
|
throw RuntimeError.NoIPAddressFound(message)
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
|
print(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ip = try Leases().resolveMACAddress(macAddress: vmMACAddress) {
|
switch resolutionStrategy {
|
||||||
|
case .arp:
|
||||||
|
if let ip = try ARPCache().ResolveMACAddress(macAddress: vmMACAddress) {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
case .dhcp:
|
||||||
|
if let leases = try Leases(), let ip = try leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try await Task.sleep(nanoseconds: 1_000_000)
|
// wait a second
|
||||||
|
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
} while Date.now < waitUntil
|
} while Date.now < waitUntil
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Import: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(abstract: "Import VM from a compressed .tvm file")
|
||||||
|
|
||||||
|
@Argument(help: "Path to a file created with \"tart export\".")
|
||||||
|
var path: String
|
||||||
|
|
||||||
|
@Argument(help: "Destination VM name.")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
func validate() throws {
|
||||||
|
if name.contains("/") {
|
||||||
|
throw ValidationError("<name> should be a local name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let localStorage = VMStorageLocal()
|
||||||
|
|
||||||
|
// Create a temporary VM directory to which we will load the export file
|
||||||
|
let tmpVMDir = try VMDirectory.temporary()
|
||||||
|
|
||||||
|
// Lock the temporary VM directory to prevent it's garbage collection
|
||||||
|
// while we're running
|
||||||
|
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||||
|
try tmpVMDirLock.lock()
|
||||||
|
|
||||||
|
// Populate the temporary VM directory with the export file contents
|
||||||
|
print("importing...")
|
||||||
|
try tmpVMDir.importFromArchive(path: path)
|
||||||
|
|
||||||
|
try await withTaskCancellationHandler(operation: {
|
||||||
|
// Acquire a global lock
|
||||||
|
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||||
|
try lock.lock()
|
||||||
|
|
||||||
|
// Re-generate the VM's MAC address importing it will result in address collision
|
||||||
|
if try localStorage.hasVMsWithMACAddress(macAddress: tmpVMDir.macAddress()) {
|
||||||
|
try tmpVMDir.regenerateMACAddress()
|
||||||
|
}
|
||||||
|
|
||||||
|
try localStorage.move(name, from: tmpVMDir)
|
||||||
|
|
||||||
|
try lock.unlock()
|
||||||
|
}, onCancel: {
|
||||||
|
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,27 +2,61 @@ import ArgumentParser
|
|||||||
import Dispatch
|
import Dispatch
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
fileprivate struct VMInfo: Encodable {
|
||||||
|
let Source: String
|
||||||
|
let Name: String
|
||||||
|
let Size: Int
|
||||||
|
let Running: Bool
|
||||||
|
let State: String
|
||||||
|
}
|
||||||
|
|
||||||
struct List: AsyncParsableCommand {
|
struct List: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "List created VMs")
|
static var configuration = CommandConfiguration(abstract: "List created VMs")
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
|
||||||
|
var source: String?
|
||||||
|
|
||||||
|
@Option(help: "Output format: text or json")
|
||||||
|
var format: Format = .text
|
||||||
|
|
||||||
|
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
|
||||||
|
var quiet: Bool = false
|
||||||
|
|
||||||
|
func validate() throws {
|
||||||
|
guard let source = source else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !["local", "oci"].contains(source) {
|
||||||
|
throw ValidationError("'\(source)' is not a valid <source>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
var infos: [VMInfo] = []
|
||||||
print("Source\tName")
|
|
||||||
|
|
||||||
displayTable("local", try VMStorageLocal().list())
|
if source == nil || source == "local" {
|
||||||
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
|
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
||||||
|
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
Foundation.exit(0)
|
if source == nil || source == "oci" {
|
||||||
} catch {
|
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
||||||
print(error)
|
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
Foundation.exit(1)
|
if (quiet) {
|
||||||
|
for info in infos {
|
||||||
|
print(info.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print(format.renderList(infos))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
|
private func sortedInfos(_ infos: [VMInfo]) -> [VMInfo] {
|
||||||
for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) {
|
infos.sorted(by: { left, right in left.Name < right.Name })
|
||||||
print("\(source)\t\(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
|
||||||
@@ -27,7 +30,6 @@ struct Login: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
|
||||||
var user: String
|
var user: String
|
||||||
var password: String
|
var password: String
|
||||||
|
|
||||||
@@ -36,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()
|
||||||
}
|
}
|
||||||
@@ -43,24 +48,17 @@ struct Login: AsyncParsableCommand {
|
|||||||
host: (user, password)
|
host: (user, password)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
if !noValidate {
|
||||||
do {
|
do {
|
||||||
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()
|
try await registry.ping()
|
||||||
} catch {
|
} catch {
|
||||||
print("invalid credentials: \(error)")
|
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
||||||
|
}
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,86 +1,157 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import Dispatch
|
import Dispatch
|
||||||
|
import Sentry
|
||||||
import SwiftUI
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
|
||||||
if gc {
|
if gc {
|
||||||
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)
|
||||||
}
|
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() }
|
||||||
|
|
||||||
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
var spaceBudgetBytes = spaceBudgetBytes
|
||||||
var cacheReclaimedBytes: Int = 0
|
var prunablesToDelete: [Prunable] = []
|
||||||
|
|
||||||
var it = prunables.makeIterator()
|
for prunable in prunables {
|
||||||
|
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
|
||||||
|
|
||||||
while (cacheUsedBytes - cacheReclaimedBytes) > cacheBudgetBytes {
|
if prunableSizeBytes <= spaceBudgetBytes {
|
||||||
guard let prunable = it.next() else {
|
// Don't mark for deletion as
|
||||||
break
|
// there's a budget available
|
||||||
}
|
spaceBudgetBytes -= prunableSizeBytes
|
||||||
|
} else {
|
||||||
cacheReclaimedBytes -= try prunable.sizeBytes()
|
// Mark for deletion
|
||||||
try prunable.delete()
|
prunablesToDelete.append(prunable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func pruneReclaim(reclaimBytes: UInt64) throws {
|
try prunablesToDelete.forEach { try $0.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
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() }
|
||||||
@@ -101,8 +172,18 @@ struct Prune: AsyncParsableCommand {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
|
||||||
|
// do not prune the initiator
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try SentrySDK.span?.setData(value: prunable.sizeBytes(), key: prunable.url.path)
|
||||||
|
|
||||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||||
|
|
||||||
try prunable.delete()
|
try prunable.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,14 +20,22 @@ 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 {
|
||||||
do {
|
|
||||||
// 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
|
||||||
if VMStorageLocal().exists(remoteName) {
|
if VMStorageLocal().exists(remoteName) {
|
||||||
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
||||||
|
|
||||||
Foundation.exit(0)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let remoteName = try RemoteName(remoteName)
|
let remoteName = try RemoteName(remoteName)
|
||||||
@@ -26,13 +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)
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
var localName: String
|
var localName: String
|
||||||
|
|
||||||
@Argument(help: "remote VM name(s)")
|
@Argument(help: "remote VM name(s)")
|
||||||
@@ -23,13 +23,16 @@ 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 {
|
||||||
do {
|
let ociStorage = VMStorageOCI()
|
||||||
let localVMDir = try VMStorageLocal().open(localName)
|
let localVMDir = try VMStorageHelper.open(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{
|
||||||
@@ -54,29 +57,55 @@ 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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
// Populate the local cache (if requested)
|
// Populate the local cache (if requested)
|
||||||
if populateCache {
|
if populateCache {
|
||||||
let ociStorage = VMStorageOCI()
|
|
||||||
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
||||||
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// link the rest remote names
|
||||||
|
if populateCache {
|
||||||
for remoteName in remoteNamesForRegistry {
|
for remoteName in remoteNamesForRegistry {
|
||||||
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Rename: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(abstract: "Rename a VM")
|
||||||
|
|
||||||
|
@Argument(help: "VM name")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
@Argument(help: "new VM name")
|
||||||
|
var newName: String
|
||||||
|
|
||||||
|
func validate() throws {
|
||||||
|
if newName.contains("/") {
|
||||||
|
throw ValidationError("<new-name> should be a local name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let localStorage = VMStorageLocal()
|
||||||
|
|
||||||
|
if !localStorage.exists(name) {
|
||||||
|
throw ValidationError("failed to rename a non-existent VM: \(name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if localStorage.exists(newName) {
|
||||||
|
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
|
||||||
|
}
|
||||||
|
|
||||||
|
try localStorage.rename(name, newName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
|
import Cocoa
|
||||||
|
import Darwin
|
||||||
import Dispatch
|
import Dispatch
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
import Sentry
|
||||||
|
|
||||||
var vm: VM?
|
var vm: VM?
|
||||||
|
|
||||||
@@ -19,6 +22,20 @@ struct Run: AsyncParsableCommand {
|
|||||||
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
|
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
|
||||||
var noGraphics: Bool = false
|
var noGraphics: Bool = false
|
||||||
|
|
||||||
|
@Flag(help: ArgumentHelp(
|
||||||
|
"Open serial console in /dev/ttySXX",
|
||||||
|
discussion: "Useful for debugging Linux Kernel."))
|
||||||
|
var serial: Bool = false
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp(
|
||||||
|
"Attach an externally created serial console",
|
||||||
|
discussion: "Alternative to `--serial` flag for programmatic integrations."
|
||||||
|
))
|
||||||
|
var serialPath: String?
|
||||||
|
|
||||||
|
@Flag(help: "Force open a UI window, even when VNC is enabled.")
|
||||||
|
var graphics: Bool = false
|
||||||
|
|
||||||
@Flag(help: "Boot into recovery mode")
|
@Flag(help: "Boot into recovery mode")
|
||||||
var recovery: Bool = false
|
var recovery: Bool = false
|
||||||
|
|
||||||
@@ -34,38 +51,154 @@ struct Run: AsyncParsableCommand {
|
|||||||
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
||||||
var vncExperimental: Bool = false
|
var vncExperimental: Bool = false
|
||||||
|
|
||||||
@Flag var withSoftnet: Bool = false
|
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("""
|
@Option(help: ArgumentHelp("""
|
||||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
|
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\" --disk=\"/dev/disk0\")
|
||||||
""", discussion: """
|
""", discussion: """
|
||||||
|
Can be either a disk image file or a block device like a local SSD on AWS EC2 Mac instances.
|
||||||
|
|
||||||
Learn how to create a disk image using Disk Utility here:
|
Learn how to create a disk image using Disk Utility here:
|
||||||
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
|
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
|
||||||
|
|
||||||
|
To work with block devices 'tart' binary must be executed as root which affects locating Tart VMs.
|
||||||
|
To workaround this issue pass TART_HOME explicitly:
|
||||||
|
|
||||||
|
sudo TART_HOME="$HOME/.tart" tart run sonoma --disk=/dev/disk0
|
||||||
""", valueName: "path[:ro]"))
|
""", valueName: "path[:ro]"))
|
||||||
var disk: [String] = []
|
var disk: [String] = []
|
||||||
|
|
||||||
|
@Option(name: [.customLong("rosetta")], help: ArgumentHelp(
|
||||||
|
"Attaches a Rosetta share to the guest Linux VM with a specific tag (e.g. --rosetta=\"rosetta\")",
|
||||||
|
discussion: """
|
||||||
|
Requires host to be macOS 13.0 (Ventura) with Rosetta installed. The latter can be done
|
||||||
|
by running "softwareupdate --install-rosetta" (without quotes) in the Terminal.app.
|
||||||
|
|
||||||
|
Note that you also have to configure Rosetta in the guest Linux VM by following the
|
||||||
|
steps from "Mount the Shared Directory and Register Rosetta" section here:
|
||||||
|
https://developer.apple.com/documentation/virtualization/running_intel_binaries_in_linux_vms_with_rosetta#3978496
|
||||||
|
""",
|
||||||
|
valueName: "tag"
|
||||||
|
))
|
||||||
|
var rosettaTag: String?
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("""
|
@Option(help: ArgumentHelp("""
|
||||||
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
|
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")
|
||||||
""", discussion: """
|
""", discussion: """
|
||||||
All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS,
|
Requires host to be macOS 13.0 (Ventura) or newer.
|
||||||
|
A shared directory is automatically mounted to "/Volumes/My Shared Files" directory on macOS,
|
||||||
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
|
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
|
||||||
""", valueName: "name:path[:ro]"))
|
For macOS guests, they must be running macOS 13.0 (Ventura) or newer.
|
||||||
|
|
||||||
|
In case of passing multiple directories it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\"
|
||||||
|
These names will be used as directory names under the mounting point inside guests. For the example above it will be
|
||||||
|
"/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively.
|
||||||
|
""", valueName: "[name:]path[:ro]"))
|
||||||
var dir: [String] = []
|
var dir: [String] = []
|
||||||
|
|
||||||
func validate() throws {
|
@Option(help: ArgumentHelp("""
|
||||||
|
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
|
||||||
|
""", discussion: """
|
||||||
|
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
|
||||||
|
""", valueName: "interface name"))
|
||||||
|
var netBridged: [String] = []
|
||||||
|
|
||||||
|
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
|
||||||
|
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
|
||||||
|
var netSoftnet: Bool = false
|
||||||
|
|
||||||
|
@Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\"."))
|
||||||
|
var suspendable: Bool = false
|
||||||
|
|
||||||
|
@Flag(help: ArgumentHelp("Whether system hot keys should be sent to the guest instead of the host",
|
||||||
|
discussion: "If enabled then system hot keys like Cmd+Tab will be sent to the guest instead of the host."))
|
||||||
|
var captureSystemKeys: Bool = false
|
||||||
|
|
||||||
|
mutating func validate() throws {
|
||||||
if vnc && vncExperimental {
|
if vnc && vncExperimental {
|
||||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if netBridged.count > 0 && netSoftnet {
|
||||||
|
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if graphics && noGraphics {
|
||||||
|
throw ValidationError("--graphics and --no-graphics are mutually exclusive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noGraphics || vnc || vncExperimental) && captureSystemKeys {
|
||||||
|
throw ValidationError("--captures-system-keys can only be used with the default VM view")
|
||||||
|
}
|
||||||
|
|
||||||
|
let localStorage = VMStorageLocal()
|
||||||
|
let vmDir = try localStorage.open(name)
|
||||||
|
if try vmDir.state() == "suspended" {
|
||||||
|
suspendable = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if suspendable {
|
||||||
|
if dir.count > 0 {
|
||||||
|
throw ValidationError("Suspending VMs with shared directories is not supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for disk in disk {
|
||||||
|
if disk.hasSuffix("-amd64.iso") {
|
||||||
|
throw ValidationError("Seems you have a disk targeting x86 architecture (hence amd64 in the name). Please use an 'arm64' version of the disk.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let localStorage = VMStorageLocal()
|
||||||
|
let vmDir = try localStorage.open(name)
|
||||||
|
|
||||||
|
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
||||||
|
if try vmDir.state() == "suspended" {
|
||||||
|
try storageLock.lock() // lock before checking
|
||||||
|
let needToGenerateNewMac = try localStorage.list().contains {
|
||||||
|
// check if there is a running VM with the same MAC but different name
|
||||||
|
try $1.running() && $1.macAddress() == vmDir.macAddress() && $1.name != vmDir.name
|
||||||
|
}
|
||||||
|
|
||||||
|
if needToGenerateNewMac {
|
||||||
|
print("There is already a running VM with the same MAC address!")
|
||||||
|
print("Resetting VM to assign a new MAC address...")
|
||||||
|
try vmDir.regenerateMACAddress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if netSoftnet && isInteractiveSession() {
|
||||||
|
try Softnet.configureSUIDBitIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
let additionalDiskAttachments = try additionalDiskAttachments()
|
||||||
|
|
||||||
|
var serialPorts: [VZSerialPortConfiguration] = []
|
||||||
|
if serial {
|
||||||
|
let tty_fd = createPTY()
|
||||||
|
if (tty_fd < 0) {
|
||||||
|
throw RuntimeError.VMConfigurationError("Failed to create PTY")
|
||||||
|
}
|
||||||
|
let tty_read = FileHandle.init(fileDescriptor: tty_fd)
|
||||||
|
let tty_write = FileHandle.init(fileDescriptor: tty_fd)
|
||||||
|
serialPorts.append(createSerialPortConfiguration(tty_read, tty_write))
|
||||||
|
} else if serialPath != nil {
|
||||||
|
let tty_read = FileHandle.init(forReadingAtPath: serialPath!)
|
||||||
|
let tty_write = FileHandle.init(forWritingAtPath: serialPath!)
|
||||||
|
if (tty_read == nil || tty_write == nil) {
|
||||||
|
throw RuntimeError.VMConfigurationError("Failed to open PTY")
|
||||||
|
}
|
||||||
|
serialPorts.append(createSerialPortConfiguration(tty_read!, tty_write!))
|
||||||
|
}
|
||||||
|
|
||||||
vm = try VM(
|
vm = try VM(
|
||||||
vmDir: vmDir,
|
vmDir: vmDir,
|
||||||
withSoftnet: withSoftnet,
|
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||||
additionalDiskAttachments: additionalDiskAttachments(),
|
additionalStorageDevices: additionalDiskAttachments,
|
||||||
directoryShares: directoryShares()
|
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||||
|
serialPorts: serialPorts,
|
||||||
|
suspendable: suspendable
|
||||||
)
|
)
|
||||||
|
|
||||||
let vncImpl: VNC? = try {
|
let vncImpl: VNC? = try {
|
||||||
@@ -79,8 +212,42 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Lock the VM
|
||||||
|
//
|
||||||
|
// More specifically, lock the "config.json", because we can't lock
|
||||||
|
// directories with fcntl(2)-based locking and we better not interfere
|
||||||
|
// with the VM's disk and NVRAM, because they are opened (and even seem
|
||||||
|
// to be locked) directly by the Virtualization.Framework's process.
|
||||||
|
//
|
||||||
|
// Note that due to "completely stupid semantics"[1] of the fcntl-based
|
||||||
|
// file locking, we need to acquire the lock after we read the VM's
|
||||||
|
// configuration file, otherwise we will loose the lock.
|
||||||
|
//
|
||||||
|
// [1]: https://man.openbsd.org/fcntl
|
||||||
|
let lock = try vmDir.lock()
|
||||||
|
if try !lock.trylock() {
|
||||||
|
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// now VM state will return "running" so we can unlock
|
||||||
|
try storageLock.unlock()
|
||||||
|
|
||||||
let task = Task {
|
let task = Task {
|
||||||
do {
|
do {
|
||||||
|
var resume = false
|
||||||
|
|
||||||
|
if #available(macOS 14, *) {
|
||||||
|
if FileManager.default.fileExists(atPath: vmDir.stateURL.path) {
|
||||||
|
print("restoring VM state from a snapshot...")
|
||||||
|
try await vm!.virtualMachine.restoreMachineStateFrom(url: vmDir.stateURL)
|
||||||
|
try FileManager.default.removeItem(at: vmDir.stateURL)
|
||||||
|
resume = true
|
||||||
|
print("resuming VM...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try await vm!.start(recovery: recovery, resume: resume)
|
||||||
|
|
||||||
if let vncImpl = vncImpl {
|
if let vncImpl = vncImpl {
|
||||||
let vncURL = try await vncImpl.waitForURL()
|
let vncURL = try await vncImpl.waitForURL()
|
||||||
|
|
||||||
@@ -92,7 +259,7 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try await vm!.run(recovery)
|
try await vm!.run()
|
||||||
|
|
||||||
if let vncImpl = vncImpl {
|
if let vncImpl = vncImpl {
|
||||||
try vncImpl.stop()
|
try vncImpl.stop()
|
||||||
@@ -100,79 +267,225 @@ struct Run: AsyncParsableCommand {
|
|||||||
|
|
||||||
Foundation.exit(0)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
|
// Capture the error into Sentry
|
||||||
print("Virtual machine \"\(name)\" is already running!")
|
SentrySDK.capture(error: error)
|
||||||
Foundation.exit(2)
|
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||||
}
|
|
||||||
|
fputs("\(error)\n", stderr)
|
||||||
|
|
||||||
print(error)
|
|
||||||
Foundation.exit(1)
|
Foundation.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "tart stop" support
|
||||||
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||||
sigintSrc.setEventHandler {
|
sigintSrc.setEventHandler {
|
||||||
task.cancel()
|
task.cancel()
|
||||||
}
|
}
|
||||||
sigintSrc.activate()
|
sigintSrc.activate()
|
||||||
|
|
||||||
if noGraphics || vnc || vncExperimental {
|
// "tart suspend" / UI window closing support
|
||||||
dispatchMain()
|
signal(SIGUSR1, SIG_IGN)
|
||||||
|
let sigusr1Src = DispatchSource.makeSignalSource(signal: SIGUSR1)
|
||||||
|
sigusr1Src.setEventHandler {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
if #available(macOS 14, *) {
|
||||||
|
try vm!.configuration.validateSaveRestoreSupport()
|
||||||
|
|
||||||
|
print("pausing VM to take a snapshot...")
|
||||||
|
try await vm!.virtualMachine.pause()
|
||||||
|
|
||||||
|
print("creating a snapshot...")
|
||||||
|
try await vm!.virtualMachine.saveMachineStateTo(url: vmDir.stateURL)
|
||||||
|
|
||||||
|
print("snapshot created successfully! shutting down the VM...")
|
||||||
|
|
||||||
|
task.cancel()
|
||||||
} else {
|
} else {
|
||||||
runUI()
|
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
|
||||||
|
|
||||||
|
Foundation.exit(1)
|
||||||
|
}
|
||||||
|
} catch (let e) {
|
||||||
|
print(RuntimeError.SuspendFailed(e.localizedDescription))
|
||||||
|
|
||||||
|
Foundation.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sigusr1Src.activate()
|
||||||
|
|
||||||
|
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||||
|
if noGraphics || useVNCWithoutGraphics {
|
||||||
|
// enter the main even loop, without bringing up any UI,
|
||||||
|
// and just wait for the VM to exit.
|
||||||
|
let nsApp = NSApplication.shared
|
||||||
|
nsApp.setActivationPolicy(.prohibited)
|
||||||
|
nsApp.run()
|
||||||
|
} else {
|
||||||
|
runUI(suspendable, captureSystemKeys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
|
private func createSerialPortConfiguration(_ tty_read: FileHandle, _ tty_write: FileHandle) -> VZVirtioConsoleDeviceSerialPortConfiguration {
|
||||||
var result: [VZDiskImageStorageDeviceAttachment] = []
|
let serialPortConfiguration = VZVirtioConsoleDeviceSerialPortConfiguration()
|
||||||
|
let serialPortAttachment = VZFileHandleSerialPortAttachment(
|
||||||
|
fileHandleForReading: tty_read,
|
||||||
|
fileHandleForWriting: tty_write)
|
||||||
|
|
||||||
|
serialPortConfiguration.attachment = serialPortAttachment
|
||||||
|
return serialPortConfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInteractiveSession() -> Bool {
|
||||||
|
isatty(STDOUT_FILENO) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
|
||||||
|
if netSoftnet {
|
||||||
|
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
|
|
||||||
|
return try Softnet(vmMACAddress: config.macAddress.string)
|
||||||
|
}
|
||||||
|
|
||||||
|
if netBridged.count > 0 {
|
||||||
|
func findBridgedInterface(_ name: String) throws -> VZBridgedNetworkInterface {
|
||||||
|
let interface = VZBridgedNetworkInterface.networkInterfaces.first { interface in
|
||||||
|
interface.identifier == name || interface.localizedDisplayName == name
|
||||||
|
}
|
||||||
|
if (interface == nil) {
|
||||||
|
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
|
||||||
|
+ "available interfaces: \(bridgeInterfaces())")
|
||||||
|
}
|
||||||
|
return interface!
|
||||||
|
}
|
||||||
|
|
||||||
|
return NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bridgeInterfaces() -> [String] {
|
||||||
|
VZBridgedNetworkInterface.networkInterfaces.map { interface in
|
||||||
|
var bridgeDescription = interface.identifier
|
||||||
|
|
||||||
|
if let localizedDisplayName = interface.localizedDisplayName {
|
||||||
|
bridgeDescription += " (or \"\(localizedDisplayName)\")"
|
||||||
|
}
|
||||||
|
|
||||||
|
return bridgeDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func additionalDiskAttachments() throws -> [VZStorageDeviceConfiguration] {
|
||||||
|
var result: [VZStorageDeviceConfiguration] = []
|
||||||
let readOnlySuffix = ":ro"
|
let readOnlySuffix = ":ro"
|
||||||
|
let expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
|
||||||
|
|
||||||
for rawDisk in disk {
|
for rawDisk in expandedDiskPaths {
|
||||||
if rawDisk.hasSuffix(readOnlySuffix) {
|
let diskReadOnly = rawDisk.hasSuffix(readOnlySuffix)
|
||||||
result.append(try VZDiskImageStorageDeviceAttachment(
|
let diskPath = diskReadOnly ? String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count)) : rawDisk
|
||||||
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
|
let diskURL = URL(fileURLWithPath: diskPath)
|
||||||
readOnly: true
|
|
||||||
))
|
// check if `diskPath` is a block device or a directory
|
||||||
|
if pathHasMode(diskPath, mode: S_IFBLK) || pathHasMode(diskPath, mode: S_IFDIR) {
|
||||||
|
print("Using block device\n")
|
||||||
|
guard #available(macOS 14, *) else {
|
||||||
|
throw UnsupportedOSError("attaching block devices", "are")
|
||||||
|
}
|
||||||
|
let fileHandle = FileHandle(forUpdatingAtPath: diskPath)
|
||||||
|
guard fileHandle != nil else {
|
||||||
|
if ProcessInfo.processInfo.userName != "root" {
|
||||||
|
throw RuntimeError.VMConfigurationError("need to run as root to work with block devices")
|
||||||
|
}
|
||||||
|
throw RuntimeError.VMConfigurationError("block device \(diskURL.url.path) seems to be already in use, unmount it first via 'diskutil unmount'")
|
||||||
|
}
|
||||||
|
let attachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: fileHandle!, readOnly: diskReadOnly, synchronizationMode: .full)
|
||||||
|
result.append(VZVirtioBlockDeviceConfiguration(attachment: attachment))
|
||||||
} else {
|
} else {
|
||||||
result.append(try VZDiskImageStorageDeviceAttachment(
|
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
|
||||||
url: URL(fileURLWithPath: rawDisk),
|
// see https://github.com/cirruslabs/tart/issues/323 for more details.
|
||||||
readOnly: false
|
if try !diskReadOnly && !FileLock(lockURL: diskURL).trylock() {
|
||||||
))
|
throw RuntimeError.DiskAlreadyInUse("disk \(diskURL.url.path) seems to be already in use, unmount it first in Finder")
|
||||||
|
}
|
||||||
|
|
||||||
|
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(
|
||||||
|
url: diskURL,
|
||||||
|
readOnly: diskReadOnly
|
||||||
|
)
|
||||||
|
result.append(VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func directoryShares() throws -> [DirectoryShare] {
|
func directoryShares() throws -> [VZDirectorySharingDeviceConfiguration] {
|
||||||
var result: [DirectoryShare] = []
|
if dir.isEmpty {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
guard #available(macOS 13, *) else {
|
||||||
|
throw UnsupportedOSError("directory sharing", "is")
|
||||||
|
}
|
||||||
|
|
||||||
|
var directoryShares: [DirectoryShare] = []
|
||||||
|
|
||||||
|
var allNamedShares = true
|
||||||
for rawDir in dir {
|
for rawDir in dir {
|
||||||
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
|
let directoryShare = try DirectoryShare(parseFrom: rawDir)
|
||||||
|
if (directoryShare.name == nil) {
|
||||||
if splits.count < 2 {
|
allNamedShares = false
|
||||||
throw ValidationError("invalid --dir syntax: should at least include name and path, colon-separated")
|
}
|
||||||
|
directoryShares.append(directoryShare)
|
||||||
}
|
}
|
||||||
|
|
||||||
var readOnly: Bool = false
|
|
||||||
|
|
||||||
if splits.count == 3 {
|
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
||||||
if splits[2] == "ro" {
|
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
|
||||||
readOnly = true
|
if allNamedShares {
|
||||||
} else {
|
var directories: [String : VZSharedDirectory] = Dictionary()
|
||||||
throw ValidationError("invalid --dir syntax: optional read-only specifier can only be \"ro\"")
|
try directoryShares.forEach { directories[$0.name!] = try $0.createConfiguration() }
|
||||||
}
|
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
|
||||||
|
} else if dir.count > 1 {
|
||||||
|
throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named")
|
||||||
|
} else if dir.count == 1 {
|
||||||
|
let directoryShare = directoryShares.first!
|
||||||
|
let singleDirectoryShare = VZSingleDirectoryShare(directory: try directoryShare.createConfiguration())
|
||||||
|
sharingDevice.share = singleDirectoryShare
|
||||||
}
|
}
|
||||||
|
|
||||||
let (name, path) = (String(splits[0]), String(splits[1]))
|
return [sharingDevice]
|
||||||
|
|
||||||
result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: path), readOnly: readOnly))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
private func rosettaDirectoryShare() throws -> [VZDirectorySharingDeviceConfiguration] {
|
||||||
|
guard let rosettaTag = rosettaTag else {
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runUI() {
|
guard #available(macOS 13, *) else {
|
||||||
|
throw UnsupportedOSError("Rosetta directory share", "is")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch VZLinuxRosettaDirectoryShare.availability {
|
||||||
|
case .notInstalled:
|
||||||
|
throw UnsupportedOSError("Rosetta directory share", "is", "that have Rosetta installed")
|
||||||
|
case .notSupported:
|
||||||
|
throw UnsupportedOSError("Rosetta directory share", "is", "running Apple silicon")
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
try VZVirtioFileSystemDeviceConfiguration.validateTag(rosettaTag)
|
||||||
|
let device = VZVirtioFileSystemDeviceConfiguration(tag: rosettaTag)
|
||||||
|
device.share = try VZLinuxRosettaDirectoryShare()
|
||||||
|
|
||||||
|
return [device]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool) {
|
||||||
let nsApp = NSApplication.shared
|
let nsApp = NSApplication.shared
|
||||||
nsApp.setActivationPolicy(.regular)
|
nsApp.setActivationPolicy(.regular)
|
||||||
nsApp.activate(ignoringOtherApps: true)
|
nsApp.activate(ignoringOtherApps: true)
|
||||||
@@ -180,15 +493,33 @@ struct Run: AsyncParsableCommand {
|
|||||||
nsApp.applicationIconImage = NSImage(data: AppIconData)
|
nsApp.applicationIconImage = NSImage(data: AppIconData)
|
||||||
|
|
||||||
struct MainApp: App {
|
struct MainApp: App {
|
||||||
|
static var disappearSignal: Int32 = SIGINT
|
||||||
|
static var capturesSystemKeys: Bool = false
|
||||||
|
|
||||||
|
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup(vm!.name) {
|
WindowGroup(vm!.name) {
|
||||||
Group {
|
Group {
|
||||||
VMView(vm: vm!).onAppear {
|
VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys).onAppear {
|
||||||
NSWindow.allowsAutomaticWindowTabbing = false
|
NSWindow.allowsAutomaticWindowTabbing = false
|
||||||
}.onDisappear {
|
}.onDisappear {
|
||||||
|
let ret = kill(getpid(), MainApp.disappearSignal)
|
||||||
|
if ret != 0 {
|
||||||
|
// Fallback to the old termination method that doesn't
|
||||||
|
// propagate the cancellation to Task's in case graceful
|
||||||
|
// termination via kill(2) is not successful
|
||||||
NSApplication.shared.terminate(self)
|
NSApplication.shared.terminate(self)
|
||||||
}
|
}
|
||||||
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
}
|
||||||
|
}.frame(
|
||||||
|
minWidth: CGFloat(vm!.config.display.width),
|
||||||
|
idealWidth: CGFloat(vm!.config.display.width),
|
||||||
|
maxWidth: .infinity,
|
||||||
|
minHeight: CGFloat(vm!.config.display.height),
|
||||||
|
idealHeight: CGFloat(vm!.config.display.height),
|
||||||
|
maxHeight: .infinity
|
||||||
|
)
|
||||||
}.commands {
|
}.commands {
|
||||||
// Remove some standard menu options
|
// Remove some standard menu options
|
||||||
CommandGroup(replacing: .help, addition: {})
|
CommandGroup(replacing: .help, addition: {})
|
||||||
@@ -198,23 +529,69 @@ struct Run: AsyncParsableCommand {
|
|||||||
CommandGroup(replacing: .undoRedo, addition: {})
|
CommandGroup(replacing: .undoRedo, addition: {})
|
||||||
CommandGroup(replacing: .windowSize, addition: {})
|
CommandGroup(replacing: .windowSize, addition: {})
|
||||||
// Replace some standard menu options
|
// Replace some standard menu options
|
||||||
CommandGroup(replacing: .appInfo) { AboutTart() }
|
CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) }
|
||||||
|
CommandMenu("Control") {
|
||||||
|
Button("Start") {
|
||||||
|
Task { try await vm!.virtualMachine.start() }
|
||||||
|
}
|
||||||
|
Button("Stop") {
|
||||||
|
Task { try await vm!.virtualMachine.stop() }
|
||||||
|
}
|
||||||
|
Button("Request Stop") {
|
||||||
|
Task { try vm!.virtualMachine.requestStop() }
|
||||||
|
}
|
||||||
|
if #available(macOS 14, *) {
|
||||||
|
Button("Suspend") {
|
||||||
|
kill(getpid(), SIGUSR1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
|
||||||
|
MainApp.capturesSystemKeys = captureSystemKeys
|
||||||
MainApp.main()
|
MainApp.main()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The only way to fully remove Edit menu item.
|
||||||
|
class MinimalMenuAppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
|
||||||
|
let indexOfEditMenu = 2
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ : Notification) {
|
||||||
|
NSApplication.shared.mainMenu?.removeItem(at: indexOfEditMenu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct AboutTart: View {
|
struct AboutTart: View {
|
||||||
|
var credits: NSAttributedString
|
||||||
|
|
||||||
|
init(config: VMConfig) {
|
||||||
|
let mutableAttrStr = NSMutableAttributedString()
|
||||||
|
let style = NSMutableParagraphStyle()
|
||||||
|
style.alignment = NSTextAlignment.center
|
||||||
|
let attrCenter: [NSAttributedString.Key : Any] = [
|
||||||
|
.paragraphStyle: style,
|
||||||
|
]
|
||||||
|
mutableAttrStr.append(NSAttributedString(string: "CPU: \(config.cpuCount) cores\n", attributes: attrCenter))
|
||||||
|
mutableAttrStr.append(NSAttributedString(string: "Memory: \(config.memorySize / 1024 / 1024) MB\n", attributes: attrCenter))
|
||||||
|
mutableAttrStr.append(NSAttributedString(string: "Display: \(config.display.description)\n", attributes: attrCenter))
|
||||||
|
mutableAttrStr.append(NSAttributedString(string: "https://github.com/cirruslabs/tart", attributes: [
|
||||||
|
.paragraphStyle: style,
|
||||||
|
.link : "https://github.com/cirruslabs/tart"
|
||||||
|
]))
|
||||||
|
credits = mutableAttrStr
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button("About Tart") {
|
Button("About Tart") {
|
||||||
NSApplication.shared.orderFrontStandardAboutPanel(options: [
|
NSApplication.shared.orderFrontStandardAboutPanel(options: [
|
||||||
NSApplication.AboutPanelOptionKey.applicationIcon: NSApplication.shared.applicationIconImage as Any,
|
NSApplication.AboutPanelOptionKey.applicationIcon: NSApplication.shared.applicationIconImage as Any,
|
||||||
NSApplication.AboutPanelOptionKey.applicationName: "Tart",
|
NSApplication.AboutPanelOptionKey.applicationName: "Tart",
|
||||||
NSApplication.AboutPanelOptionKey.applicationVersion: CI.version,
|
NSApplication.AboutPanelOptionKey.applicationVersion: CI.version,
|
||||||
NSApplication.AboutPanelOptionKey.credits: try! NSAttributedString(markdown: "https://github.com/cirruslabs/tart"),
|
NSApplication.AboutPanelOptionKey.credits: credits,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,10 +601,19 @@ struct VMView: NSViewRepresentable {
|
|||||||
typealias NSViewType = VZVirtualMachineView
|
typealias NSViewType = VZVirtualMachineView
|
||||||
|
|
||||||
@ObservedObject var vm: VM
|
@ObservedObject var vm: VM
|
||||||
|
var capturesSystemKeys: Bool
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSViewType {
|
func makeNSView(context: Context) -> NSViewType {
|
||||||
let machineView = VZVirtualMachineView()
|
let machineView = VZVirtualMachineView()
|
||||||
machineView.capturesSystemKeys = true
|
|
||||||
|
machineView.capturesSystemKeys = capturesSystemKeys
|
||||||
|
|
||||||
|
// Enable automatic display reconfiguration
|
||||||
|
// for guests that support it
|
||||||
|
if #available(macOS 14.0, *) {
|
||||||
|
machineView.automaticallyReconfiguresDisplay = true
|
||||||
|
}
|
||||||
|
|
||||||
return machineView
|
return machineView
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,3 +621,120 @@ struct VMView: NSViewRepresentable {
|
|||||||
nsView.virtualMachine = vm.virtualMachine
|
nsView.virtualMachine = vm.virtualMachine
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DirectoryShare {
|
||||||
|
let name: String?
|
||||||
|
let path: URL
|
||||||
|
let readOnly: Bool
|
||||||
|
|
||||||
|
init(parseFrom: String) throws {
|
||||||
|
let readOnlySuffix = ":ro"
|
||||||
|
readOnly = parseFrom.hasSuffix(readOnlySuffix)
|
||||||
|
let maybeNameAndURL = readOnly ? String(parseFrom.dropLast(readOnlySuffix.count)) : parseFrom
|
||||||
|
|
||||||
|
if maybeNameAndURL.starts(with: "https://") || maybeNameAndURL.starts(with: "http://") {
|
||||||
|
// just a URL
|
||||||
|
name = nil
|
||||||
|
path = URL(string: maybeNameAndURL)!
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let splits = maybeNameAndURL.split(separator: ":", maxSplits: 1)
|
||||||
|
|
||||||
|
if splits.count == 2 {
|
||||||
|
name = String(splits[0])
|
||||||
|
path = String(splits[1]).toRemoteOrLocalURL()
|
||||||
|
} else {
|
||||||
|
name = nil
|
||||||
|
path = String(splits[0]).toRemoteOrLocalURL()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createConfiguration() throws -> VZSharedDirectory {
|
||||||
|
if (path.isFileURL) {
|
||||||
|
return VZSharedDirectory(url: path, readOnly: readOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
let urlCache = URLCache(memoryCapacity: 0, diskCapacity: 1 * 1024 * 1024 * 1024)
|
||||||
|
|
||||||
|
let archiveRequest = URLRequest(url: path, cachePolicy: .returnCacheDataElseLoad)
|
||||||
|
var response: CachedURLResponse? = urlCache.cachedResponse(for: archiveRequest)
|
||||||
|
if (response == nil || response?.data.isEmpty == true) {
|
||||||
|
print("Downloading \(path)...")
|
||||||
|
// download and unarchive remote directories if needed here
|
||||||
|
// use old school API to prevent deadlocks since we are running via MainActor
|
||||||
|
let downloadSemaphore = DispatchSemaphore(value: 0)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let (archiveData, archiveResponse) = try await URLSession.shared.data(for: archiveRequest)
|
||||||
|
if archiveData.isEmpty {
|
||||||
|
print("Remote archive is empty!")
|
||||||
|
} else {
|
||||||
|
urlCache.storeCachedResponse(CachedURLResponse(response: archiveResponse, data: archiveData, storagePolicy: .allowed), for: archiveRequest)
|
||||||
|
print("Cached for future invocations!")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Download failed: \(error)")
|
||||||
|
}
|
||||||
|
downloadSemaphore.signal()
|
||||||
|
}
|
||||||
|
downloadSemaphore.wait()
|
||||||
|
response = urlCache.cachedResponse(for: archiveRequest)
|
||||||
|
} else {
|
||||||
|
print("Using cached archive for \(path)...")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response == nil) {
|
||||||
|
throw ValidationError("Failed to fetch a remote archive!")
|
||||||
|
}
|
||||||
|
|
||||||
|
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".volume")
|
||||||
|
try FileManager.default.createDirectory(atPath: temporaryLocation.path, withIntermediateDirectories: true)
|
||||||
|
let lock = try FileLock(lockURL: temporaryLocation)
|
||||||
|
try lock.lock()
|
||||||
|
|
||||||
|
guard let executableURL = resolveBinaryPath("tar") else {
|
||||||
|
throw ValidationError("tar not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
let process = Process.init()
|
||||||
|
process.executableURL = executableURL
|
||||||
|
process.currentDirectoryURL = temporaryLocation
|
||||||
|
process.arguments = ["-xz"]
|
||||||
|
|
||||||
|
let inPipe = Pipe()
|
||||||
|
process.standardInput = inPipe
|
||||||
|
process.launch()
|
||||||
|
|
||||||
|
inPipe.fileHandleForWriting.write(response!.data)
|
||||||
|
try inPipe.fileHandleForWriting.close()
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||||
|
throw ValidationError("Unarchiving failed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Unarchived into a temporary directory!")
|
||||||
|
|
||||||
|
return VZSharedDirectory(url: temporaryLocation, readOnly: readOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
func toRemoteOrLocalURL() -> URL {
|
||||||
|
if (starts(with: "https://") || starts(with: "https://")) {
|
||||||
|
URL(string: self)!
|
||||||
|
} else {
|
||||||
|
URL(fileURLWithPath: NSString(string: self).expandingTildeInPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathHasMode(_ path: String, mode: mode_t) -> Bool {
|
||||||
|
var st = stat()
|
||||||
|
let statRes = stat(path, &st)
|
||||||
|
guard statRes != -1 else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (Int32(st.st_mode) & Int32(mode)) == Int32(mode)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ struct Set: AsyncParsableCommand {
|
|||||||
var cpu: UInt16?
|
var cpu: UInt16?
|
||||||
|
|
||||||
@Option(help: "VM memory size in megabytes")
|
@Option(help: "VM memory size in megabytes")
|
||||||
var memory: UInt16?
|
var memory: UInt64?
|
||||||
|
|
||||||
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||||
var display: VMDisplayConfig?
|
var display: VMDisplayConfig?
|
||||||
@@ -20,7 +20,6 @@ struct Set: AsyncParsableCommand {
|
|||||||
var diskSize: UInt16?
|
var diskSize: UInt16?
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
|
||||||
@@ -29,7 +28,7 @@ struct Set: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let memory = memory {
|
if let memory = memory {
|
||||||
try vmConfig.setMemory(memorySize: UInt64(memory) * 1024 * 1024)
|
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let display = display {
|
if let display = display {
|
||||||
@@ -46,13 +45,6 @@ struct Set: AsyncParsableCommand {
|
|||||||
if diskSize != nil {
|
if diskSize != nil {
|
||||||
try vmDir.resizeDisk(diskSize!)
|
try vmDir.resizeDisk(diskSize!)
|
||||||
}
|
}
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import System
|
||||||
|
import SwiftDate
|
||||||
|
|
||||||
|
struct Stop: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
|
||||||
|
|
||||||
|
@Argument(help: "VM name")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
|
||||||
|
var timeout: UInt64 = 30
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
|
switch try vmDir.state() {
|
||||||
|
case "suspended":
|
||||||
|
try stopSuspended(vmDir)
|
||||||
|
case "running":
|
||||||
|
try await stopRunning(vmDir)
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
var pid = try lock.pid()
|
||||||
|
if pid == 0 {
|
||||||
|
throw RuntimeError.VMNotRunning(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to gracefully terminate the VM
|
||||||
|
//
|
||||||
|
// Note that we don't check the return code here
|
||||||
|
// to provide a clean exit from "tart stop" in cases
|
||||||
|
// when the VM is already shutting down and we hit
|
||||||
|
// a race condition.
|
||||||
|
//
|
||||||
|
// We check the return code in the kill(2) below, though,
|
||||||
|
// because it's a less common scenario and it would be
|
||||||
|
// nice to know for the user that we've tried all methods
|
||||||
|
// and failed to shutdown the VM.
|
||||||
|
kill(pid, SIGINT)
|
||||||
|
|
||||||
|
// Ensure that the VM has terminated
|
||||||
|
var gracefulWaitDuration = Measurement(value: Double(timeout), unit: UnitDuration.seconds)
|
||||||
|
let gracefulTickDuration = Measurement(value: Double(100), unit: UnitDuration.milliseconds)
|
||||||
|
|
||||||
|
while gracefulWaitDuration.value > 0 {
|
||||||
|
pid = try lock.pid()
|
||||||
|
if pid == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(gracefulTickDuration.converted(to: .nanoseconds).value))
|
||||||
|
gracefulWaitDuration = gracefulWaitDuration - gracefulTickDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seems that VM is still running, proceed with forceful termination
|
||||||
|
let ret = kill(pid, SIGKILL)
|
||||||
|
if ret != 0 {
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError.VMTerminationFailed("failed to forcefully terminate the VM \"\(name)\": \(details)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
|
let lock = try vmDir.lock()
|
||||||
|
|
||||||
|
// Find the VM's PID
|
||||||
|
var 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)\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import Foundation
|
|||||||
struct Config {
|
struct Config {
|
||||||
let tartHomeDir: URL
|
let tartHomeDir: URL
|
||||||
let tartCacheDir: URL
|
let tartCacheDir: URL
|
||||||
|
let tartTmpDir: URL
|
||||||
|
|
||||||
init() throws {
|
init() throws {
|
||||||
var tartHomeDir: URL
|
var tartHomeDir: URL
|
||||||
@@ -14,11 +15,27 @@ struct Config {
|
|||||||
.homeDirectoryForCurrentUser
|
.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".tart", isDirectory: true)
|
.appendingPathComponent(".tart", isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.tartHomeDir = tartHomeDir
|
self.tartHomeDir = tartHomeDir
|
||||||
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
|
|
||||||
|
|
||||||
|
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: tartCacheDir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: tartCacheDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
tartTmpDir = tartHomeDir.appendingPathComponent("tmp", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: tartTmpDir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func gc() throws {
|
||||||
|
for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
|
||||||
|
includingPropertiesForKeys: [], options: []) {
|
||||||
|
let lock = try FileLock(lockURL: entry)
|
||||||
|
if try !lock.trylock() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try FileManager.default.removeItem(at: entry)
|
||||||
|
|
||||||
|
try lock.unlock()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func jsonEncoder() -> JSONEncoder {
|
static func jsonEncoder() -> JSONEncoder {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class EnvironmentCredentialsProvider: CredentialsProvider {
|
||||||
|
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 password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"]
|
||||||
|
if let username = username, let password = password {
|
||||||
|
return (username, password)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func store(host: String, user: String, password: String) throws {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Foundation
|
||||||
|
import AsyncAlgorithms
|
||||||
|
|
||||||
|
fileprivate let urlSession = createURLSession()
|
||||||
|
|
||||||
|
class Fetcher {
|
||||||
|
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
|
if viaFile {
|
||||||
|
return try await fetchViaFile(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await fetchViaMemory(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fetchViaMemory(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
|
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||||
|
|
||||||
|
let (data, response) = try await urlSession.data(for: request)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await dataCh.send(data)
|
||||||
|
|
||||||
|
dataCh.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (dataCh, response as! HTTPURLResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fetchViaFile(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
|
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||||
|
|
||||||
|
let (fileURL, response) = try await urlSession.download(for: request)
|
||||||
|
|
||||||
|
// Acquire a handle to the downloaded file and then remove it.
|
||||||
|
//
|
||||||
|
// This keeps a working reference to that file, yet we don't
|
||||||
|
// have to deal with the cleanup any more.
|
||||||
|
let mappedFile = try Data(contentsOf: fileURL, options: [.alwaysMapped])
|
||||||
|
try FileManager.default.removeItem(at: fileURL)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
for chunk in (0 ..< mappedFile.count).chunks(ofCount: 64 * 1024 * 1024) {
|
||||||
|
await dataCh.send(mappedFile.subdata(in: chunk))
|
||||||
|
}
|
||||||
|
|
||||||
|
dataCh.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (dataCh, response as! HTTPURLResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func createURLSession() -> URLSession {
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
|
||||||
|
// Harbor expects a CSRF token to be present if the HTTP client
|
||||||
|
// carries a session cookie between its requests[1] and fails if
|
||||||
|
// it was not present[2].
|
||||||
|
//
|
||||||
|
// To fix that, we disable the automatic cookies carry in URLSession.
|
||||||
|
//
|
||||||
|
// [1]: https://github.com/goharbor/harbor/blob/a4c577f9ec4f18396207a5e686433a6ba203d4ef/src/server/middleware/csrf/csrf.go#L78
|
||||||
|
// [2]: https://github.com/cirruslabs/tart/issues/295
|
||||||
|
config.httpShouldSetCookies = false
|
||||||
|
|
||||||
|
return URLSession(configuration: config)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import TextTable
|
||||||
|
|
||||||
|
enum Format: String, ExpressibleByArgument, CaseIterable {
|
||||||
|
case text, json
|
||||||
|
|
||||||
|
private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"}
|
||||||
|
|
||||||
|
func renderSingle<T>(_ data: T) -> String where T: Encodable {
|
||||||
|
switch self {
|
||||||
|
case .text:
|
||||||
|
return renderList([data])
|
||||||
|
case .json:
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = .prettyPrinted
|
||||||
|
return try! encoder.encode(data).asText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderList<T>(_ data: Array<T>) -> String where T: Encodable {
|
||||||
|
switch self {
|
||||||
|
case .text:
|
||||||
|
if (data.count == 0) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
let table = TextTable<T> { (item: T) in
|
||||||
|
let mirroredObject = Mirror(reflecting: item)
|
||||||
|
return mirroredObject.children.enumerated()
|
||||||
|
.filter {(_, element) in
|
||||||
|
// 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) ?? ""
|
||||||
|
case .json:
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = .prettyPrinted
|
||||||
|
return try! encoder.encode(data).asText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ class IPSWCache: PrunableStorage {
|
|||||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func locationFor(image: VZMacOSRestoreImage) -> URL {
|
func locationFor(fileName: String) -> URL {
|
||||||
baseURL.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false)
|
baseURL.appendingPathComponent(fileName, isDirectory: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func prunables() throws -> [Prunable] {
|
func prunables() throws -> [Prunable] {
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ struct ARPCacheInternalError: Error, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ARPCache {
|
struct ARPCache {
|
||||||
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
let arpCommandOutput: Data
|
||||||
|
|
||||||
|
init() throws {
|
||||||
let process = Process.init()
|
let process = Process.init()
|
||||||
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
||||||
process.arguments = ["-an"]
|
process.arguments = ["-an"]
|
||||||
@@ -58,10 +60,15 @@ struct ARPCache {
|
|||||||
terminationStatus: process.terminationStatus)
|
terminationStatus: process.terminationStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
|
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
||||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||||
}
|
}
|
||||||
let lines = String(decoding: rawLines, as: UTF8.self)
|
|
||||||
|
self.arpCommandOutput = arpCommandOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||||
|
let lines = String(decoding: arpCommandOutput, as: UTF8.self)
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.components(separatedBy: "\n")
|
.components(separatedBy: "\n")
|
||||||
|
|
||||||
@@ -88,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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,24 +19,33 @@ enum LeasesError: Error {
|
|||||||
class Leases {
|
class Leases {
|
||||||
private let leases: [MACAddress : Lease]
|
private let leases: [MACAddress : Lease]
|
||||||
|
|
||||||
convenience init() throws {
|
convenience init?() throws {
|
||||||
try self.init(URL(fileURLWithPath: "/var/db/dhcpd_leases"))
|
try self.init(URL(fileURLWithPath: "/var/db/dhcpd_leases"))
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(_ fromURL: URL) throws {
|
convenience init?(_ fromURL: URL) throws {
|
||||||
let fileContents = try String(contentsOf: fromURL, encoding: .utf8)
|
do {
|
||||||
|
let urlContents = try String(contentsOf: fromURL, encoding: .utf8)
|
||||||
|
try self.init(urlContents)
|
||||||
|
} catch {
|
||||||
|
if error.isFileNotFound() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
try self.init(fileContents)
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(uniqueKeysWithValues: leases)
|
||||||
leases[lease.mac] = lease
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -100,7 +109,7 @@ class Leases {
|
|||||||
return rawLeases
|
return rawLeases
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
func ResolveMACAddress(macAddress: MACAddress) -> IPv4Address? {
|
||||||
leases[macAddress]?.ip
|
leases[macAddress]?.ip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Virtualization
|
||||||
|
|
||||||
|
protocol Network {
|
||||||
|
func attachments() -> [VZNetworkDeviceAttachment]
|
||||||
|
func run(_ sema: DispatchSemaphore) throws
|
||||||
|
func stop() async throws
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
class NetworkBridged: Network {
|
||||||
|
let interfaces: [VZBridgedNetworkInterface]
|
||||||
|
|
||||||
|
init(interfaces: [VZBridgedNetworkInterface]) {
|
||||||
|
self.interfaces = interfaces
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
|
interfaces.map { VZBridgedNetworkDeviceAttachment(interface: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(_ sema: DispatchSemaphore) throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() async throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import Foundation
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
class NetworkShared: Network {
|
||||||
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
|
[VZNATNetworkDeviceAttachment()]
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(_ sema: DispatchSemaphore) throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() async throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import Foundation
|
||||||
|
import Virtualization
|
||||||
|
import Atomics
|
||||||
|
import System
|
||||||
|
|
||||||
|
enum SoftnetError: Error {
|
||||||
|
case InitializationFailed(why: String)
|
||||||
|
case RuntimeFailed(why: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Softnet: Network {
|
||||||
|
private let process = Process()
|
||||||
|
private var monitorTask: Task<Void, Error>? = nil
|
||||||
|
private let monitorTaskFinished = ManagedAtomic<Bool>(false)
|
||||||
|
|
||||||
|
let vmFD: Int32
|
||||||
|
|
||||||
|
init(vmMACAddress: String) throws {
|
||||||
|
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||||
|
|
||||||
|
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
||||||
|
if ret != 0 {
|
||||||
|
throw SoftnetError.InitializationFailed(why: "socketpair() failed with exit code \(ret)")
|
||||||
|
}
|
||||||
|
|
||||||
|
vmFD = fds[0]
|
||||||
|
let softnetFD = fds[1]
|
||||||
|
|
||||||
|
try setSocketBuffers(vmFD, 1 * 1024 * 1024);
|
||||||
|
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
||||||
|
|
||||||
|
process.executableURL = try Self.softnetExecutableURL()
|
||||||
|
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress]
|
||||||
|
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func softnetExecutableURL() throws -> URL {
|
||||||
|
let binaryName = "softnet"
|
||||||
|
|
||||||
|
guard let executableURL = resolveBinaryPath(binaryName) else {
|
||||||
|
throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
return executableURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(_ sema: DispatchSemaphore) throws {
|
||||||
|
try process.run()
|
||||||
|
|
||||||
|
monitorTask = Task {
|
||||||
|
// Wait for the Softnet to finish
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
// Signal to the caller that the Softnet has finished
|
||||||
|
sema.signal()
|
||||||
|
|
||||||
|
// Signal to ourselves that the Softnet has finished
|
||||||
|
monitorTaskFinished.store(true, ordering: .sequentiallyConsistent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() async throws {
|
||||||
|
if monitorTaskFinished.load(ordering: .sequentiallyConsistent) {
|
||||||
|
// Consume the monitor task's value to ensure the task has finished
|
||||||
|
_ = try await monitorTask?.value
|
||||||
|
|
||||||
|
throw SoftnetError.RuntimeFailed(why: "Softnet process terminated prematurely")
|
||||||
|
} else {
|
||||||
|
process.interrupt()
|
||||||
|
|
||||||
|
// Consume the monitor task's value to ensure the task has finished
|
||||||
|
_ = try await monitorTask?.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
|
||||||
|
let option_len = socklen_t(MemoryLayout<Int>.size)
|
||||||
|
|
||||||
|
// The system expects the value of SO_RCVBUF to be at least double the value of SO_SNDBUF,
|
||||||
|
// and for optimal performance, the recommended value of SO_RCVBUF is four times the value of SO_SNDBUF.
|
||||||
|
// See: https://developer.apple.com/documentation/virtualization/vzfilehandlenetworkdeviceattachment/3969266-maximumtransmissionunit
|
||||||
|
var receiveBufferSize = 4 * sizeBytes
|
||||||
|
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &receiveBufferSize, option_len)
|
||||||
|
if ret != 0 {
|
||||||
|
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendBufferSize = sizeBytes
|
||||||
|
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sendBufferSize, option_len)
|
||||||
|
if ret != 0 {
|
||||||
|
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
|
let fh = FileHandle.init(fileDescriptor: vmFD)
|
||||||
|
return [VZFileHandleNetworkDeviceAttachment(fileHandle: fh)]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func configureSUIDBitIfNeeded() throws {
|
||||||
|
// Obtain the Softnet executable path
|
||||||
|
//
|
||||||
|
// It's important to use resolvingSymlinksInPath() here, because otherwise
|
||||||
|
// we will get something like "/opt/homebrew/bin/softnet" instead of
|
||||||
|
// "/opt/homebrew/Cellar/softnet/0.6.2/bin/softnet"
|
||||||
|
let softnetExecutablePath = try Softnet.softnetExecutableURL().resolvingSymlinksInPath().path
|
||||||
|
|
||||||
|
// Check if the SUID bit is already configured
|
||||||
|
let info = try FileManager.default.attributesOfItem(atPath: softnetExecutablePath) as NSDictionary
|
||||||
|
if info.fileOwnerAccountID() == 0 && (info.filePosixPermissions() & Int(S_ISUID)) != 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the passwordless Sudo is already configured for Softnet
|
||||||
|
let sudoBinaryName = "sudo"
|
||||||
|
|
||||||
|
guard let sudoExecutableURL = resolveBinaryPath(sudoBinaryName) else {
|
||||||
|
throw SoftnetError.InitializationFailed(why: "\(sudoBinaryName) not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
var process = Process()
|
||||||
|
process.executableURL = sudoExecutableURL
|
||||||
|
process.arguments = ["--non-interactive", "softnet", "--help"]
|
||||||
|
process.standardInput = nil
|
||||||
|
process.standardOutput = nil
|
||||||
|
process.standardError = nil
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
if process.terminationStatus == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure the SUID bit by spawning the Sudo process in interactive mode
|
||||||
|
// and asking the user for password required to run chown & chmod
|
||||||
|
fputs("Softnet requires a Sudo password to set the SUID bit on the Softnet executable, please enter it below.\n",
|
||||||
|
stderr)
|
||||||
|
|
||||||
|
process = try Process.run(sudoExecutableURL, arguments: [
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
"chown root \(softnetExecutablePath) && chmod u+s \(softnetExecutablePath)",
|
||||||
|
])
|
||||||
|
|
||||||
|
// Set TTY's foreground process group to that of the Sudo process,
|
||||||
|
// otherwise it will get stopped by a SIGTTIN once user input arrives
|
||||||
|
if tcsetpgrp(STDIN_FILENO, process.processIdentifier) == -1 {
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError.SoftnetFailed("tcsetpgrp(2) failed: \(details)")
|
||||||
|
}
|
||||||
|
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
if process.terminationStatus != 0 {
|
||||||
|
throw RuntimeError.SoftnetFailed("failed to configure SUID bit on Softnet executable with Sudo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, progress: Progress) async throws -> [OCIManifestLayer]
|
||||||
|
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) 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, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||||
|
var pushedLayers: [OCIManifestLayer] = []
|
||||||
|
|
||||||
|
// Open the disk file
|
||||||
|
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||||
|
var mappedDiskReadOffset = 0
|
||||||
|
|
||||||
|
// Compress the disk file as a single stream
|
||||||
|
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
||||||
|
// Determine the size of the next chunk
|
||||||
|
let bytesRead = min(length, mappedDisk.count - mappedDiskReadOffset)
|
||||||
|
|
||||||
|
// Read the next uncompressed chunk
|
||||||
|
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
||||||
|
|
||||||
|
// Advance the offset
|
||||||
|
mappedDiskReadOffset += bytesRead
|
||||||
|
|
||||||
|
// Provide the uncompressed chunk to the compressing filter
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cut the compressed stream into layers, each equal exactly ``Self.layerLimitBytes`` bytes,
|
||||||
|
// except for the last one, which may be smaller
|
||||||
|
while let compressedData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
||||||
|
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||||
|
|
||||||
|
pushedLayers.append(OCIManifestLayer(
|
||||||
|
mediaType: diskV1MediaType,
|
||||||
|
size: compressedData.count,
|
||||||
|
digest: layerDigest
|
||||||
|
))
|
||||||
|
|
||||||
|
// Update progress using an absolute value
|
||||||
|
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pushedLayers
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws {
|
||||||
|
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||||
|
throw OCIError.FailedToCreateVmFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the disk file
|
||||||
|
let disk = try FileHandle(forWritingTo: diskURL)
|
||||||
|
defer { try! disk.close() }
|
||||||
|
|
||||||
|
// Decompress the layers onto the disk in a single stream
|
||||||
|
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||||
|
if let data = data {
|
||||||
|
disk.write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for diskLayer in diskLayers {
|
||||||
|
try await registry.pullBlob(diskLayer.digest) { data in
|
||||||
|
try filter.write(data)
|
||||||
|
|
||||||
|
// Update the progress
|
||||||
|
progress.completedUnitCount += Int64(data.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try filter.finalize()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import Foundation
|
||||||
|
import Compression
|
||||||
|
|
||||||
|
class DiskV2: Disk {
|
||||||
|
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||||
|
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||||
|
private static let holeGranularityBytes = 64 * 1024
|
||||||
|
|
||||||
|
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||||
|
var pushedLayers: [OCIManifestLayer] = []
|
||||||
|
|
||||||
|
// Open the disk file
|
||||||
|
var mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||||
|
|
||||||
|
// Compress the disk file as multiple individually decompressible streams,
|
||||||
|
// each equal ``Self.layerLimitBytes`` bytes or slightly larger due to the
|
||||||
|
// internal compressor's buffer
|
||||||
|
var offset: UInt64 = 0
|
||||||
|
|
||||||
|
while let (compressedData, uncompressedSize, uncompressedDigest) = try compressNextLayerOfLimitBytesOrMore(mappedDisk: mappedDisk, offset: offset) {
|
||||||
|
offset += uncompressedSize
|
||||||
|
|
||||||
|
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||||
|
|
||||||
|
pushedLayers.append(OCIManifestLayer(
|
||||||
|
mediaType: diskV2MediaType,
|
||||||
|
size: compressedData.count,
|
||||||
|
digest: layerDigest,
|
||||||
|
uncompressedSize: uncompressedSize,
|
||||||
|
uncompressedContentDigest: uncompressedDigest
|
||||||
|
))
|
||||||
|
|
||||||
|
// Update progress using a relative value
|
||||||
|
progress.completedUnitCount += Int64(uncompressedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pushedLayers
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws {
|
||||||
|
// Support resumable pulls
|
||||||
|
let pullResumed = FileManager.default.fileExists(atPath: diskURL.path)
|
||||||
|
|
||||||
|
if !pullResumed && !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||||
|
throw OCIError.FailedToCreateVmFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
// 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 try pullResumed && Digest.hash(diskURL, offset: diskWritingOffset, size: uncompressedLayerSize) == uncompressedLayerContentDigest {
|
||||||
|
// Update the progress
|
||||||
|
progress.completedUnitCount += Int64(diskLayer.size)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the disk file
|
||||||
|
let disk = try FileHandle(forWritingTo: diskURL)
|
||||||
|
|
||||||
|
// A zero chunk for faster than byte-by-byte comparisons
|
||||||
|
//
|
||||||
|
// Assumes that the other Data(...) is equal in size, but it's fine to get a false-negative
|
||||||
|
// on the last block since it costs only 64 KiB of excess data per 500 MB layer.
|
||||||
|
//
|
||||||
|
// Some simple benchmarks ("sync && sudo purge" command was used to negate the disk caching effects):
|
||||||
|
// +--------------------------------------+---------------------------------------------------+
|
||||||
|
// | Operation | time(1) result |
|
||||||
|
// +--------------------------------------+---------------------------------------------------+
|
||||||
|
// | Data(...) == zeroChunk | 2.16s user 11.71s system 73% cpu 18.928 total |
|
||||||
|
// | Data(...).contains(where: {$0 != 0}) | 603.68s user 12.97s system 99% cpu 10:22.85 total |
|
||||||
|
// +--------------------------------------+---------------------------------------------------+
|
||||||
|
let zeroChunk = Data(count: holeGranularityBytes)
|
||||||
|
var diskWritingOffset = diskWritingOffset
|
||||||
|
|
||||||
|
// Pull and decompress a single layer into the specific offset on disk
|
||||||
|
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||||
|
guard let data = data else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for chunk in data.chunks(ofCount: holeGranularityBytes) {
|
||||||
|
// Only write chunks that are not zero
|
||||||
|
if chunk != zeroChunk {
|
||||||
|
try disk.seek(toOffset: diskWritingOffset)
|
||||||
|
disk.write(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
diskWritingOffset += UInt64(chunk.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 compressNextLayerOfLimitBytesOrMore(mappedDisk: Data, offset: UInt64) throws -> (Data, UInt64, String)? {
|
||||||
|
var compressedData = Data()
|
||||||
|
var bytesRead: UInt64 = 0
|
||||||
|
let digest = Digest()
|
||||||
|
|
||||||
|
// Create a compressing filter that we will terminate upon
|
||||||
|
// reaching ``Self.layerLimitBytes`` of compressed data
|
||||||
|
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: bufferSizeBytes) { (length: Int) -> Data? in
|
||||||
|
if compressedData.count >= Self.layerLimitBytes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let readFromByte = Int(offset + bytesRead)
|
||||||
|
|
||||||
|
let numBytesToRead = min(mappedDisk.count - readFromByte, bufferSizeBytes)
|
||||||
|
if numBytesToRead == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let uncompressedChunk = mappedDisk.subdata(in: readFromByte ..< (readFromByte + numBytesToRead))
|
||||||
|
|
||||||
|
bytesRead += UInt64(uncompressedChunk.count)
|
||||||
|
digest.update(uncompressedChunk)
|
||||||
|
|
||||||
|
return uncompressedChunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve compressed data chunks, but normally no more than ``Self.layerLimitBytes`` bytes
|
||||||
|
while let compressedChunk = try compressingFilter.readData(ofLength: Self.bufferSizeBytes) {
|
||||||
|
compressedData.append(compressedChunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing was read this time from the disk,
|
||||||
|
// signal that to the consumer
|
||||||
|
if bytesRead == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return (compressedData, bytesRead, digest.finalize())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -64,6 +82,37 @@ struct OCIManifestLayer: Codable, Equatable {
|
|||||||
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]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Descriptor: Equatable {
|
struct Descriptor: Equatable {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ null
|
|||||||
':'
|
':'
|
||||||
'/'
|
'/'
|
||||||
'.'
|
'.'
|
||||||
'@'
|
|
||||||
'-'
|
'-'
|
||||||
|
'@'
|
||||||
'_'
|
'_'
|
||||||
null
|
null
|
||||||
null
|
null
|
||||||
@@ -24,6 +24,7 @@ rule names:
|
|||||||
root
|
root
|
||||||
host
|
host
|
||||||
port
|
port
|
||||||
|
host_component
|
||||||
namespace
|
namespace
|
||||||
namespace_component
|
namespace_component
|
||||||
reference
|
reference
|
||||||
@@ -33,4 +34,4 @@ name
|
|||||||
|
|
||||||
|
|
||||||
atn:
|
atn:
|
||||||
[4, 1, 8, 85, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 1, 0, 1, 0, 1, 0, 3, 0, 22, 8, 0, 1, 0, 1, 0, 1, 0, 3, 0, 27, 8, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 5, 1, 34, 8, 1, 10, 1, 12, 1, 37, 9, 1, 1, 2, 4, 2, 40, 8, 2, 11, 2, 12, 2, 41, 1, 3, 1, 3, 1, 3, 5, 3, 47, 8, 3, 10, 3, 12, 3, 50, 9, 3, 1, 4, 1, 4, 3, 4, 54, 8, 4, 4, 4, 56, 8, 4, 11, 4, 12, 4, 57, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 3, 5, 67, 8, 5, 1, 6, 1, 6, 1, 6, 1, 6, 5, 6, 73, 8, 6, 10, 6, 12, 6, 76, 9, 6, 1, 7, 1, 7, 1, 8, 4, 8, 81, 8, 8, 11, 8, 12, 8, 82, 1, 8, 0, 0, 9, 0, 2, 4, 6, 8, 10, 12, 14, 16, 0, 2, 2, 0, 3, 3, 5, 6, 1, 0, 7, 8, 85, 0, 18, 1, 0, 0, 0, 2, 30, 1, 0, 0, 0, 4, 39, 1, 0, 0, 0, 6, 43, 1, 0, 0, 0, 8, 55, 1, 0, 0, 0, 10, 66, 1, 0, 0, 0, 12, 68, 1, 0, 0, 0, 14, 77, 1, 0, 0, 0, 16, 80, 1, 0, 0, 0, 18, 21, 3, 2, 1, 0, 19, 20, 5, 1, 0, 0, 20, 22, 3, 4, 2, 0, 21, 19, 1, 0, 0, 0, 21, 22, 1, 0, 0, 0, 22, 23, 1, 0, 0, 0, 23, 24, 5, 2, 0, 0, 24, 26, 3, 6, 3, 0, 25, 27, 3, 10, 5, 0, 26, 25, 1, 0, 0, 0, 26, 27, 1, 0, 0, 0, 27, 28, 1, 0, 0, 0, 28, 29, 5, 0, 0, 1, 29, 1, 1, 0, 0, 0, 30, 35, 3, 16, 8, 0, 31, 32, 5, 3, 0, 0, 32, 34, 3, 16, 8, 0, 33, 31, 1, 0, 0, 0, 34, 37, 1, 0, 0, 0, 35, 33, 1, 0, 0, 0, 35, 36, 1, 0, 0, 0, 36, 3, 1, 0, 0, 0, 37, 35, 1, 0, 0, 0, 38, 40, 5, 7, 0, 0, 39, 38, 1, 0, 0, 0, 40, 41, 1, 0, 0, 0, 41, 39, 1, 0, 0, 0, 41, 42, 1, 0, 0, 0, 42, 5, 1, 0, 0, 0, 43, 48, 3, 8, 4, 0, 44, 45, 5, 2, 0, 0, 45, 47, 3, 8, 4, 0, 46, 44, 1, 0, 0, 0, 47, 50, 1, 0, 0, 0, 48, 46, 1, 0, 0, 0, 48, 49, 1, 0, 0, 0, 49, 7, 1, 0, 0, 0, 50, 48, 1, 0, 0, 0, 51, 53, 3, 16, 8, 0, 52, 54, 3, 14, 7, 0, 53, 52, 1, 0, 0, 0, 53, 54, 1, 0, 0, 0, 54, 56, 1, 0, 0, 0, 55, 51, 1, 0, 0, 0, 56, 57, 1, 0, 0, 0, 57, 55, 1, 0, 0, 0, 57, 58, 1, 0, 0, 0, 58, 9, 1, 0, 0, 0, 59, 60, 5, 1, 0, 0, 60, 67, 3, 12, 6, 0, 61, 62, 5, 4, 0, 0, 62, 63, 3, 16, 8, 0, 63, 64, 5, 1, 0, 0, 64, 65, 3, 16, 8, 0, 65, 67, 1, 0, 0, 0, 66, 59, 1, 0, 0, 0, 66, 61, 1, 0, 0, 0, 67, 11, 1, 0, 0, 0, 68, 74, 3, 16, 8, 0, 69, 70, 3, 14, 7, 0, 70, 71, 3, 16, 8, 0, 71, 73, 1, 0, 0, 0, 72, 69, 1, 0, 0, 0, 73, 76, 1, 0, 0, 0, 74, 72, 1, 0, 0, 0, 74, 75, 1, 0, 0, 0, 75, 13, 1, 0, 0, 0, 76, 74, 1, 0, 0, 0, 77, 78, 7, 0, 0, 0, 78, 15, 1, 0, 0, 0, 79, 81, 7, 1, 0, 0, 80, 79, 1, 0, 0, 0, 81, 82, 1, 0, 0, 0, 82, 80, 1, 0, 0, 0, 82, 83, 1, 0, 0, 0, 83, 17, 1, 0, 0, 0, 10, 21, 26, 35, 41, 48, 53, 57, 66, 74, 82]
|
[4, 1, 8, 95, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 1, 0, 1, 0, 1, 0, 3, 0, 24, 8, 0, 1, 0, 1, 0, 1, 0, 3, 0, 29, 8, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 5, 1, 36, 8, 1, 10, 1, 12, 1, 39, 9, 1, 1, 2, 4, 2, 42, 8, 2, 11, 2, 12, 2, 43, 1, 3, 1, 3, 1, 3, 5, 3, 49, 8, 3, 10, 3, 12, 3, 52, 9, 3, 1, 4, 1, 4, 1, 4, 5, 4, 57, 8, 4, 10, 4, 12, 4, 60, 9, 4, 1, 5, 1, 5, 3, 5, 64, 8, 5, 4, 5, 66, 8, 5, 11, 5, 12, 5, 67, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 3, 6, 77, 8, 6, 1, 7, 1, 7, 1, 7, 1, 7, 5, 7, 83, 8, 7, 10, 7, 12, 7, 86, 9, 7, 1, 8, 1, 8, 1, 9, 4, 9, 91, 8, 9, 11, 9, 12, 9, 92, 1, 9, 0, 0, 10, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 0, 2, 2, 0, 3, 4, 6, 6, 1, 0, 7, 8, 95, 0, 20, 1, 0, 0, 0, 2, 32, 1, 0, 0, 0, 4, 41, 1, 0, 0, 0, 6, 45, 1, 0, 0, 0, 8, 53, 1, 0, 0, 0, 10, 65, 1, 0, 0, 0, 12, 76, 1, 0, 0, 0, 14, 78, 1, 0, 0, 0, 16, 87, 1, 0, 0, 0, 18, 90, 1, 0, 0, 0, 20, 23, 3, 2, 1, 0, 21, 22, 5, 1, 0, 0, 22, 24, 3, 4, 2, 0, 23, 21, 1, 0, 0, 0, 23, 24, 1, 0, 0, 0, 24, 25, 1, 0, 0, 0, 25, 26, 5, 2, 0, 0, 26, 28, 3, 8, 4, 0, 27, 29, 3, 12, 6, 0, 28, 27, 1, 0, 0, 0, 28, 29, 1, 0, 0, 0, 29, 30, 1, 0, 0, 0, 30, 31, 5, 0, 0, 1, 31, 1, 1, 0, 0, 0, 32, 37, 3, 6, 3, 0, 33, 34, 5, 3, 0, 0, 34, 36, 3, 6, 3, 0, 35, 33, 1, 0, 0, 0, 36, 39, 1, 0, 0, 0, 37, 35, 1, 0, 0, 0, 37, 38, 1, 0, 0, 0, 38, 3, 1, 0, 0, 0, 39, 37, 1, 0, 0, 0, 40, 42, 5, 7, 0, 0, 41, 40, 1, 0, 0, 0, 42, 43, 1, 0, 0, 0, 43, 41, 1, 0, 0, 0, 43, 44, 1, 0, 0, 0, 44, 5, 1, 0, 0, 0, 45, 50, 3, 18, 9, 0, 46, 47, 5, 4, 0, 0, 47, 49, 3, 18, 9, 0, 48, 46, 1, 0, 0, 0, 49, 52, 1, 0, 0, 0, 50, 48, 1, 0, 0, 0, 50, 51, 1, 0, 0, 0, 51, 7, 1, 0, 0, 0, 52, 50, 1, 0, 0, 0, 53, 58, 3, 10, 5, 0, 54, 55, 5, 2, 0, 0, 55, 57, 3, 10, 5, 0, 56, 54, 1, 0, 0, 0, 57, 60, 1, 0, 0, 0, 58, 56, 1, 0, 0, 0, 58, 59, 1, 0, 0, 0, 59, 9, 1, 0, 0, 0, 60, 58, 1, 0, 0, 0, 61, 63, 3, 18, 9, 0, 62, 64, 3, 16, 8, 0, 63, 62, 1, 0, 0, 0, 63, 64, 1, 0, 0, 0, 64, 66, 1, 0, 0, 0, 65, 61, 1, 0, 0, 0, 66, 67, 1, 0, 0, 0, 67, 65, 1, 0, 0, 0, 67, 68, 1, 0, 0, 0, 68, 11, 1, 0, 0, 0, 69, 70, 5, 1, 0, 0, 70, 77, 3, 14, 7, 0, 71, 72, 5, 5, 0, 0, 72, 73, 3, 18, 9, 0, 73, 74, 5, 1, 0, 0, 74, 75, 3, 18, 9, 0, 75, 77, 1, 0, 0, 0, 76, 69, 1, 0, 0, 0, 76, 71, 1, 0, 0, 0, 77, 13, 1, 0, 0, 0, 78, 84, 3, 18, 9, 0, 79, 80, 3, 16, 8, 0, 80, 81, 3, 18, 9, 0, 81, 83, 1, 0, 0, 0, 82, 79, 1, 0, 0, 0, 83, 86, 1, 0, 0, 0, 84, 82, 1, 0, 0, 0, 84, 85, 1, 0, 0, 0, 85, 15, 1, 0, 0, 0, 86, 84, 1, 0, 0, 0, 87, 88, 7, 0, 0, 0, 88, 17, 1, 0, 0, 0, 89, 91, 7, 1, 0, 0, 90, 89, 1, 0, 0, 0, 91, 92, 1, 0, 0, 0, 92, 90, 1, 0, 0, 0, 92, 93, 1, 0, 0, 0, 93, 19, 1, 0, 0, 0, 11, 23, 28, 37, 43, 50, 58, 63, 67, 76, 84, 92]
|
||||||
@@ -9,6 +9,6 @@ LETTER=8
|
|||||||
':'=1
|
':'=1
|
||||||
'/'=2
|
'/'=2
|
||||||
'.'=3
|
'.'=3
|
||||||
'@'=4
|
'-'=4
|
||||||
'-'=5
|
'@'=5
|
||||||
'_'=6
|
'_'=6
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated from Reference.g4 by ANTLR 4.10.1
|
// Generated from java-escape by ANTLR 4.11.1
|
||||||
|
|
||||||
import Antlr4
|
import Antlr4
|
||||||
|
|
||||||
@@ -49,6 +49,19 @@ open class ReferenceBaseListener: ReferenceListener {
|
|||||||
*/
|
*/
|
||||||
open func exitPort(_ ctx: ReferenceParser.PortContext) { }
|
open func exitPort(_ ctx: ReferenceParser.PortContext) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* <p>The default implementation does nothing.</p>
|
||||||
|
*/
|
||||||
|
open func enterHost_component(_ ctx: ReferenceParser.Host_componentContext) { }
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* <p>The default implementation does nothing.</p>
|
||||||
|
*/
|
||||||
|
open func exitHost_component(_ ctx: ReferenceParser.Host_componentContext) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ null
|
|||||||
':'
|
':'
|
||||||
'/'
|
'/'
|
||||||
'.'
|
'.'
|
||||||
'@'
|
|
||||||
'-'
|
'-'
|
||||||
|
'@'
|
||||||
'_'
|
'_'
|
||||||
null
|
null
|
||||||
null
|
null
|
||||||
@@ -38,4 +38,4 @@ mode names:
|
|||||||
DEFAULT_MODE
|
DEFAULT_MODE
|
||||||
|
|
||||||
atn:
|
atn:
|
||||||
[4, 0, 8, 33, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 6, 1, 6, 1, 7, 1, 7, 0, 0, 8, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 1, 0, 2, 1, 0, 48, 57, 2, 0, 65, 90, 97, 122, 32, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0, 1, 17, 1, 0, 0, 0, 3, 19, 1, 0, 0, 0, 5, 21, 1, 0, 0, 0, 7, 23, 1, 0, 0, 0, 9, 25, 1, 0, 0, 0, 11, 27, 1, 0, 0, 0, 13, 29, 1, 0, 0, 0, 15, 31, 1, 0, 0, 0, 17, 18, 5, 58, 0, 0, 18, 2, 1, 0, 0, 0, 19, 20, 5, 47, 0, 0, 20, 4, 1, 0, 0, 0, 21, 22, 5, 46, 0, 0, 22, 6, 1, 0, 0, 0, 23, 24, 5, 64, 0, 0, 24, 8, 1, 0, 0, 0, 25, 26, 5, 45, 0, 0, 26, 10, 1, 0, 0, 0, 27, 28, 5, 95, 0, 0, 28, 12, 1, 0, 0, 0, 29, 30, 7, 0, 0, 0, 30, 14, 1, 0, 0, 0, 31, 32, 7, 1, 0, 0, 32, 16, 1, 0, 0, 0, 1, 0, 0]
|
[4, 0, 8, 33, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 6, 1, 6, 1, 7, 1, 7, 0, 0, 8, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 1, 0, 2, 1, 0, 48, 57, 2, 0, 65, 90, 97, 122, 32, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0, 1, 17, 1, 0, 0, 0, 3, 19, 1, 0, 0, 0, 5, 21, 1, 0, 0, 0, 7, 23, 1, 0, 0, 0, 9, 25, 1, 0, 0, 0, 11, 27, 1, 0, 0, 0, 13, 29, 1, 0, 0, 0, 15, 31, 1, 0, 0, 0, 17, 18, 5, 58, 0, 0, 18, 2, 1, 0, 0, 0, 19, 20, 5, 47, 0, 0, 20, 4, 1, 0, 0, 0, 21, 22, 5, 46, 0, 0, 22, 6, 1, 0, 0, 0, 23, 24, 5, 45, 0, 0, 24, 8, 1, 0, 0, 0, 25, 26, 5, 64, 0, 0, 26, 10, 1, 0, 0, 0, 27, 28, 5, 95, 0, 0, 28, 12, 1, 0, 0, 0, 29, 30, 7, 0, 0, 0, 30, 14, 1, 0, 0, 0, 31, 32, 7, 1, 0, 0, 32, 16, 1, 0, 0, 0, 1, 0, 0]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated from Reference.g4 by ANTLR 4.10.1
|
// Generated from java-escape by ANTLR 4.11.1
|
||||||
import Antlr4
|
import Antlr4
|
||||||
|
|
||||||
open class ReferenceLexer: Lexer {
|
open class ReferenceLexer: Lexer {
|
||||||
@@ -33,7 +33,7 @@ open class ReferenceLexer: Lexer {
|
|||||||
]
|
]
|
||||||
|
|
||||||
private static let _LITERAL_NAMES: [String?] = [
|
private static let _LITERAL_NAMES: [String?] = [
|
||||||
nil, "':'", "'/'", "'.'", "'@'", "'-'", "'_'"
|
nil, "':'", "'/'", "'.'", "'-'", "'@'", "'_'"
|
||||||
]
|
]
|
||||||
private static let _SYMBOLIC_NAMES: [String?] = [
|
private static let _SYMBOLIC_NAMES: [String?] = [
|
||||||
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
|
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
|
||||||
@@ -49,7 +49,7 @@ open class ReferenceLexer: Lexer {
|
|||||||
|
|
||||||
public
|
public
|
||||||
required init(_ input: CharStream) {
|
required init(_ input: CharStream) {
|
||||||
RuntimeMetaData.checkVersion("4.10.1", RuntimeMetaData.VERSION)
|
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
||||||
super.init(input)
|
super.init(input)
|
||||||
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
|
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ open class ReferenceLexer: Lexer {
|
|||||||
1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,1,17,1,0,0,0,3,19,1,0,0,0,5,21,1,0,0,
|
1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,1,17,1,0,0,0,3,19,1,0,0,0,5,21,1,0,0,
|
||||||
0,7,23,1,0,0,0,9,25,1,0,0,0,11,27,1,0,0,0,13,29,1,0,0,0,15,31,1,0,0,0,
|
0,7,23,1,0,0,0,9,25,1,0,0,0,11,27,1,0,0,0,13,29,1,0,0,0,15,31,1,0,0,0,
|
||||||
17,18,5,58,0,0,18,2,1,0,0,0,19,20,5,47,0,0,20,4,1,0,0,0,21,22,5,46,0,0,
|
17,18,5,58,0,0,18,2,1,0,0,0,19,20,5,47,0,0,20,4,1,0,0,0,21,22,5,46,0,0,
|
||||||
22,6,1,0,0,0,23,24,5,64,0,0,24,8,1,0,0,0,25,26,5,45,0,0,26,10,1,0,0,0,
|
22,6,1,0,0,0,23,24,5,45,0,0,24,8,1,0,0,0,25,26,5,64,0,0,26,10,1,0,0,0,
|
||||||
27,28,5,95,0,0,28,12,1,0,0,0,29,30,7,0,0,0,30,14,1,0,0,0,31,32,7,1,0,0,
|
27,28,5,95,0,0,28,12,1,0,0,0,29,30,7,0,0,0,30,14,1,0,0,0,31,32,7,1,0,0,
|
||||||
32,16,1,0,0,0,1,0,0
|
32,16,1,0,0,0,1,0,0
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ LETTER=8
|
|||||||
':'=1
|
':'=1
|
||||||
'/'=2
|
'/'=2
|
||||||
'.'=3
|
'.'=3
|
||||||
'@'=4
|
'-'=4
|
||||||
'-'=5
|
'@'=5
|
||||||
'_'=6
|
'_'=6
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated from Reference.g4 by ANTLR 4.10.1
|
// Generated from java-escape by ANTLR 4.11.1
|
||||||
import Antlr4
|
import Antlr4
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +42,18 @@ public protocol ReferenceListener: ParseTreeListener {
|
|||||||
- ctx: the parse tree
|
- ctx: the parse tree
|
||||||
*/
|
*/
|
||||||
func exitPort(_ ctx: ReferenceParser.PortContext)
|
func exitPort(_ ctx: ReferenceParser.PortContext)
|
||||||
|
/**
|
||||||
|
* Enter a parse tree produced by {@link ReferenceParser#host_component}.
|
||||||
|
- Parameters:
|
||||||
|
- ctx: the parse tree
|
||||||
|
*/
|
||||||
|
func enterHost_component(_ ctx: ReferenceParser.Host_componentContext)
|
||||||
|
/**
|
||||||
|
* Exit a parse tree produced by {@link ReferenceParser#host_component}.
|
||||||
|
- Parameters:
|
||||||
|
- ctx: the parse tree
|
||||||
|
*/
|
||||||
|
func exitHost_component(_ ctx: ReferenceParser.Host_componentContext)
|
||||||
/**
|
/**
|
||||||
* Enter a parse tree produced by {@link ReferenceParser#namespace}.
|
* Enter a parse tree produced by {@link ReferenceParser#namespace}.
|
||||||
- Parameters:
|
- Parameters:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated from Reference.g4 by ANTLR 4.10.1
|
// Generated from java-escape by ANTLR 4.11.1
|
||||||
import Antlr4
|
import Antlr4
|
||||||
|
|
||||||
open class ReferenceParser: Parser {
|
open class ReferenceParser: Parser {
|
||||||
@@ -21,18 +21,18 @@ open class ReferenceParser: Parser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public
|
public
|
||||||
static let RULE_root = 0, RULE_host = 1, RULE_port = 2, RULE_namespace = 3,
|
static let RULE_root = 0, RULE_host = 1, RULE_port = 2, RULE_host_component = 3,
|
||||||
RULE_namespace_component = 4, RULE_reference = 5, RULE_tag = 6,
|
RULE_namespace = 4, RULE_namespace_component = 5, RULE_reference = 6,
|
||||||
RULE_separator = 7, RULE_name = 8
|
RULE_tag = 7, RULE_separator = 8, RULE_name = 9
|
||||||
|
|
||||||
public
|
public
|
||||||
static let ruleNames: [String] = [
|
static let ruleNames: [String] = [
|
||||||
"root", "host", "port", "namespace", "namespace_component", "reference",
|
"root", "host", "port", "host_component", "namespace", "namespace_component",
|
||||||
"tag", "separator", "name"
|
"reference", "tag", "separator", "name"
|
||||||
]
|
]
|
||||||
|
|
||||||
private static let _LITERAL_NAMES: [String?] = [
|
private static let _LITERAL_NAMES: [String?] = [
|
||||||
nil, "':'", "'/'", "'.'", "'@'", "'-'", "'_'"
|
nil, "':'", "'/'", "'.'", "'-'", "'@'", "'_'"
|
||||||
]
|
]
|
||||||
private static let _SYMBOLIC_NAMES: [String?] = [
|
private static let _SYMBOLIC_NAMES: [String?] = [
|
||||||
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
|
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
|
||||||
@@ -41,7 +41,7 @@ open class ReferenceParser: Parser {
|
|||||||
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
||||||
|
|
||||||
override open
|
override open
|
||||||
func getGrammarFileName() -> String { return "Reference.g4" }
|
func getGrammarFileName() -> String { return "java-escape" }
|
||||||
|
|
||||||
override open
|
override open
|
||||||
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
|
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
|
||||||
@@ -60,7 +60,7 @@ open class ReferenceParser: Parser {
|
|||||||
|
|
||||||
override public
|
override public
|
||||||
init(_ input:TokenStream) throws {
|
init(_ input:TokenStream) throws {
|
||||||
RuntimeMetaData.checkVersion("4.10.1", RuntimeMetaData.VERSION)
|
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
||||||
try super.init(input)
|
try super.init(input)
|
||||||
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
|
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
|
||||||
}
|
}
|
||||||
@@ -115,41 +115,33 @@ open class ReferenceParser: Parser {
|
|||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(18)
|
setState(20)
|
||||||
try host()
|
try host()
|
||||||
setState(21)
|
setState(23)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
if (//closure
|
if (_la == ReferenceParser.Tokens.T__0.rawValue) {
|
||||||
{ () -> Bool in
|
setState(21)
|
||||||
let testSet: Bool = _la == ReferenceParser.Tokens.T__0.rawValue
|
|
||||||
return testSet
|
|
||||||
}()) {
|
|
||||||
setState(19)
|
|
||||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||||
setState(20)
|
setState(22)
|
||||||
try port()
|
try port()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(23)
|
setState(25)
|
||||||
try match(ReferenceParser.Tokens.T__1.rawValue)
|
try match(ReferenceParser.Tokens.T__1.rawValue)
|
||||||
setState(24)
|
|
||||||
try namespace()
|
|
||||||
setState(26)
|
setState(26)
|
||||||
|
try namespace()
|
||||||
|
setState(28)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
if (//closure
|
if (_la == ReferenceParser.Tokens.T__0.rawValue || _la == ReferenceParser.Tokens.T__4.rawValue) {
|
||||||
{ () -> Bool in
|
setState(27)
|
||||||
let testSet: Bool = _la == ReferenceParser.Tokens.T__0.rawValue || _la == ReferenceParser.Tokens.T__3.rawValue
|
|
||||||
return testSet
|
|
||||||
}()) {
|
|
||||||
setState(25)
|
|
||||||
try reference()
|
try reference()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(28)
|
setState(30)
|
||||||
try match(ReferenceParser.Tokens.EOF.rawValue)
|
try match(ReferenceParser.Tokens.EOF.rawValue)
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -164,12 +156,12 @@ open class ReferenceParser: Parser {
|
|||||||
|
|
||||||
public class HostContext: ParserRuleContext {
|
public class HostContext: ParserRuleContext {
|
||||||
open
|
open
|
||||||
func name() -> [NameContext] {
|
func host_component() -> [Host_componentContext] {
|
||||||
return getRuleContexts(NameContext.self)
|
return getRuleContexts(Host_componentContext.self)
|
||||||
}
|
}
|
||||||
open
|
open
|
||||||
func name(_ i: Int) -> NameContext? {
|
func host_component(_ i: Int) -> Host_componentContext? {
|
||||||
return getRuleContext(NameContext.self, i)
|
return getRuleContext(Host_componentContext.self, i)
|
||||||
}
|
}
|
||||||
override open
|
override open
|
||||||
func getRuleIndex() -> Int {
|
func getRuleIndex() -> Int {
|
||||||
@@ -199,23 +191,19 @@ open class ReferenceParser: Parser {
|
|||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(30)
|
setState(32)
|
||||||
try name()
|
try host_component()
|
||||||
setState(35)
|
setState(37)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
while (//closure
|
while (_la == ReferenceParser.Tokens.T__2.rawValue) {
|
||||||
{ () -> Bool in
|
setState(33)
|
||||||
let testSet: Bool = _la == ReferenceParser.Tokens.T__2.rawValue
|
|
||||||
return testSet
|
|
||||||
}()) {
|
|
||||||
setState(31)
|
|
||||||
try match(ReferenceParser.Tokens.T__2.rawValue)
|
try match(ReferenceParser.Tokens.T__2.rawValue)
|
||||||
setState(32)
|
setState(34)
|
||||||
try name()
|
try host_component()
|
||||||
|
|
||||||
|
|
||||||
setState(37)
|
setState(39)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
}
|
}
|
||||||
@@ -267,22 +255,82 @@ open class ReferenceParser: Parser {
|
|||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(39)
|
setState(41)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
repeat {
|
repeat {
|
||||||
setState(38)
|
setState(40)
|
||||||
try match(ReferenceParser.Tokens.DIGIT.rawValue)
|
try match(ReferenceParser.Tokens.DIGIT.rawValue)
|
||||||
|
|
||||||
|
|
||||||
setState(41);
|
setState(43);
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
} while (//closure
|
} while (_la == ReferenceParser.Tokens.DIGIT.rawValue)
|
||||||
{ () -> Bool in
|
|
||||||
let testSet: Bool = _la == ReferenceParser.Tokens.DIGIT.rawValue
|
}
|
||||||
return testSet
|
catch ANTLRException.recognition(let re) {
|
||||||
}())
|
_localctx.exception = re
|
||||||
|
_errHandler.reportError(self, re)
|
||||||
|
try _errHandler.recover(self, re)
|
||||||
|
}
|
||||||
|
|
||||||
|
return _localctx
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Host_componentContext: ParserRuleContext {
|
||||||
|
open
|
||||||
|
func name() -> [NameContext] {
|
||||||
|
return getRuleContexts(NameContext.self)
|
||||||
|
}
|
||||||
|
open
|
||||||
|
func name(_ i: Int) -> NameContext? {
|
||||||
|
return getRuleContext(NameContext.self, i)
|
||||||
|
}
|
||||||
|
override open
|
||||||
|
func getRuleIndex() -> Int {
|
||||||
|
return ReferenceParser.RULE_host_component
|
||||||
|
}
|
||||||
|
override open
|
||||||
|
func enterRule(_ listener: ParseTreeListener) {
|
||||||
|
if let listener = listener as? ReferenceListener {
|
||||||
|
listener.enterHost_component(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override open
|
||||||
|
func exitRule(_ listener: ParseTreeListener) {
|
||||||
|
if let listener = listener as? ReferenceListener {
|
||||||
|
listener.exitHost_component(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@discardableResult
|
||||||
|
open func host_component() throws -> Host_componentContext {
|
||||||
|
var _localctx: Host_componentContext
|
||||||
|
_localctx = Host_componentContext(_ctx, getState())
|
||||||
|
try enterRule(_localctx, 6, ReferenceParser.RULE_host_component)
|
||||||
|
var _la: Int = 0
|
||||||
|
defer {
|
||||||
|
try! exitRule()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try enterOuterAlt(_localctx, 1)
|
||||||
|
setState(45)
|
||||||
|
try name()
|
||||||
|
setState(50)
|
||||||
|
try _errHandler.sync(self)
|
||||||
|
_la = try _input.LA(1)
|
||||||
|
while (_la == ReferenceParser.Tokens.T__3.rawValue) {
|
||||||
|
setState(46)
|
||||||
|
try match(ReferenceParser.Tokens.T__3.rawValue)
|
||||||
|
setState(47)
|
||||||
|
try name()
|
||||||
|
|
||||||
|
|
||||||
|
setState(52)
|
||||||
|
try _errHandler.sync(self)
|
||||||
|
_la = try _input.LA(1)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
catch ANTLRException.recognition(let re) {
|
catch ANTLRException.recognition(let re) {
|
||||||
@@ -324,30 +372,26 @@ open class ReferenceParser: Parser {
|
|||||||
open func namespace() throws -> NamespaceContext {
|
open func namespace() throws -> NamespaceContext {
|
||||||
var _localctx: NamespaceContext
|
var _localctx: NamespaceContext
|
||||||
_localctx = NamespaceContext(_ctx, getState())
|
_localctx = NamespaceContext(_ctx, getState())
|
||||||
try enterRule(_localctx, 6, ReferenceParser.RULE_namespace)
|
try enterRule(_localctx, 8, ReferenceParser.RULE_namespace)
|
||||||
var _la: Int = 0
|
var _la: Int = 0
|
||||||
defer {
|
defer {
|
||||||
try! exitRule()
|
try! exitRule()
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(43)
|
setState(53)
|
||||||
try namespace_component()
|
try namespace_component()
|
||||||
setState(48)
|
setState(58)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
while (//closure
|
while (_la == ReferenceParser.Tokens.T__1.rawValue) {
|
||||||
{ () -> Bool in
|
setState(54)
|
||||||
let testSet: Bool = _la == ReferenceParser.Tokens.T__1.rawValue
|
|
||||||
return testSet
|
|
||||||
}()) {
|
|
||||||
setState(44)
|
|
||||||
try match(ReferenceParser.Tokens.T__1.rawValue)
|
try match(ReferenceParser.Tokens.T__1.rawValue)
|
||||||
setState(45)
|
setState(55)
|
||||||
try namespace_component()
|
try namespace_component()
|
||||||
|
|
||||||
|
|
||||||
setState(50)
|
setState(60)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
}
|
}
|
||||||
@@ -400,45 +444,34 @@ open class ReferenceParser: Parser {
|
|||||||
open func namespace_component() throws -> Namespace_componentContext {
|
open func namespace_component() throws -> Namespace_componentContext {
|
||||||
var _localctx: Namespace_componentContext
|
var _localctx: Namespace_componentContext
|
||||||
_localctx = Namespace_componentContext(_ctx, getState())
|
_localctx = Namespace_componentContext(_ctx, getState())
|
||||||
try enterRule(_localctx, 8, ReferenceParser.RULE_namespace_component)
|
try enterRule(_localctx, 10, ReferenceParser.RULE_namespace_component)
|
||||||
var _la: Int = 0
|
var _la: Int = 0
|
||||||
defer {
|
defer {
|
||||||
try! exitRule()
|
try! exitRule()
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(55)
|
setState(65)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
repeat {
|
repeat {
|
||||||
setState(51)
|
setState(61)
|
||||||
try name()
|
try name()
|
||||||
setState(53)
|
setState(63)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
if (//closure
|
if ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
||||||
{ () -> Bool in
|
setState(62)
|
||||||
let testSet: Bool = { () -> Bool in
|
|
||||||
let testArray: [Int] = [_la, ReferenceParser.Tokens.T__2.rawValue,ReferenceParser.Tokens.T__4.rawValue,ReferenceParser.Tokens.T__5.rawValue]
|
|
||||||
return Utils.testBitLeftShiftArray(testArray, 0)
|
|
||||||
}()
|
|
||||||
return testSet
|
|
||||||
}()) {
|
|
||||||
setState(52)
|
|
||||||
try separator()
|
try separator()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setState(57);
|
setState(67);
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
} while (//closure
|
} while (_la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue)
|
||||||
{ () -> Bool in
|
|
||||||
let testSet: Bool = _la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue
|
|
||||||
return testSet
|
|
||||||
}())
|
|
||||||
|
|
||||||
}
|
}
|
||||||
catch ANTLRException.recognition(let re) {
|
catch ANTLRException.recognition(let re) {
|
||||||
@@ -484,33 +517,33 @@ open class ReferenceParser: Parser {
|
|||||||
open func reference() throws -> ReferenceContext {
|
open func reference() throws -> ReferenceContext {
|
||||||
var _localctx: ReferenceContext
|
var _localctx: ReferenceContext
|
||||||
_localctx = ReferenceContext(_ctx, getState())
|
_localctx = ReferenceContext(_ctx, getState())
|
||||||
try enterRule(_localctx, 10, ReferenceParser.RULE_reference)
|
try enterRule(_localctx, 12, ReferenceParser.RULE_reference)
|
||||||
defer {
|
defer {
|
||||||
try! exitRule()
|
try! exitRule()
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
setState(66)
|
setState(76)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
switch (ReferenceParser.Tokens(rawValue: try _input.LA(1))!) {
|
switch (ReferenceParser.Tokens(rawValue: try _input.LA(1))!) {
|
||||||
case .T__0:
|
case .T__0:
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(59)
|
setState(69)
|
||||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||||
setState(60)
|
setState(70)
|
||||||
try tag()
|
try tag()
|
||||||
|
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
case .T__3:
|
case .T__4:
|
||||||
try enterOuterAlt(_localctx, 2)
|
try enterOuterAlt(_localctx, 2)
|
||||||
setState(61)
|
setState(71)
|
||||||
try match(ReferenceParser.Tokens.T__3.rawValue)
|
try match(ReferenceParser.Tokens.T__4.rawValue)
|
||||||
setState(62)
|
setState(72)
|
||||||
try name()
|
try name()
|
||||||
setState(63)
|
setState(73)
|
||||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||||
setState(64)
|
setState(74)
|
||||||
try name()
|
try name()
|
||||||
|
|
||||||
|
|
||||||
@@ -566,33 +599,26 @@ open class ReferenceParser: Parser {
|
|||||||
open func tag() throws -> TagContext {
|
open func tag() throws -> TagContext {
|
||||||
var _localctx: TagContext
|
var _localctx: TagContext
|
||||||
_localctx = TagContext(_ctx, getState())
|
_localctx = TagContext(_ctx, getState())
|
||||||
try enterRule(_localctx, 12, ReferenceParser.RULE_tag)
|
try enterRule(_localctx, 14, ReferenceParser.RULE_tag)
|
||||||
var _la: Int = 0
|
var _la: Int = 0
|
||||||
defer {
|
defer {
|
||||||
try! exitRule()
|
try! exitRule()
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(68)
|
setState(78)
|
||||||
try name()
|
try name()
|
||||||
setState(74)
|
setState(84)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
while (//closure
|
while ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
||||||
{ () -> Bool in
|
setState(79)
|
||||||
let testSet: Bool = { () -> Bool in
|
|
||||||
let testArray: [Int] = [_la, ReferenceParser.Tokens.T__2.rawValue,ReferenceParser.Tokens.T__4.rawValue,ReferenceParser.Tokens.T__5.rawValue]
|
|
||||||
return Utils.testBitLeftShiftArray(testArray, 0)
|
|
||||||
}()
|
|
||||||
return testSet
|
|
||||||
}()) {
|
|
||||||
setState(69)
|
|
||||||
try separator()
|
try separator()
|
||||||
setState(70)
|
setState(80)
|
||||||
try name()
|
try name()
|
||||||
|
|
||||||
|
|
||||||
setState(76)
|
setState(86)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
}
|
}
|
||||||
@@ -629,23 +655,16 @@ open class ReferenceParser: Parser {
|
|||||||
open func separator() throws -> SeparatorContext {
|
open func separator() throws -> SeparatorContext {
|
||||||
var _localctx: SeparatorContext
|
var _localctx: SeparatorContext
|
||||||
_localctx = SeparatorContext(_ctx, getState())
|
_localctx = SeparatorContext(_ctx, getState())
|
||||||
try enterRule(_localctx, 14, ReferenceParser.RULE_separator)
|
try enterRule(_localctx, 16, ReferenceParser.RULE_separator)
|
||||||
var _la: Int = 0
|
var _la: Int = 0
|
||||||
defer {
|
defer {
|
||||||
try! exitRule()
|
try! exitRule()
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(77)
|
setState(87)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
if (!(//closure
|
if (!((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
|
||||||
{ () -> Bool in
|
|
||||||
let testSet: Bool = { () -> Bool in
|
|
||||||
let testArray: [Int] = [_la, ReferenceParser.Tokens.T__2.rawValue,ReferenceParser.Tokens.T__4.rawValue,ReferenceParser.Tokens.T__5.rawValue]
|
|
||||||
return Utils.testBitLeftShiftArray(testArray, 0)
|
|
||||||
}()
|
|
||||||
return testSet
|
|
||||||
}())) {
|
|
||||||
try _errHandler.recoverInline(self)
|
try _errHandler.recoverInline(self)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -701,7 +720,7 @@ open class ReferenceParser: Parser {
|
|||||||
open func name() throws -> NameContext {
|
open func name() throws -> NameContext {
|
||||||
var _localctx: NameContext
|
var _localctx: NameContext
|
||||||
_localctx = NameContext(_ctx, getState())
|
_localctx = NameContext(_ctx, getState())
|
||||||
try enterRule(_localctx, 16, ReferenceParser.RULE_name)
|
try enterRule(_localctx, 18, ReferenceParser.RULE_name)
|
||||||
var _la: Int = 0
|
var _la: Int = 0
|
||||||
defer {
|
defer {
|
||||||
try! exitRule()
|
try! exitRule()
|
||||||
@@ -709,19 +728,15 @@ open class ReferenceParser: Parser {
|
|||||||
do {
|
do {
|
||||||
var _alt:Int
|
var _alt:Int
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(80);
|
setState(90);
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_alt = 1;
|
_alt = 1;
|
||||||
repeat {
|
repeat {
|
||||||
switch (_alt) {
|
switch (_alt) {
|
||||||
case 1:
|
case 1:
|
||||||
setState(79)
|
setState(89)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
if (!(//closure
|
if (!(_la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue)) {
|
||||||
{ () -> Bool in
|
|
||||||
let testSet: Bool = _la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue
|
|
||||||
return testSet
|
|
||||||
}())) {
|
|
||||||
try _errHandler.recoverInline(self)
|
try _errHandler.recoverInline(self)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -734,9 +749,9 @@ open class ReferenceParser: Parser {
|
|||||||
default:
|
default:
|
||||||
throw ANTLRException.recognition(e: NoViableAltException(self))
|
throw ANTLRException.recognition(e: NoViableAltException(self))
|
||||||
}
|
}
|
||||||
setState(82);
|
setState(92);
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_alt = try getInterpreter().adaptivePredict(_input,9,_ctx)
|
_alt = try getInterpreter().adaptivePredict(_input,10,_ctx)
|
||||||
} while (_alt != 2 && _alt != ATN.INVALID_ALT_NUMBER)
|
} while (_alt != 2 && _alt != ATN.INVALID_ALT_NUMBER)
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -750,30 +765,33 @@ open class ReferenceParser: Parser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static let _serializedATN:[Int] = [
|
static let _serializedATN:[Int] = [
|
||||||
4,1,8,85,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,
|
4,1,8,95,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,
|
||||||
7,2,8,7,8,1,0,1,0,1,0,3,0,22,8,0,1,0,1,0,1,0,3,0,27,8,0,1,0,1,0,1,1,1,
|
7,2,8,7,8,2,9,7,9,1,0,1,0,1,0,3,0,24,8,0,1,0,1,0,1,0,3,0,29,8,0,1,0,1,
|
||||||
1,1,1,5,1,34,8,1,10,1,12,1,37,9,1,1,2,4,2,40,8,2,11,2,12,2,41,1,3,1,3,
|
0,1,1,1,1,1,1,5,1,36,8,1,10,1,12,1,39,9,1,1,2,4,2,42,8,2,11,2,12,2,43,
|
||||||
1,3,5,3,47,8,3,10,3,12,3,50,9,3,1,4,1,4,3,4,54,8,4,4,4,56,8,4,11,4,12,
|
1,3,1,3,1,3,5,3,49,8,3,10,3,12,3,52,9,3,1,4,1,4,1,4,5,4,57,8,4,10,4,12,
|
||||||
4,57,1,5,1,5,1,5,1,5,1,5,1,5,1,5,3,5,67,8,5,1,6,1,6,1,6,1,6,5,6,73,8,6,
|
4,60,9,4,1,5,1,5,3,5,64,8,5,4,5,66,8,5,11,5,12,5,67,1,6,1,6,1,6,1,6,1,
|
||||||
10,6,12,6,76,9,6,1,7,1,7,1,8,4,8,81,8,8,11,8,12,8,82,1,8,0,0,9,0,2,4,6,
|
6,1,6,1,6,3,6,77,8,6,1,7,1,7,1,7,1,7,5,7,83,8,7,10,7,12,7,86,9,7,1,8,1,
|
||||||
8,10,12,14,16,0,2,2,0,3,3,5,6,1,0,7,8,85,0,18,1,0,0,0,2,30,1,0,0,0,4,39,
|
8,1,9,4,9,91,8,9,11,9,12,9,92,1,9,0,0,10,0,2,4,6,8,10,12,14,16,18,0,2,
|
||||||
1,0,0,0,6,43,1,0,0,0,8,55,1,0,0,0,10,66,1,0,0,0,12,68,1,0,0,0,14,77,1,
|
2,0,3,4,6,6,1,0,7,8,95,0,20,1,0,0,0,2,32,1,0,0,0,4,41,1,0,0,0,6,45,1,0,
|
||||||
0,0,0,16,80,1,0,0,0,18,21,3,2,1,0,19,20,5,1,0,0,20,22,3,4,2,0,21,19,1,
|
0,0,8,53,1,0,0,0,10,65,1,0,0,0,12,76,1,0,0,0,14,78,1,0,0,0,16,87,1,0,0,
|
||||||
0,0,0,21,22,1,0,0,0,22,23,1,0,0,0,23,24,5,2,0,0,24,26,3,6,3,0,25,27,3,
|
0,18,90,1,0,0,0,20,23,3,2,1,0,21,22,5,1,0,0,22,24,3,4,2,0,23,21,1,0,0,
|
||||||
10,5,0,26,25,1,0,0,0,26,27,1,0,0,0,27,28,1,0,0,0,28,29,5,0,0,1,29,1,1,
|
0,23,24,1,0,0,0,24,25,1,0,0,0,25,26,5,2,0,0,26,28,3,8,4,0,27,29,3,12,6,
|
||||||
0,0,0,30,35,3,16,8,0,31,32,5,3,0,0,32,34,3,16,8,0,33,31,1,0,0,0,34,37,
|
0,28,27,1,0,0,0,28,29,1,0,0,0,29,30,1,0,0,0,30,31,5,0,0,1,31,1,1,0,0,0,
|
||||||
1,0,0,0,35,33,1,0,0,0,35,36,1,0,0,0,36,3,1,0,0,0,37,35,1,0,0,0,38,40,5,
|
32,37,3,6,3,0,33,34,5,3,0,0,34,36,3,6,3,0,35,33,1,0,0,0,36,39,1,0,0,0,
|
||||||
7,0,0,39,38,1,0,0,0,40,41,1,0,0,0,41,39,1,0,0,0,41,42,1,0,0,0,42,5,1,0,
|
37,35,1,0,0,0,37,38,1,0,0,0,38,3,1,0,0,0,39,37,1,0,0,0,40,42,5,7,0,0,41,
|
||||||
0,0,43,48,3,8,4,0,44,45,5,2,0,0,45,47,3,8,4,0,46,44,1,0,0,0,47,50,1,0,
|
40,1,0,0,0,42,43,1,0,0,0,43,41,1,0,0,0,43,44,1,0,0,0,44,5,1,0,0,0,45,50,
|
||||||
0,0,48,46,1,0,0,0,48,49,1,0,0,0,49,7,1,0,0,0,50,48,1,0,0,0,51,53,3,16,
|
3,18,9,0,46,47,5,4,0,0,47,49,3,18,9,0,48,46,1,0,0,0,49,52,1,0,0,0,50,48,
|
||||||
8,0,52,54,3,14,7,0,53,52,1,0,0,0,53,54,1,0,0,0,54,56,1,0,0,0,55,51,1,0,
|
1,0,0,0,50,51,1,0,0,0,51,7,1,0,0,0,52,50,1,0,0,0,53,58,3,10,5,0,54,55,
|
||||||
0,0,56,57,1,0,0,0,57,55,1,0,0,0,57,58,1,0,0,0,58,9,1,0,0,0,59,60,5,1,0,
|
5,2,0,0,55,57,3,10,5,0,56,54,1,0,0,0,57,60,1,0,0,0,58,56,1,0,0,0,58,59,
|
||||||
0,60,67,3,12,6,0,61,62,5,4,0,0,62,63,3,16,8,0,63,64,5,1,0,0,64,65,3,16,
|
1,0,0,0,59,9,1,0,0,0,60,58,1,0,0,0,61,63,3,18,9,0,62,64,3,16,8,0,63,62,
|
||||||
8,0,65,67,1,0,0,0,66,59,1,0,0,0,66,61,1,0,0,0,67,11,1,0,0,0,68,74,3,16,
|
1,0,0,0,63,64,1,0,0,0,64,66,1,0,0,0,65,61,1,0,0,0,66,67,1,0,0,0,67,65,
|
||||||
8,0,69,70,3,14,7,0,70,71,3,16,8,0,71,73,1,0,0,0,72,69,1,0,0,0,73,76,1,
|
1,0,0,0,67,68,1,0,0,0,68,11,1,0,0,0,69,70,5,1,0,0,70,77,3,14,7,0,71,72,
|
||||||
0,0,0,74,72,1,0,0,0,74,75,1,0,0,0,75,13,1,0,0,0,76,74,1,0,0,0,77,78,7,
|
5,5,0,0,72,73,3,18,9,0,73,74,5,1,0,0,74,75,3,18,9,0,75,77,1,0,0,0,76,69,
|
||||||
0,0,0,78,15,1,0,0,0,79,81,7,1,0,0,80,79,1,0,0,0,81,82,1,0,0,0,82,80,1,
|
1,0,0,0,76,71,1,0,0,0,77,13,1,0,0,0,78,84,3,18,9,0,79,80,3,16,8,0,80,81,
|
||||||
0,0,0,82,83,1,0,0,0,83,17,1,0,0,0,10,21,26,35,41,48,53,57,66,74,82
|
3,18,9,0,81,83,1,0,0,0,82,79,1,0,0,0,83,86,1,0,0,0,84,82,1,0,0,0,84,85,
|
||||||
|
1,0,0,0,85,15,1,0,0,0,86,84,1,0,0,0,87,88,7,0,0,0,88,17,1,0,0,0,89,91,
|
||||||
|
7,1,0,0,90,89,1,0,0,0,91,92,1,0,0,0,92,90,1,0,0,0,92,93,1,0,0,0,93,19,
|
||||||
|
1,0,0,0,11,23,28,37,43,50,58,63,67,76,84,92
|
||||||
]
|
]
|
||||||
|
|
||||||
public
|
public
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
grammar Reference;
|
grammar Reference;
|
||||||
|
|
||||||
root: host (':' port)? '/' namespace reference? EOF;
|
root: host (':' port)? '/' namespace reference? EOF;
|
||||||
host: name ('.' name)*;
|
host: host_component ('.' host_component)*;
|
||||||
port: DIGIT+;
|
port: DIGIT+;
|
||||||
|
host_component: name ('-' name)*;
|
||||||
namespace: namespace_component ('/' namespace_component)*;
|
namespace: namespace_component ('/' namespace_component)*;
|
||||||
namespace_component: (name separator?)+;
|
namespace_component: (name separator?)+;
|
||||||
reference: (':' tag) | ('@' name ':' name);
|
reference: (':' tag) | ('@' name ':' name);
|
||||||
|
|||||||
@@ -1,28 +1,42 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import NIOCore
|
|
||||||
import NIOHTTP1
|
|
||||||
import AsyncHTTPClient
|
|
||||||
import Algorithms
|
import Algorithms
|
||||||
import NIOPosix
|
import AsyncAlgorithms
|
||||||
|
|
||||||
enum RegistryError: Error {
|
enum RegistryError: Error {
|
||||||
case UnexpectedHTTPStatusCode(when: String, code: UInt, details: String = "")
|
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
|
||||||
case MissingLocationHeader
|
case MissingLocationHeader
|
||||||
case AuthFailed(why: String, details: String = "")
|
case AuthFailed(why: String, details: String = "")
|
||||||
case MalformedHeader(why: String)
|
case MalformedHeader(why: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension HTTPClientResponse.Body {
|
enum HTTPMethod: String {
|
||||||
func readTextResponse() async throws -> String? {
|
case GET = "GET"
|
||||||
let data = try await readResponse()
|
case POST = "POST"
|
||||||
return String(decoding: data, as: UTF8.self)
|
case PUT = "PUT"
|
||||||
|
case PATCH = "PATCH"
|
||||||
}
|
}
|
||||||
|
|
||||||
func readResponse() async throws -> Data {
|
enum HTTPCode: Int {
|
||||||
var result = Data()
|
case Ok = 200
|
||||||
for try await part in self {
|
case Created = 201
|
||||||
result.append(Data(buffer: part))
|
case Accepted = 202
|
||||||
|
case Unauthorized = 401
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
func asText() -> String {
|
||||||
|
String(decoding: self, as: UTF8.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AsyncThrowingChannel<Data, Error> {
|
||||||
|
func asData() async throws -> Data {
|
||||||
|
var result = Data()
|
||||||
|
|
||||||
|
for try await chunk in self {
|
||||||
|
result += chunk
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +45,8 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
let defaultIssuedAt = Date()
|
let defaultIssuedAt = Date()
|
||||||
let defaultExpiresIn = 60
|
let defaultExpiresIn = 60
|
||||||
|
|
||||||
var token: String
|
var token: String?
|
||||||
|
var accessToken: String?
|
||||||
var expiresIn: Int?
|
var expiresIn: Int?
|
||||||
var issuedAt: Date?
|
var issuedAt: Date?
|
||||||
|
|
||||||
@@ -51,7 +66,13 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
return dateFormatter.date(from: dateString) ?? Date()
|
return dateFormatter.date(from: dateString) ?? Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
return try decoder.decode(TokenResponse.self, from: fromData)
|
let response = try decoder.decode(TokenResponse.self, from: fromData)
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -69,7 +90,7 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func header() -> (String, String) {
|
func header() -> (String, String) {
|
||||||
("Authorization", "Bearer \(token)")
|
return ("Authorization", "Bearer \(token ?? accessToken ?? "")")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isValid() -> Bool {
|
func isValid() -> Bool {
|
||||||
@@ -78,23 +99,24 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Registry {
|
class Registry {
|
||||||
private let httpClient = HTTPClient(
|
private let baseURL: URL
|
||||||
eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup(numberOfThreads: 1))
|
|
||||||
)
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
try! httpClient.syncShutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
||||||
|
|
||||||
|
if let port = baseURL.port {
|
||||||
|
return "\(host):\(port)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
init(urlComponents: URLComponents,
|
init(urlComponents: URLComponents,
|
||||||
namespace: String,
|
namespace: String,
|
||||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||||
) throws {
|
) throws {
|
||||||
baseURL = urlComponents.url!
|
baseURL = urlComponents.url!
|
||||||
self.namespace = namespace
|
self.namespace = namespace
|
||||||
@@ -105,7 +127,7 @@ class Registry {
|
|||||||
host: String,
|
host: String,
|
||||||
namespace: String,
|
namespace: String,
|
||||||
insecure: Bool = false,
|
insecure: Bool = false,
|
||||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||||
) throws {
|
) throws {
|
||||||
let proto = insecure ? "http" : "https"
|
let proto = insecure ? "http" : "https"
|
||||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||||
@@ -114,43 +136,41 @@ class Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ping() async throws {
|
func ping() async throws {
|
||||||
let response = try await endpointRequest(.GET, "/v2/")
|
let (_, response) = try await dataRequest(.GET, endpointURL("/v2/"))
|
||||||
if response.status != .ok {
|
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "doing ping", code: response.status.code)
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "doing ping", code: response.statusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushManifest(reference: String, manifest: OCIManifest) async throws -> String {
|
func pushManifest(reference: String, manifest: OCIManifest) async throws -> String {
|
||||||
let manifestJSON = try manifest.toJSON()
|
let manifestJSON = try manifest.toJSON()
|
||||||
|
|
||||||
let response = try await endpointRequest(.PUT, "\(namespace)/manifests/\(reference)",
|
let (data, response) = try await dataRequest(.PUT, endpointURL("\(namespace)/manifests/\(reference)"),
|
||||||
headers: ["Content-Type": manifest.mediaType],
|
headers: ["Content-Type": manifest.mediaType],
|
||||||
body: manifestJSON)
|
body: manifestJSON)
|
||||||
if response.status != .created {
|
if response.statusCode != HTTPCode.Created.rawValue {
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.status.code,
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
|
||||||
details: try await response.body.readTextResponse() ?? "")
|
details: data.asText())
|
||||||
}
|
}
|
||||||
|
|
||||||
return Digest.hash(manifestJSON)
|
return Digest.hash(manifestJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func pullManifest(reference: String) async throws -> (OCIManifest, Data) {
|
public func pullManifest(reference: String) async throws -> (OCIManifest, Data) {
|
||||||
let response = try await endpointRequest(.GET, "\(namespace)/manifests/\(reference)",
|
let (data, response) = try await dataRequest(.GET, endpointURL("\(namespace)/manifests/\(reference)"),
|
||||||
headers: ["Accept": ociManifestMediaType])
|
headers: ["Accept": ociManifestMediaType])
|
||||||
if response.status != .ok {
|
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||||
let body = try await response.body.readTextResponse()
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.status.code,
|
details: data.asText())
|
||||||
details: body ?? "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let manifestData = try await response.body.readResponse()
|
let manifest = try OCIManifest(fromJSON: data)
|
||||||
let manifest = try OCIManifest(fromJSON: manifestData)
|
|
||||||
|
|
||||||
return (manifest, manifestData)
|
return (manifest, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uploadLocationFromResponse(_ response: HTTPClientResponse) throws -> URLComponents {
|
private func uploadLocationFromResponse(_ response: HTTPURLResponse) throws -> URLComponents {
|
||||||
guard let uploadLocationRaw = response.headers.first(name: "Location") else {
|
guard let uploadLocationRaw = response.value(forHTTPHeaderField: "Location") else {
|
||||||
throw RegistryError.MissingLocationHeader
|
throw RegistryError.MissingLocationHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,12 +183,11 @@ class Registry {
|
|||||||
|
|
||||||
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
|
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
|
||||||
// Initiate a blob upload
|
// Initiate a blob upload
|
||||||
let postResponse = try await endpointRequest(.POST, "\(namespace)/blobs/uploads/",
|
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
|
||||||
headers: ["Content-Length": "0"])
|
headers: ["Content-Length": "0"])
|
||||||
if postResponse.status != .accepted {
|
if postResponse.statusCode != HTTPCode.Accepted.rawValue {
|
||||||
let body = try await postResponse.body.readTextResponse()
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.status.code,
|
details: data.asText())
|
||||||
details: body ?? "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Figure out where to upload the blob
|
// Figure out where to upload the blob
|
||||||
@@ -178,7 +197,7 @@ class Registry {
|
|||||||
|
|
||||||
if chunkSizeMb == 0 {
|
if chunkSizeMb == 0 {
|
||||||
// monolithic upload
|
// monolithic upload
|
||||||
let response = try await rawRequest(
|
let (data, response) = try await dataRequest(
|
||||||
.PUT,
|
.PUT,
|
||||||
uploadLocation,
|
uploadLocation,
|
||||||
headers: [
|
headers: [
|
||||||
@@ -187,10 +206,9 @@ class Registry {
|
|||||||
parameters: ["digest": digest],
|
parameters: ["digest": digest],
|
||||||
body: fromData
|
body: fromData
|
||||||
)
|
)
|
||||||
if response.status != .created {
|
if response.statusCode != HTTPCode.Created.rawValue {
|
||||||
let body = try await response.body.readTextResponse()
|
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
|
||||||
code: response.status.code, details: body ?? "")
|
code: response.statusCode, details: data.asText())
|
||||||
}
|
}
|
||||||
return digest
|
return digest
|
||||||
}
|
}
|
||||||
@@ -200,7 +218,7 @@ class Registry {
|
|||||||
let chunks = fromData.chunks(ofCount: chunkSizeMb == 0 ? fromData.count : chunkSizeMb * 1_000_000)
|
let chunks = fromData.chunks(ofCount: chunkSizeMb == 0 ? fromData.count : chunkSizeMb * 1_000_000)
|
||||||
for (index, chunk) in chunks.enumerated() {
|
for (index, chunk) in chunks.enumerated() {
|
||||||
let lastChunk = index == (chunks.count - 1)
|
let lastChunk = index == (chunks.count - 1)
|
||||||
let response = try await rawRequest(
|
let (data, response) = try await dataRequest(
|
||||||
lastChunk ? .PUT : .PATCH,
|
lastChunk ? .PUT : .PATCH,
|
||||||
uploadLocation,
|
uploadLocation,
|
||||||
headers: [
|
headers: [
|
||||||
@@ -210,11 +228,10 @@ class Registry {
|
|||||||
parameters: lastChunk ? ["digest": digest] : [:],
|
parameters: lastChunk ? ["digest": digest] : [:],
|
||||||
body: chunk
|
body: chunk
|
||||||
)
|
)
|
||||||
let expectedStatus: HTTPResponseStatus = lastChunk ? .created : .accepted
|
// always accept both statuses since AWS ECR is not following specification
|
||||||
if response.status != expectedStatus {
|
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
|
||||||
let body = try await response.body.readTextResponse()
|
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
|
||||||
code: response.status.code, details: body ?? "")
|
code: response.statusCode, details: data.asText())
|
||||||
}
|
}
|
||||||
uploadedBytes += chunk.count
|
uploadedBytes += chunk.count
|
||||||
// Update location for the next chunk
|
// Update location for the next chunk
|
||||||
@@ -224,42 +241,50 @@ class Registry {
|
|||||||
return digest
|
return digest
|
||||||
}
|
}
|
||||||
|
|
||||||
public func pullBlob(_ digest: String, handler: (ByteBuffer) throws -> Void) async throws {
|
public func pullBlob(_ digest: String, handler: (Data) async throws -> Void) async throws {
|
||||||
let response = try await endpointRequest(.GET, "\(namespace)/blobs/\(digest)")
|
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
|
||||||
if response.status != .ok {
|
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||||
let body = try await response.body.readTextResponse()
|
let body = try await channel.asData().asText()
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.status.code,
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
|
||||||
details: body ?? "")
|
details: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
for try await part in response.body {
|
for try await part in channel {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
try handler(part)
|
try await handler(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func endpointRequest(
|
private func endpointURL(_ endpoint: String) -> URLComponents {
|
||||||
_ method: HTTPMethod,
|
|
||||||
_ endpoint: String,
|
|
||||||
headers: Dictionary<String, String> = Dictionary(),
|
|
||||||
parameters: Dictionary<String, String> = Dictionary(),
|
|
||||||
body: Data? = nil
|
|
||||||
) async throws -> HTTPClientResponse {
|
|
||||||
let url = URL(string: endpoint, relativeTo: baseURL)!
|
let url = URL(string: endpoint, relativeTo: baseURL)!
|
||||||
let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true)!
|
|
||||||
|
|
||||||
return try await rawRequest(method, urlComponents, headers: headers, parameters: parameters, body: body)
|
return URLComponents(url: url, resolvingAgainstBaseURL: true)!
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rawRequest(
|
private func dataRequest(
|
||||||
_ method: HTTPMethod,
|
_ method: HTTPMethod,
|
||||||
_ urlComponents: URLComponents,
|
_ urlComponents: URLComponents,
|
||||||
headers: Dictionary<String, String> = Dictionary(),
|
headers: Dictionary<String, String> = Dictionary(),
|
||||||
parameters: Dictionary<String, String> = Dictionary(),
|
parameters: Dictionary<String, String> = Dictionary(),
|
||||||
body: Data? = nil,
|
body: Data? = nil,
|
||||||
doAuth: Bool = true
|
doAuth: Bool = true
|
||||||
) async throws -> HTTPClientResponse {
|
) async throws -> (Data, HTTPURLResponse) {
|
||||||
|
let (channel, response) = try await channelRequest(method, urlComponents,
|
||||||
|
headers: headers, parameters: parameters, body: body, doAuth: doAuth)
|
||||||
|
|
||||||
|
return (try await channel.asData(), response)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func channelRequest(
|
||||||
|
_ method: HTTPMethod,
|
||||||
|
_ urlComponents: URLComponents,
|
||||||
|
headers: Dictionary<String, String> = Dictionary(),
|
||||||
|
parameters: Dictionary<String, String> = Dictionary(),
|
||||||
|
body: Data? = nil,
|
||||||
|
doAuth: Bool = true,
|
||||||
|
viaFile: Bool = false
|
||||||
|
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
var urlComponents = urlComponents
|
var urlComponents = urlComponents
|
||||||
|
|
||||||
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
||||||
@@ -269,48 +294,44 @@ class Registry {
|
|||||||
URLQueryItem(name: key, value: value)
|
URLQueryItem(name: key, value: value)
|
||||||
})
|
})
|
||||||
|
|
||||||
var request = HTTPClientRequest(url: urlComponents.string!)
|
var request = URLRequest(url: urlComponents.url!)
|
||||||
request.method = method
|
request.httpMethod = method.rawValue
|
||||||
for (key, value) in headers {
|
for (key, value) in headers {
|
||||||
request.headers.add(name: key, value: value)
|
request.addValue(value, forHTTPHeaderField: key)
|
||||||
}
|
}
|
||||||
if body != nil {
|
if let body = body {
|
||||||
request.headers.add(name: "Content-Length", value: "\(body!.count)")
|
request.addValue("\(body.count)", forHTTPHeaderField: "Content-Length")
|
||||||
request.body = HTTPClientRequest.Body.bytes(body!)
|
request.httpBody = body
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate token if it has expired
|
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||||
if currentAuthToken?.isValid() == false {
|
|
||||||
currentAuthToken = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = try await authAwareRequest(request: request)
|
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||||
|
_ = try await channel.asData()
|
||||||
if doAuth && response.status == .unauthorized {
|
|
||||||
try await auth(response: response)
|
try await auth(response: response)
|
||||||
response = try await authAwareRequest(request: request)
|
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return (channel, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func auth(response: HTTPClientResponse) async throws {
|
private func auth(response: HTTPURLResponse) async throws {
|
||||||
// Process WWW-Authenticate header
|
// Process WWW-Authenticate header
|
||||||
guard let wwwAuthenticateRaw = response.headers.first(name: "WWW-Authenticate") else {
|
guard let wwwAuthenticateRaw = response.value(forHTTPHeaderField: "WWW-Authenticate") else {
|
||||||
throw RegistryError.AuthFailed(why: "got HTTP 401, but WWW-Authenticate header is missing")
|
throw RegistryError.AuthFailed(why: "got HTTP 401, but WWW-Authenticate header is missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
||||||
|
|
||||||
if wwwAuthenticate.scheme == "Basic" {
|
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
||||||
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
if let (user, password) = try lookupCredentials() {
|
||||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
await authenticationKeeper.set(BasicAuthentication(user: user, password: password))
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if wwwAuthenticate.scheme != "Bearer" {
|
if wwwAuthenticate.scheme.lowercased() != "bearer" {
|
||||||
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
|
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
|
||||||
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
|
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
|
||||||
}
|
}
|
||||||
@@ -340,23 +361,27 @@ class Registry {
|
|||||||
|
|
||||||
var headers: Dictionary<String, String> = Dictionary()
|
var headers: Dictionary<String, String> = Dictionary()
|
||||||
|
|
||||||
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
if let (user, password) = try lookupCredentials() {
|
||||||
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
|
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
|
||||||
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = try await rawRequest(.GET, authenticateURL, headers: headers, doAuth: false)
|
let (data, response) = try await dataRequest(.GET, authenticateURL, headers: headers, doAuth: false)
|
||||||
if response.status != .ok {
|
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||||
let body = try await response.body.readTextResponse() ?? ""
|
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.statusCode) "
|
||||||
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.status.code) "
|
+ "while retrieving an authentication token", details: data.asText())
|
||||||
+ "while retrieving an authentication token", details: body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let bodyData = try await response.body.readResponse()
|
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
|
||||||
currentAuthToken = try TokenResponse.parse(fromData: bodyData)
|
}
|
||||||
|
|
||||||
|
private func lookupCredentials() throws -> (String, String)? {
|
||||||
|
var host = baseURL.host!
|
||||||
|
|
||||||
|
if let port = baseURL.port {
|
||||||
|
host += ":\(port)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func lookupCredentials(host: String) throws -> (String, String)? {
|
|
||||||
for provider in credentialsProviders {
|
for provider in credentialsProviders {
|
||||||
if let (user, password) = try provider.retrieve(host: host) {
|
if let (user, password) = try provider.retrieve(host: host) {
|
||||||
return (user, password)
|
return (user, password)
|
||||||
@@ -365,14 +390,16 @@ class Registry {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func authAwareRequest(request: HTTPClientRequest) async throws -> HTTPClientResponse {
|
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.headers.add(name: name, value: value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return try await httpClient.execute(request, deadline: .distantFuture)
|
request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))",
|
||||||
|
forHTTPHeaderField: "User-Agent")
|
||||||
|
|
||||||
|
return try await Fetcher.fetch(request, viaFile: viaFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ struct RemoteName: Comparable, Hashable, CustomStringConvertible {
|
|||||||
try ParseTreeWalker().walk(referenceCollector, try parser.root())
|
try ParseTreeWalker().walk(referenceCollector, try parser.root())
|
||||||
|
|
||||||
if let error = errorCollector.error {
|
if let error = errorCollector.error {
|
||||||
throw RuntimeError("failed to parse remote name: \(error)")
|
throw RuntimeError.FailedToParseRemoteName("\(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
host = referenceCollector.host!
|
host = referenceCollector.host!
|
||||||
@@ -120,7 +120,7 @@ struct RemoteName: Comparable, Hashable, CustomStringConvertible {
|
|||||||
} else if reference.starts(with: ":") {
|
} else if reference.starts(with: ":") {
|
||||||
self.reference = Reference(tag: String(reference.dropFirst(1)))
|
self.reference = Reference(tag: String(reference.dropFirst(1)))
|
||||||
} else {
|
} else {
|
||||||
throw RuntimeError("failed to parse remote name: unknown reference format")
|
throw RuntimeError.FailedToParseRemoteName("unknown reference format")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.reference = Reference(tag: "latest")
|
self.reference = Reference(tag: "latest")
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import Foundation
|
||||||
|
import System
|
||||||
|
|
||||||
|
class PIDLock {
|
||||||
|
let url: URL
|
||||||
|
let fd: Int32
|
||||||
|
|
||||||
|
init(lockURL: URL) throws {
|
||||||
|
url = lockURL
|
||||||
|
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 {
|
||||||
|
close(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trylock() throws -> Bool {
|
||||||
|
let (locked, _) = try lockWrapper(F_SETLK, F_WRLCK, "failed to lock \(url)")
|
||||||
|
return locked
|
||||||
|
}
|
||||||
|
|
||||||
|
func lock() throws {
|
||||||
|
_ = try lockWrapper(F_SETLKW, F_WRLCK, "failed to lock \(url)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func unlock() throws {
|
||||||
|
_ = try lockWrapper(F_SETLK, F_UNLCK, "failed to unlock \(url)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pid() throws -> pid_t {
|
||||||
|
let (_, result) = try lockWrapper(F_GETLK, F_RDLCK, "failed to get lock \(url) status")
|
||||||
|
|
||||||
|
return result.l_pid
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockWrapper(_ operation: Int32, _ type: Int32, _ message: String) throws -> (Bool, flock) {
|
||||||
|
var result = flock(l_start: 0, l_len: 0, l_pid: 0, l_type: Int16(type), l_whence: Int16(SEEK_SET))
|
||||||
|
|
||||||
|
let ret = fcntl(fd, operation, &result)
|
||||||
|
if ret != 0 {
|
||||||
|
if operation == F_SETLK && errno == EAGAIN {
|
||||||
|
return (false, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError.PIDLockFailed("\(message): \(details)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
struct Darwin: Platform {
|
struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||||
|
var description: String {
|
||||||
|
"error: host macOS version is outdated to run this virtual machine"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Darwin: PlatformSuspendable {
|
||||||
var ecid: VZMacMachineIdentifier
|
var ecid: VZMacMachineIdentifier
|
||||||
var hardwareModel: VZMacHardwareModel
|
var hardwareModel: VZMacHardwareModel
|
||||||
|
|
||||||
@@ -50,11 +56,18 @@ struct Darwin: Platform {
|
|||||||
VZMacOSBootLoader()
|
VZMacOSBootLoader()
|
||||||
}
|
}
|
||||||
|
|
||||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
|
||||||
let result = VZMacPlatformConfiguration()
|
let result = VZMacPlatformConfiguration()
|
||||||
|
|
||||||
result.machineIdentifier = ecid
|
result.machineIdentifier = ecid
|
||||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
result.auxiliaryStorage = VZMacAuxiliaryStorage(url: nvramURL)
|
||||||
|
|
||||||
|
if !hardwareModel.isSupported {
|
||||||
|
// At the moment support of M1 chip is not yet dropped in any macOS version
|
||||||
|
// This mean that host software is not supporting this hardware model and should be updated
|
||||||
|
throw UnsupportedHostOSError()
|
||||||
|
}
|
||||||
|
|
||||||
result.hardwareModel = hardwareModel
|
result.hardwareModel = hardwareModel
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -85,13 +98,35 @@ struct Darwin: Platform {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
func keyboards() -> [VZKeyboardConfiguration] {
|
||||||
if #available(macOS 13, *) {
|
if #available(macOS 14, *) {
|
||||||
// Trackpad is only supported starting with macOS Ventura
|
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||||
// macOS Monterey will continue using a USB device == .darwin
|
return [VZMacKeyboardConfiguration(), VZUSBKeyboardConfiguration()]
|
||||||
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
|
||||||
} else {
|
} else {
|
||||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
return [VZUSBKeyboardConfiguration()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||||
|
if #available(macOS 14, *) {
|
||||||
|
return [VZMacKeyboardConfiguration()]
|
||||||
|
} else {
|
||||||
|
// fallback to the regular configuration
|
||||||
|
return keyboards()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||||
|
// Trackpad is only supported by guests starting with macOS Ventura
|
||||||
|
[VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||||
|
if #available(macOS 14, *) {
|
||||||
|
return [VZMacTrackpadConfiguration()]
|
||||||
|
} else {
|
||||||
|
// fallback to the regular configuration
|
||||||
|
return pointingDevices()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ protocol PrunableStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protocol Prunable {
|
protocol Prunable {
|
||||||
|
var url: URL { get }
|
||||||
func delete() throws
|
func delete() throws
|
||||||
func accessDate() throws -> Date
|
func accessDate() throws -> Date
|
||||||
func sizeBytes() throws -> Int
|
func sizeBytes() throws -> Int
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Sentry
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct Root: AsyncParsableCommand {
|
struct Root: AsyncParsableCommand {
|
||||||
@@ -11,16 +13,56 @@ struct Root: AsyncParsableCommand {
|
|||||||
Clone.self,
|
Clone.self,
|
||||||
Run.self,
|
Run.self,
|
||||||
Set.self,
|
Set.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,
|
||||||
|
Import.self,
|
||||||
|
Export.self,
|
||||||
Prune.self,
|
Prune.self,
|
||||||
|
Rename.self,
|
||||||
|
Stop.self,
|
||||||
Delete.self,
|
Delete.self,
|
||||||
])
|
])
|
||||||
|
|
||||||
public static func main() async throws {
|
public static func main() async throws {
|
||||||
|
// Initialize Sentry
|
||||||
|
if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
|
||||||
|
SentrySDK.start { options in
|
||||||
|
options.dsn = dsn
|
||||||
|
options.releaseName = CI.release
|
||||||
|
options.tracesSampleRate = Float(
|
||||||
|
ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
|
||||||
|
) as NSNumber?
|
||||||
|
|
||||||
|
// By default only 5XX are captured
|
||||||
|
// Let's capture everything but 401 (unauthorized)
|
||||||
|
options.enableCaptureFailedRequests = true
|
||||||
|
options.failedRequestStatusCodes = [
|
||||||
|
HttpStatusCodeRange(min: 400, max: 400),
|
||||||
|
HttpStatusCodeRange(min: 402, max: 599)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
||||||
|
|
||||||
|
// Enrich future events with Cirrus CI-specific tags
|
||||||
|
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
||||||
|
SentrySDK.configureScope { scope in
|
||||||
|
for (key, value) in tags.split(separator: ",").compactMap({ parseCirrusSentryTag($0) }) {
|
||||||
|
scope.setTag(value: value, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
@@ -32,17 +74,50 @@ struct Root: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
sigintSrc.activate()
|
sigintSrc.activate()
|
||||||
|
|
||||||
|
// Set line-buffered output for stdout
|
||||||
|
setlinebuf(stdout)
|
||||||
|
|
||||||
// Parse and run command
|
// Parse and run command
|
||||||
do {
|
do {
|
||||||
var command = try parseAsRoot()
|
var command = try parseAsRoot()
|
||||||
|
|
||||||
|
// Run garbage-collection before each command (shouldn't take too long)
|
||||||
|
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
|
||||||
|
do {
|
||||||
|
try Config().gc()
|
||||||
|
} catch {
|
||||||
|
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if var asyncCommand = command as? AsyncParsableCommand {
|
if var asyncCommand = command as? AsyncParsableCommand {
|
||||||
try await asyncCommand.run()
|
try await asyncCommand.run()
|
||||||
} else {
|
} else {
|
||||||
try command.run()
|
try command.run()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
// Capture the error into Sentry
|
||||||
|
SentrySDK.capture(error: error)
|
||||||
|
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||||
|
|
||||||
|
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
|
||||||
|
if let errorWithExitCode = error as? HasExitCode {
|
||||||
|
fputs("\(error)\n", stderr)
|
||||||
|
|
||||||
|
Foundation.exit(errorWithExitCode.exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle any other exception, including ArgumentParser's ones
|
||||||
exit(withError: error)
|
exit(withError: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func parseCirrusSentryTag(_ tag: String.SubSequence) -> (String, String)? {
|
||||||
|
let splits = tag.split(separator: "=", maxSplits: 1)
|
||||||
|
if splits.count != 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return (String(splits[0]), String(splits[1]))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum SoftnetError: Error {
|
|
||||||
case InitializationFailed(why: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Softnet {
|
|
||||||
private let process = Process()
|
|
||||||
|
|
||||||
let vmFD: Int32
|
|
||||||
|
|
||||||
init(vmMACAddress: String) throws {
|
|
||||||
let binaryName = "softnet"
|
|
||||||
|
|
||||||
guard let executableURL = resolveBinaryPath(binaryName) else {
|
|
||||||
throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH")
|
|
||||||
}
|
|
||||||
|
|
||||||
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
|
||||||
|
|
||||||
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
|
||||||
if ret != 0 {
|
|
||||||
throw SoftnetError.InitializationFailed(why: "socketpair() failed with exit code \(ret)")
|
|
||||||
}
|
|
||||||
|
|
||||||
vmFD = fds[0]
|
|
||||||
let softnetFD = fds[1]
|
|
||||||
|
|
||||||
try setSocketBuffers(vmFD, 1 * 1024 * 1024);
|
|
||||||
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
|
||||||
|
|
||||||
process.executableURL = executableURL
|
|
||||||
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress]
|
|
||||||
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func run() throws {
|
|
||||||
try process.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() throws {
|
|
||||||
process.interrupt()
|
|
||||||
process.waitUntilExit()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
|
|
||||||
var option_value = sizeBytes
|
|
||||||
let option_len = socklen_t(MemoryLayout<Int>.size)
|
|
||||||
|
|
||||||
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &option_value, option_len)
|
|
||||||
if ret != 0 {
|
|
||||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)")
|
|
||||||
}
|
|
||||||
|
|
||||||
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &option_value, option_len)
|
|
||||||
if ret != 0 {
|
|
||||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||