Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4648e1aea1 | ||
|
|
4b62b73015 | ||
|
|
ae7018c31f | ||
|
|
e54c89da0c | ||
|
|
9a0ec3e6b0 | ||
|
|
400f85a493 | ||
|
|
0105280b5d | ||
|
|
c063c5bdc2 | ||
|
|
8a2b0151e0 | ||
|
|
2eea51d6ec | ||
|
|
1890909d28 | ||
|
|
0cad2e454c | ||
|
|
17dcf942c1 | ||
|
|
6296df7c0c | ||
|
|
c54b140750 | ||
|
|
048a5506df | ||
|
|
553b36349b | ||
|
|
d0bf286aa1 | ||
|
|
195a4b3a72 | ||
|
|
59393df2e1 | ||
|
|
732c8244a4 | ||
|
|
9e69c8c161 | ||
|
|
e4ac2275b9 | ||
|
|
1a1f19e169 | ||
|
|
fea916dacc | ||
|
|
b1be9730c7 | ||
|
|
14059a1d6f | ||
|
|
440681320a | ||
|
|
a80954c888 | ||
|
|
cc8201dee6 | ||
|
|
2cab49b3f1 | ||
|
|
131827802a | ||
|
|
e2b7f12388 | ||
|
|
617a5d02dc | ||
|
|
b83bce4544 | ||
|
|
7d16516b6a | ||
|
|
56ddd8df75 | ||
|
|
b1534a05d5 | ||
|
|
f54e7c2cab | ||
|
|
85dfa961c9 | ||
|
|
3a74fc35e6 | ||
|
|
7bf7f890c4 | ||
|
|
48cd4b47e4 | ||
|
|
c1dee4f9b2 | ||
|
|
116dc01f55 | ||
|
|
52abb7589c | ||
|
|
4386192161 | ||
|
|
85429cea0a | ||
|
|
384abcd0bd | ||
|
|
92529afa23 | ||
|
|
c25364b2d4 | ||
|
|
e12f95878e | ||
|
|
7efef28250 | ||
|
|
1e90752ded | ||
|
|
2020324ef5 | ||
|
|
b581db5be4 | ||
|
|
8ed2ce159f | ||
|
|
89217c23a2 | ||
|
|
0d633de04a | ||
|
|
f59ef2722d | ||
|
|
7759c72a76 | ||
|
|
4cc8a9925a | ||
|
|
b16bbf587a | ||
|
|
022317bc17 | ||
|
|
87733290e7 | ||
|
|
c14b46adc3 | ||
|
|
63329ef363 | ||
|
|
5446164a36 | ||
|
|
f3068b9055 | ||
|
|
afa6b7b46c | ||
|
|
d277fb2941 | ||
|
|
088cdc51a3 | ||
|
|
afb23a3e3a | ||
|
|
35904dc637 | ||
|
|
fec803277d | ||
|
|
13b05d75c5 | ||
|
|
54a321df7f | ||
|
|
b3695c8406 | ||
|
|
0a257a1547 | ||
|
|
60c15e3e49 | ||
|
|
a1bcbdbf0b | ||
|
|
7fec41b2cb | ||
|
|
ea4fb9a2d5 | ||
|
|
c2da3fd919 | ||
|
|
63a2793c32 | ||
|
|
0fc3d3d1f4 | ||
|
|
60b705478b | ||
|
|
fa9e3146c1 | ||
|
|
3dfe8f870c | ||
|
|
7afa446a73 | ||
|
|
d1bed25023 | ||
|
|
b39c3c9b52 | ||
|
|
8554e56e4b | ||
|
|
3fdcc5c33c | ||
|
|
182ddf0268 | ||
|
|
24375c24f3 | ||
|
|
4ebec53c77 | ||
|
|
2f5a6790d8 | ||
|
|
cc697b4f6e | ||
|
|
f6acbe8fe5 | ||
|
|
341fd168b3 | ||
|
|
e8a6efa60a | ||
|
|
efc9c74b6c | ||
|
|
f712ba8ce3 | ||
|
|
4a60c41dd3 | ||
|
|
f20a98bf01 | ||
|
|
d5e3a7718e | ||
|
|
a4db60d656 | ||
|
|
29e08220e4 | ||
|
|
cdf4932aa3 | ||
|
|
95316c0d67 | ||
|
|
4a5efefbc9 | ||
|
|
f07ffed6c8 | ||
|
|
4ca7787958 | ||
|
|
8b5b0536b7 | ||
|
|
d55e5457f9 | ||
|
|
609a2530dd | ||
|
|
ad2db1461f | ||
|
|
cc31388c1b | ||
|
|
e100cc0d7f | ||
|
|
75b5d63387 | ||
|
|
466c3c568a | ||
|
|
9f8d3017d9 | ||
|
|
49d430b3c4 | ||
|
|
dc1e502404 | ||
|
|
483627904c | ||
|
|
dd40717c04 | ||
|
|
13f2366754 | ||
|
|
7a67deab81 | ||
|
|
2c7217f5a8 | ||
|
|
8375e3cf7a | ||
|
|
6fc868e3ba | ||
|
|
348d977f09 | ||
|
|
09a8109f4b | ||
|
|
28ee76491a |
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
export VERSION="${CIRRUS_TAG:-0}"
|
||||
|
||||
mkdir -p .ci/pkg/
|
||||
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
|
||||
cp Resources/embedded.provisionprofile .ci/pkg/embedded.provisionprofile
|
||||
pkgbuild --root .ci/pkg/ --identifier com.github.cirruslabs.tart --version $VERSION \
|
||||
--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
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
TMPFILE=$(mktemp)
|
||||
envsubst < Sources/tart/CI/CI.swift > $TMPFILE
|
||||
mv $TMPFILE Sources/tart/CI/CI.swift
|
||||
@@ -0,0 +1,160 @@
|
||||
use_compute_credits: true
|
||||
|
||||
env:
|
||||
XCODE_TAG: 15
|
||||
|
||||
task:
|
||||
name: Test on Sonoma
|
||||
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:
|
||||
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
|
||||
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
binary_artifacts:
|
||||
path: .build/debug/tart
|
||||
|
||||
task:
|
||||
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
||||
name: Release (Dry Run)
|
||||
depends_on:
|
||||
- lint
|
||||
- build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||
setup_script:
|
||||
- cd $HOME
|
||||
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
||||
- security create-keychain -p password101 build.keychain
|
||||
- security default-keychain -s build.keychain
|
||||
- security unlock-keychain -p password101 build.keychain
|
||||
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
|
||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
||||
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||
install_script:
|
||||
- brew install go goreleaser/tap/goreleaser-pro
|
||||
- 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:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
- swift -version
|
||||
release_script: goreleaser
|
||||
upload_sentry_debug_files_script:
|
||||
- cd .build/arm64-apple-macosx/release/
|
||||
# Generate and upload symbols
|
||||
- dsymutil tart
|
||||
- sentry-cli debug-files upload tart.dSYM/
|
||||
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.dSYM/
|
||||
# Bundle and upload sources
|
||||
- sentry-cli debug-files bundle-sources tart.dSYM
|
||||
- sentry-cli debug-files upload tart.src.zip
|
||||
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.src.zip
|
||||
create_sentry_release_script:
|
||||
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
|
||||
- sentry-cli releases new $SENTRY_RELEASE
|
||||
- sentry-cli releases set-commits $SENTRY_RELEASE --auto
|
||||
- sentry-cli releases finalize $SENTRY_RELEASE
|
||||
|
||||
task:
|
||||
name: Deploy Documentation
|
||||
only_if: $CIRRUS_BRANCH == 'main'
|
||||
container:
|
||||
image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest
|
||||
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
|
||||
env:
|
||||
DEPLOY_TOKEN: ENCRYPTED[!45ed45666558902ed1c2400add734ec063103bec31841847e8c8764802fca229bfa6d85c690e16ad159e047574b48793!]
|
||||
deploy_script:
|
||||
- git config --global user.name "Cirrus CI"
|
||||
- git config --global user.name "hello@cirruslabs.org"
|
||||
- git remote set-url origin https://$DEPLOY_TOKEN@github.com/cirruslabs/tart/
|
||||
- mkdocs --verbose gh-deploy --force --remote-branch gh-pages
|
||||
@@ -0,0 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
@@ -0,0 +1 @@
|
||||
* @edigaryev @fkorotkov
|
||||
@@ -0,0 +1 @@
|
||||
github: [cirruslabs]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Xcode's user settings
|
||||
xcuserdata/
|
||||
tart.xcodeproj/
|
||||
|
||||
# SwiftPM
|
||||
.swiftpm/
|
||||
|
||||
# AppCode
|
||||
.idea/
|
||||
|
||||
# Swift
|
||||
.build/
|
||||
|
||||
# GoReleaser
|
||||
dist/
|
||||
|
||||
# mkdocs
|
||||
.cache
|
||||
@@ -0,0 +1,54 @@
|
||||
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:
|
||||
- builder: prebuilt
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- arm64
|
||||
binary: tart.app/Contents/MacOS/tart
|
||||
prebuilt:
|
||||
path: tart.app/Contents/MacOS/tart
|
||||
|
||||
archives:
|
||||
- name_template: "{{ .ProjectName }}"
|
||||
files:
|
||||
- src: Resources/embedded.provisionprofile
|
||||
dst: tart.app/Contents
|
||||
strip_parent: true
|
||||
- LICENSE
|
||||
|
||||
release:
|
||||
prerelease: auto
|
||||
|
||||
brews:
|
||||
- name: tart
|
||||
tap:
|
||||
owner: cirruslabs
|
||||
name: homebrew-cli
|
||||
caveats: See the GitHub repository for more information
|
||||
homepage: https://github.com/cirruslabs/tart
|
||||
license: "Fair Source"
|
||||
description: Run macOS VMs on Apple Silicon
|
||||
skip_upload: auto
|
||||
dependencies:
|
||||
- "cirruslabs/cli/softnet"
|
||||
install: |
|
||||
libexec.install Dir["*"]
|
||||
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
|
||||
custom_block: |
|
||||
depends_on :macos => :ventura
|
||||
|
||||
on_macos do
|
||||
unless Hardware::CPU.arm?
|
||||
odie "Tart only works on Apple Silicon!"
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,45 @@
|
||||
Fair Source License, version 0.9
|
||||
|
||||
Copyright (C) 2023 Cirrus Labs, Inc.
|
||||
|
||||
Licensor: Cirrus Labs, Inc.
|
||||
|
||||
Software: Tart
|
||||
|
||||
Use Limitation: 100 users. User is defined as a single core of a central processing unit (CPU) used by the product.
|
||||
The Use Limitation does not apply to CPUs installed in devices used by a single individual.
|
||||
|
||||
License Grant. Licensor hereby grants to each recipient of the
|
||||
Software ("you") a non-exclusive, non-transferable, royalty-free and
|
||||
fully-paid-up license, under all of the Licensor's copyright and
|
||||
patent rights, to use, copy, distribute, prepare derivative works of,
|
||||
publicly perform and display the Software, subject to the Use
|
||||
Limitation and the conditions set forth below.
|
||||
|
||||
Use Limitation. The license granted above allows use by up to the
|
||||
number of users per entity set forth above (the "Use Limitation"). For
|
||||
determining the number of users, "you" includes all affiliates,
|
||||
meaning legal entities controlling, controlled by, or under common
|
||||
control with you. If you exceed the Use Limitation, your use is
|
||||
subject to payment of Licensor's then-current list price for licenses.
|
||||
|
||||
Conditions. Redistribution in source code or other forms must include
|
||||
a copy of this license document to be provided in a reasonable
|
||||
manner. Any redistribution of the Software is only allowed subject to
|
||||
this license.
|
||||
|
||||
Trademarks. This license does not grant you any right in the
|
||||
trademarks, service marks, brand names or logos of Licensor.
|
||||
|
||||
DISCLAIMER. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OR
|
||||
CONDITION, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. LICENSORS HEREBY DISCLAIM ALL LIABILITY, WHETHER IN
|
||||
AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE.
|
||||
|
||||
Termination. If you violate the terms of this license, your rights
|
||||
will terminate automatically and will not be reinstated without the
|
||||
prior written consent of Licensor. Any such termination will not
|
||||
affect the right of others who may have received copies of the
|
||||
Software from you.
|
||||
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "antlr4",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/antlr/antlr4",
|
||||
"state" : {
|
||||
"branch" : "dev",
|
||||
"revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dynamic",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/mhdhejazi/Dynamic",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sentry-cocoa",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "d277532e1c8af813981ba01f591b15bbdd735615",
|
||||
"version" : "8.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-algorithms",
|
||||
"state" : {
|
||||
"revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
|
||||
"version" : "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-argument-parser",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "f3c9084a71ef4376f2fabbdf1d3d90a49f1fabdb",
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
|
||||
"version" : "1.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
|
||||
"version" : "1.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-numerics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-numerics",
|
||||
"state" : {
|
||||
"revision" : "0a5bc04095a675662cf24757cc0640aa2204253b",
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/malcommac/SwiftDate",
|
||||
"state" : {
|
||||
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// swift-tools-version:5.7
|
||||
|
||||
import PackageDescription
|
||||
let package = Package(
|
||||
name: "Tart",
|
||||
platforms: [
|
||||
.macOS(.v13)
|
||||
],
|
||||
products: [
|
||||
.executable(name: "tart", targets: ["tart"])
|
||||
],
|
||||
dependencies: [
|
||||
.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/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/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: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
.product(name: "Algorithms", package: "swift-algorithms"),
|
||||
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "Dynamic", package: "Dynamic"),
|
||||
.product(name: "SwiftDate", package: "SwiftDate"),
|
||||
.product(name: "Antlr4Static", package: "Antlr4"),
|
||||
.product(name: "Atomics", package: "swift-atomics"),
|
||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||
.product(name: "TextTable", package: "TextTable"),
|
||||
.product(name: "Sysctl", package: "swift-sysctl"),
|
||||
.product(name: "SwiftRadix", package: "SwiftRadix"),
|
||||
], 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"])
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
|
||||
|
||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon.
|
||||
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
||||
|
||||
* 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.
|
||||
* Use Tart Packer Plugin to automate VM creation.
|
||||
* Easily integrates with any CI system.
|
||||
|
||||
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">
|
||||
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://codemagic.io/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Codemagic.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://testingbot.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
|
||||
</a>
|
||||
</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).
|
||||
|
||||
## Usage
|
||||
|
||||
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/tart
|
||||
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-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.
|
||||
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 570 KiB After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 589 B |
|
After Width: | Height: | Size: 6.6 KiB |
|
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.
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 5.2 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>
|
||||
@@ -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.vm.networking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,17 @@
|
||||
struct CI {
|
||||
private static let rawVersion = "${CIRRUS_TAG}"
|
||||
|
||||
static var version: String {
|
||||
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
|
||||
}
|
||||
|
||||
static var release: String? {
|
||||
rawVersion.expanded() ? "tart@\(rawVersion)" : nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func expanded() -> Bool {
|
||||
!isEmpty && !starts(with: "$")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import SystemConfiguration
|
||||
|
||||
struct Clone: AsyncParsableCommand {
|
||||
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")
|
||||
var sourceName: String
|
||||
|
||||
@Argument(help: "new VM name")
|
||||
var newName: String
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
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 newName.contains("/") {
|
||||
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 {
|
||||
let ociStorage = VMStorageOCI()
|
||||
let localStorage = VMStorageLocal()
|
||||
|
||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency)
|
||||
}
|
||||
|
||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||
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: {
|
||||
// Acquire a global lock
|
||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
try lock.lock()
|
||||
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
&& sourceVM.state() != "suspended"
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
|
||||
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: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
struct Create: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Create a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@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?
|
||||
|
||||
@Flag(help: "create a Linux VM")
|
||||
var linux: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Disk size in Gb"))
|
||||
var diskSize: UInt16 = 50
|
||||
|
||||
func validate() throws {
|
||||
if fromIPSW == nil && !linux {
|
||||
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
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: {
|
||||
if let fromIPSW = fromIPSW {
|
||||
let ipswURL: URL
|
||||
|
||||
if fromIPSW == "latest" {
|
||||
ipswURL = try await VM.latestIPSWURL()
|
||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||
ipswURL = URL(string: fromIPSW)!
|
||||
} else {
|
||||
ipswURL = URL(fileURLWithPath: fromIPSW)
|
||||
}
|
||||
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||
}
|
||||
|
||||
if linux {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
}
|
||||
|
||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
|
||||
struct Delete: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Delete a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
var name: [String]
|
||||
|
||||
func run() async throws {
|
||||
for it in name {
|
||||
try VMStorageHelper.delete(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Network
|
||||
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 {
|
||||
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@Option(help: "Number of seconds to wait for a potential VM booting")
|
||||
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 {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
|
||||
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait) else {
|
||||
var message = "no IP address found"
|
||||
|
||||
if try !vmDir.running() {
|
||||
message += ", is your VM running?"
|
||||
}
|
||||
|
||||
if (vmConfig.os == .linux && resolver == .arp) {
|
||||
message += " (not all Linux distributions are compatible with the ARP resolver)"
|
||||
}
|
||||
|
||||
throw RuntimeError.NoIPAddressFound(message)
|
||||
}
|
||||
|
||||
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)!
|
||||
|
||||
repeat {
|
||||
switch resolutionStrategy {
|
||||
case .arp:
|
||||
if let ip = try ARPCache().ResolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
}
|
||||
case .dhcp:
|
||||
if let leases = try Leases(), let ip = try leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
// wait a second
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} while Date.now < waitUntil
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
|
||||
fileprivate struct VMInfo: Encodable {
|
||||
let Source: String
|
||||
let Name: String
|
||||
let Size: Int
|
||||
let Running: Bool
|
||||
let State: String
|
||||
}
|
||||
|
||||
struct List: AsyncParsableCommand {
|
||||
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 {
|
||||
var infos: [VMInfo] = []
|
||||
|
||||
if source == nil || source == "local" {
|
||||
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
||||
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
})
|
||||
}
|
||||
|
||||
if source == nil || source == "oci" {
|
||||
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
||||
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
})
|
||||
}
|
||||
|
||||
if (quiet) {
|
||||
for info in infos {
|
||||
print(info.Name)
|
||||
}
|
||||
} else {
|
||||
print(format.renderList(infos))
|
||||
}
|
||||
}
|
||||
|
||||
private func sortedInfos(_ infos: [VMInfo]) -> [VMInfo] {
|
||||
infos.sorted(by: { left, right in left.Name < right.Name })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
|
||||
struct Login: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Login to a registry")
|
||||
|
||||
@Argument(help: "host")
|
||||
var host: String
|
||||
|
||||
@Option(help: "username")
|
||||
var username: String?
|
||||
|
||||
@Flag(help: "password-stdin")
|
||||
var passwordStdin: Bool = false
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
@Flag(help: "skip validation of the registry's credentials before logging-in")
|
||||
var noValidate: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
let usernameProvided = username != nil
|
||||
let passwordProvided = passwordStdin
|
||||
|
||||
if usernameProvided != passwordProvided {
|
||||
throw ValidationError("both --username and --password-stdin are required")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
var user: String
|
||||
var password: String
|
||||
|
||||
if let username = username {
|
||||
user = username
|
||||
|
||||
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
||||
password = String(decoding: passwordData, as: UTF8.self)
|
||||
|
||||
// Support "echo $PASSWORD | tart login --username $USERNAME --password-stdin $REGISTRY"
|
||||
password.trimSuffix { c in c.isNewline }
|
||||
} else {
|
||||
(user, password) = try StdinCredentials.retrieve()
|
||||
}
|
||||
let credentialsProvider = DictionaryCredentialsProvider([
|
||||
host: (user, password)
|
||||
])
|
||||
|
||||
if !noValidate {
|
||||
do {
|
||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||
credentialsProviders: [credentialsProvider])
|
||||
try await registry.ping()
|
||||
} catch {
|
||||
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate class DictionaryCredentialsProvider: CredentialsProvider {
|
||||
var credentials: Dictionary<String, (String, String)>
|
||||
|
||||
init(_ credentials: Dictionary<String, (String, String)>) {
|
||||
self.credentials = credentials
|
||||
}
|
||||
|
||||
func retrieve(host: String) throws -> (String, String)? {
|
||||
credentials[host]
|
||||
}
|
||||
|
||||
func store(host: String, user: String, password: String) throws {
|
||||
credentials[host] = (user, password)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
|
||||
struct Logout: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Logout from a registry")
|
||||
|
||||
@Argument(help: "host")
|
||||
var host: String
|
||||
|
||||
func run() async throws {
|
||||
try KeychainCredentialsProvider().remove(host: host)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import Sentry
|
||||
import SwiftUI
|
||||
import SwiftDate
|
||||
|
||||
struct Prune: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs")
|
||||
|
||||
@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.",
|
||||
valueName: "n"))
|
||||
var olderThan: UInt?
|
||||
|
||||
@Option(help: .hidden)
|
||||
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)
|
||||
var gc: Bool = false
|
||||
|
||||
mutating func validate() throws {
|
||||
// --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")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
if 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
|
||||
if let olderThan = olderThan {
|
||||
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
||||
let olderThanDate = Date() - olderThanInterval
|
||||
|
||||
try Prune.pruneOlderThan(prunableStorages: prunableStorages, olderThanDate: olderThanDate)
|
||||
}
|
||||
|
||||
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
||||
if let spaceBudget = spaceBudget {
|
||||
try Prune.pruneSpaceBudget(prunableStorages: prunableStorages, spaceBudgetBytes: UInt64(spaceBudget) * 1024 * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
|
||||
static func pruneOlderThan(prunableStorages: [PrunableStorage], olderThanDate: Date) throws {
|
||||
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
|
||||
|
||||
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
|
||||
}
|
||||
|
||||
static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
|
||||
let prunables: [Prunable] = try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() > $1.accessDate() }
|
||||
|
||||
var spaceBudgetBytes = spaceBudgetBytes
|
||||
var prunablesToDelete: [Prunable] = []
|
||||
|
||||
for prunable in prunables {
|
||||
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
|
||||
|
||||
if prunableSizeBytes <= spaceBudgetBytes {
|
||||
// Don't mark for deletion as
|
||||
// there's a budget available
|
||||
spaceBudgetBytes -= prunableSizeBytes
|
||||
} else {
|
||||
// Mark for deletion
|
||||
prunablesToDelete.append(prunable)
|
||||
}
|
||||
}
|
||||
|
||||
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 prunables: [Prunable] = try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||
|
||||
// Does it even make sense to start?
|
||||
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
||||
if cacheUsedBytes < reclaimBytes {
|
||||
return
|
||||
}
|
||||
|
||||
var cacheReclaimedBytes: Int = 0
|
||||
|
||||
var it = prunables.makeIterator()
|
||||
|
||||
while cacheReclaimedBytes <= reclaimBytes {
|
||||
guard let prunable = it.next() else {
|
||||
break
|
||||
}
|
||||
|
||||
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
|
||||
// do not prune the initiator
|
||||
continue
|
||||
}
|
||||
|
||||
try SentrySDK.span?.setData(value: prunable.sizeBytes(), key: prunable.url.path)
|
||||
|
||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||
|
||||
try prunable.delete()
|
||||
}
|
||||
|
||||
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
|
||||
struct Pull: AsyncParsableCommand {
|
||||
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")
|
||||
var remoteName: String
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
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 {
|
||||
// Be more liberal when accepting local image as argument,
|
||||
// see https://github.com/cirruslabs/tart/issues/36
|
||||
if VMStorageLocal().exists(remoteName) {
|
||||
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let remoteName = try RemoteName(remoteName)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
|
||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import Foundation
|
||||
import Compression
|
||||
|
||||
struct Push: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
|
||||
|
||||
@Argument(help: "local or remote VM name")
|
||||
var localName: String
|
||||
|
||||
@Argument(help: "remote VM name(s)")
|
||||
var remoteNames: [String]
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
|
||||
discussion: """
|
||||
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
|
||||
For example, AWS Elastic Container Registry supports only chunks larger than 5MB but GitHub Container Registry supports only chunks smaller than 4MB. Google Container Registry on the other hand doesn't support chunked uploads at all.
|
||||
Please refer to the documentation of your particular registry in order to see if this option is suitable for you and what's the recommended chunk size.
|
||||
"""))
|
||||
var chunkSize: Int = 0
|
||||
|
||||
@Option(help: .hidden)
|
||||
var diskFormat: String = "v2"
|
||||
|
||||
@Flag(help: ArgumentHelp("cache pushed images locally",
|
||||
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
|
||||
var populateCache: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
let ociStorage = VMStorageOCI()
|
||||
let localVMDir = try VMStorageHelper.open(localName)
|
||||
|
||||
// Parse remote names supplied by the user
|
||||
let remoteNames = try remoteNames.map{
|
||||
try RemoteName($0)
|
||||
}
|
||||
|
||||
// Group remote names by registry
|
||||
struct RegistryIdentifier: Hashable, Equatable {
|
||||
var host: String
|
||||
var namespace: String
|
||||
}
|
||||
|
||||
let registryGroups = Dictionary(grouping: remoteNames, by: {
|
||||
RegistryIdentifier(host: $0.host, namespace: $0.namespace)
|
||||
})
|
||||
|
||||
// Push VM
|
||||
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
||||
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
|
||||
insecure: insecure)
|
||||
|
||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||
|
||||
let references = remoteNamesForRegistry.map{ $0.reference.value }
|
||||
|
||||
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)
|
||||
if populateCache {
|
||||
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
||||
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
||||
}
|
||||
}
|
||||
|
||||
// link the rest remote names
|
||||
if populateCache {
|
||||
for remoteName in remoteNamesForRegistry {
|
||||
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lightweightPushToRegistry(registry: Registry, remoteName: RemoteName, references: [String]) async throws -> RemoteName {
|
||||
// Is the local OCI VM already present in the registry?
|
||||
let digest = try VMStorageOCI().digest(remoteName)
|
||||
|
||||
let (remoteManifest, _) = try await registry.pullManifest(reference: digest)
|
||||
|
||||
// Overwrite registry's references with the retrieved manifest
|
||||
for reference in references {
|
||||
defaultLogger.appendNewLine("pushing manifest for \(reference)...")
|
||||
|
||||
_ = try await registry.pushManifest(reference: reference, manifest: remoteManifest)
|
||||
}
|
||||
|
||||
return RemoteName(host: registry.host!, namespace: registry.namespace,
|
||||
reference: Reference(digest: digest))
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection where Element == RemoteName {
|
||||
func referenceNames() -> String {
|
||||
let references = self.map{ $0.reference.fullyQualified }
|
||||
|
||||
switch count {
|
||||
case 0: return "∅"
|
||||
case 1: return references.first!
|
||||
default: return "{" + references.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
import ArgumentParser
|
||||
import Cocoa
|
||||
import Darwin
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
import Sentry
|
||||
|
||||
var vm: VM?
|
||||
|
||||
struct IPNotFound: Error {
|
||||
}
|
||||
|
||||
struct Run: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Run a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Don't open a UI window.",
|
||||
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
|
||||
|
||||
@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")
|
||||
var recovery: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Use screen sharing instead of the built-in UI.",
|
||||
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
|
||||
+ "Note that Remote Login option should be enabled inside the VM."))
|
||||
var vnc: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Use Virtualization.Framework's VNC server instead of the build-in UI.",
|
||||
discussion: "Useful since this type of VNC is available in recovery mode and in macOS installation.\n"
|
||||
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
||||
var vncExperimental: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\" --disk=\"/dev/disk0\")
|
||||
""", 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:
|
||||
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]"))
|
||||
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("""
|
||||
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")
|
||||
""", discussion: """
|
||||
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".
|
||||
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] = []
|
||||
|
||||
@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 {
|
||||
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
|
||||
func run() async throws {
|
||||
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(
|
||||
vmDir: vmDir,
|
||||
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||
additionalStorageDevices: additionalDiskAttachments,
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
if vnc {
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
return ScreenSharingVNC(vmConfig: vmConfig)
|
||||
} else if vncExperimental {
|
||||
return FullFledgedVNC(virtualMachine: vm!.virtualMachine)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
let vncURL = try await vncImpl.waitForURL()
|
||||
|
||||
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
|
||||
print("VNC server is running at \(vncURL)")
|
||||
} else {
|
||||
print("Opening \(vncURL)...")
|
||||
NSWorkspace.shared.open(vncURL)
|
||||
}
|
||||
}
|
||||
|
||||
try await vm!.run()
|
||||
|
||||
if let vncImpl = vncImpl {
|
||||
try vncImpl.stop()
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
// Capture the error into Sentry
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
|
||||
fputs("\(error)\n", stderr)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// "tart stop" support
|
||||
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||
sigintSrc.setEventHandler {
|
||||
task.cancel()
|
||||
}
|
||||
sigintSrc.activate()
|
||||
|
||||
// "tart suspend" / UI window closing support
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private func createSerialPortConfiguration(_ tty_read: FileHandle, _ tty_write: FileHandle) -> VZVirtioConsoleDeviceSerialPortConfiguration {
|
||||
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 expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
|
||||
|
||||
for rawDisk in expandedDiskPaths {
|
||||
let diskReadOnly = rawDisk.hasSuffix(readOnlySuffix)
|
||||
let diskPath = diskReadOnly ? String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count)) : rawDisk
|
||||
let diskURL = URL(fileURLWithPath: diskPath)
|
||||
|
||||
// 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 {
|
||||
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
|
||||
// see https://github.com/cirruslabs/tart/issues/323 for more details.
|
||||
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
|
||||
}
|
||||
|
||||
func directoryShares() throws -> [VZDirectorySharingDeviceConfiguration] {
|
||||
if dir.isEmpty {
|
||||
return []
|
||||
}
|
||||
|
||||
guard #available(macOS 13, *) else {
|
||||
throw UnsupportedOSError("directory sharing", "is")
|
||||
}
|
||||
|
||||
var directoryShares: [DirectoryShare] = []
|
||||
|
||||
var allNamedShares = true
|
||||
for rawDir in dir {
|
||||
let directoryShare = try DirectoryShare(parseFrom: rawDir)
|
||||
if (directoryShare.name == nil) {
|
||||
allNamedShares = false
|
||||
}
|
||||
directoryShares.append(directoryShare)
|
||||
}
|
||||
|
||||
|
||||
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
||||
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
|
||||
if allNamedShares {
|
||||
var directories: [String : VZSharedDirectory] = Dictionary()
|
||||
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
|
||||
}
|
||||
|
||||
return [sharingDevice]
|
||||
}
|
||||
|
||||
private func rosettaDirectoryShare() throws -> [VZDirectorySharingDeviceConfiguration] {
|
||||
guard let rosettaTag = rosettaTag else {
|
||||
return []
|
||||
}
|
||||
|
||||
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
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
|
||||
nsApp.applicationIconImage = NSImage(data: AppIconData)
|
||||
|
||||
struct MainApp: App {
|
||||
static var disappearSignal: Int32 = SIGINT
|
||||
static var capturesSystemKeys: Bool = false
|
||||
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
Group {
|
||||
VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}.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)
|
||||
}
|
||||
}
|
||||
}.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 {
|
||||
// Remove some standard menu options
|
||||
CommandGroup(replacing: .help, addition: {})
|
||||
CommandGroup(replacing: .newItem, addition: {})
|
||||
CommandGroup(replacing: .pasteboard, addition: {})
|
||||
CommandGroup(replacing: .textEditing, addition: {})
|
||||
CommandGroup(replacing: .undoRedo, addition: {})
|
||||
CommandGroup(replacing: .windowSize, addition: {})
|
||||
// Replace some standard menu options
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
Button("About Tart") {
|
||||
NSApplication.shared.orderFrontStandardAboutPanel(options: [
|
||||
NSApplication.AboutPanelOptionKey.applicationIcon: NSApplication.shared.applicationIconImage as Any,
|
||||
NSApplication.AboutPanelOptionKey.applicationName: "Tart",
|
||||
NSApplication.AboutPanelOptionKey.applicationVersion: CI.version,
|
||||
NSApplication.AboutPanelOptionKey.credits: credits,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VMView: NSViewRepresentable {
|
||||
typealias NSViewType = VZVirtualMachineView
|
||||
|
||||
@ObservedObject var vm: VM
|
||||
var capturesSystemKeys: Bool
|
||||
|
||||
func makeNSView(context: Context) -> NSViewType {
|
||||
let machineView = VZVirtualMachineView()
|
||||
|
||||
machineView.capturesSystemKeys = capturesSystemKeys
|
||||
|
||||
// Enable automatic display reconfiguration
|
||||
// for guests that support it
|
||||
if #available(macOS 14.0, *) {
|
||||
machineView.automaticallyReconfiguresDisplay = true
|
||||
}
|
||||
|
||||
return machineView
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSViewType, context: Context) {
|
||||
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) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Set: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@Option(help: "Number of VM CPUs")
|
||||
var cpu: UInt16?
|
||||
|
||||
@Option(help: "VM memory size in megabytes")
|
||||
var memory: UInt64?
|
||||
|
||||
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||
var display: VMDisplayConfig?
|
||||
|
||||
@Option(help: .hidden)
|
||||
var diskSize: UInt16?
|
||||
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
|
||||
if let cpu = cpu {
|
||||
try vmConfig.setCPU(cpuCount: Int(cpu))
|
||||
}
|
||||
|
||||
if let memory = memory {
|
||||
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
|
||||
}
|
||||
|
||||
if let display = display {
|
||||
if (display.width > 0) {
|
||||
vmConfig.display.width = display.width
|
||||
}
|
||||
if (display.height > 0) {
|
||||
vmConfig.display.height = display.height
|
||||
}
|
||||
}
|
||||
|
||||
try vmConfig.save(toURL: vmDir.configURL)
|
||||
|
||||
if diskSize != nil {
|
||||
try vmDir.resizeDisk(diskSize!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VMDisplayConfig: ExpressibleByArgument {
|
||||
public init(argument: String) {
|
||||
let parts = argument.components(separatedBy: "x").map {
|
||||
Int($0) ?? 0
|
||||
}
|
||||
self = VMDisplayConfig(
|
||||
width: parts[safe: 0] ?? 0,
|
||||
height: parts[safe: 1] ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
struct Config {
|
||||
let tartHomeDir: URL
|
||||
let tartCacheDir: URL
|
||||
let tartTmpDir: URL
|
||||
|
||||
init() throws {
|
||||
var tartHomeDir: URL
|
||||
|
||||
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
|
||||
tartHomeDir = URL(fileURLWithPath: customTartHome)
|
||||
} else {
|
||||
tartHomeDir = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".tart", isDirectory: true)
|
||||
}
|
||||
self.tartHomeDir = tartHomeDir
|
||||
|
||||
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: 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 {
|
||||
let encoder = JSONEncoder()
|
||||
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
|
||||
return encoder
|
||||
}
|
||||
|
||||
static func jsonDecoder() -> JSONDecoder {
|
||||
JSONDecoder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
enum CredentialsProviderError: Error {
|
||||
case Failed(message: String)
|
||||
}
|
||||
|
||||
protocol CredentialsProvider {
|
||||
func retrieve(host: String) throws -> (String, String)?
|
||||
func store(host: String, user: String, password: String) throws
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
|
||||
class DockerConfigCredentialsProvider: CredentialsProvider {
|
||||
func retrieve(host: String) throws -> (String, String)? {
|
||||
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
|
||||
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
|
||||
return nil
|
||||
}
|
||||
let config = try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: dockerConfigURL))
|
||||
|
||||
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
|
||||
return credentialsFromAuth
|
||||
}
|
||||
if let helperProgram = try config.findCredHelper(host: host) {
|
||||
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func executeHelper(binaryName: String, host: String) throws -> (String, String)? {
|
||||
guard let executableURL = resolveBinaryPath(binaryName) else {
|
||||
throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH")
|
||||
}
|
||||
|
||||
let process = Process.init()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = ["get"]
|
||||
|
||||
let outPipe = Pipe()
|
||||
let inPipe = Pipe()
|
||||
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = outPipe
|
||||
process.standardInput = inPipe
|
||||
|
||||
process.launch()
|
||||
|
||||
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
||||
inPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
let outputData = try outPipe.fileHandleForReading.readToEnd()
|
||||
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!")
|
||||
}
|
||||
if outputData == nil || outputData?.count == 0 {
|
||||
throw CredentialsProviderError.Failed(message: "Docker helper output is empty!")
|
||||
}
|
||||
|
||||
let getOutput = try JSONDecoder().decode(DockerGetOutput.self, from: outputData!)
|
||||
return (getOutput.Username, getOutput.Secret)
|
||||
}
|
||||
|
||||
func store(host: String, user: String, password: String) throws {
|
||||
throw CredentialsProviderError.Failed(message: "Docker helpers don't support storing!")
|
||||
}
|
||||
}
|
||||
|
||||
struct DockerConfig: Codable {
|
||||
var auths: Dictionary<String, DockerAuthConfig>? = 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 {
|
||||
var auth: String? = nil
|
||||
|
||||
func decodeCredentials() -> (String, String)? {
|
||||
// auth is a base64("username:password")
|
||||
guard let authBase64 = auth else {
|
||||
return nil
|
||||
}
|
||||
guard let data = Data(base64Encoded: authBase64) else {
|
||||
return nil
|
||||
}
|
||||
guard let components = String(data: data, encoding: .utf8)?.components(separatedBy: ":") else {
|
||||
return nil
|
||||
}
|
||||
if components.count != 2 {
|
||||
return nil
|
||||
}
|
||||
return (components[0], components[1])
|
||||
}
|
||||
}
|
||||
|
||||
struct DockerGetOutput: Codable {
|
||||
var Username: String
|
||||
var Secret: String
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
class KeychainCredentialsProvider: CredentialsProvider {
|
||||
func retrieve(host: String) throws -> (String, String)? {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
||||
kSecAttrServer as String: host,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecReturnData as String: true,
|
||||
kSecAttrLabel as String: "Tart Credentials",
|
||||
]
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
|
||||
if status != errSecSuccess {
|
||||
if status == errSecItemNotFound {
|
||||
return nil
|
||||
}
|
||||
|
||||
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
|
||||
}
|
||||
|
||||
guard let item = item as? [String: Any],
|
||||
let user = item[kSecAttrAccount as String] as? String,
|
||||
let passwordData = item[kSecValueData as String] as? Data,
|
||||
let password = String(data: passwordData, encoding: .utf8)
|
||||
else {
|
||||
throw CredentialsProviderError.Failed(message: "Keychain item has unexpected format")
|
||||
}
|
||||
|
||||
return (user, password)
|
||||
}
|
||||
|
||||
func store(host: String, user: String, password: String) throws {
|
||||
let passwordData = password.data(using: .utf8)
|
||||
let key: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
||||
kSecAttrServer as String: host,
|
||||
kSecAttrLabel as String: "Tart Credentials",
|
||||
]
|
||||
let value: [String: Any] = [kSecAttrAccount as String: user,
|
||||
kSecValueData as String: passwordData,
|
||||
]
|
||||
|
||||
let status = SecItemCopyMatching(key as CFDictionary, nil)
|
||||
|
||||
switch status {
|
||||
case errSecItemNotFound:
|
||||
let status = SecItemAdd(key.merging(value) { (current, _) in current } as CFDictionary, nil)
|
||||
if status != errSecSuccess {
|
||||
throw CredentialsProviderError.Failed(message: "Keychain failed to add item: \(status.explanation())")
|
||||
}
|
||||
case errSecSuccess:
|
||||
let status = SecItemUpdate(key as CFDictionary, value as CFDictionary)
|
||||
if status != errSecSuccess {
|
||||
throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
|
||||
}
|
||||
default:
|
||||
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 {
|
||||
func explanation() -> CFString {
|
||||
SecCopyErrorMessageString(self, nil) ?? "Unknown status code \(self)." as CFString
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
enum StdinCredentialsError: Error {
|
||||
case CredentialRequired(which: String)
|
||||
case CredentialTooLong(message: String)
|
||||
}
|
||||
|
||||
class StdinCredentials {
|
||||
static func retrieve() throws -> (String, String) {
|
||||
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
|
||||
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
|
||||
|
||||
return (user, password)
|
||||
}
|
||||
|
||||
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 */)
|
||||
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
|
||||
throw StdinCredentialsError.CredentialRequired(which: name)
|
||||
}
|
||||
|
||||
let credential = String(cString: rawCredential).trimmingCharacters(in: .newlines)
|
||||
|
||||
if credential.count > maxCharacters {
|
||||
throw StdinCredentialsError.CredentialTooLong(
|
||||
message: "\(name) should contain no more than \(maxCharacters) characters")
|
||||
}
|
||||
|
||||
return credential
|
||||
}
|
||||
}
|
||||
@@ -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,48 @@
|
||||
import Foundation
|
||||
import System
|
||||
|
||||
enum FileLockError: Error, Equatable {
|
||||
case Failed(_ message: String)
|
||||
case AlreadyLocked
|
||||
}
|
||||
|
||||
class FileLock {
|
||||
let url: URL
|
||||
let fd: Int32
|
||||
|
||||
init(lockURL: URL) throws {
|
||||
url = lockURL
|
||||
fd = open(lockURL.path, 0)
|
||||
}
|
||||
|
||||
deinit {
|
||||
close(fd)
|
||||
}
|
||||
|
||||
func trylock() throws -> Bool {
|
||||
try flockWrapper(LOCK_EX | LOCK_NB)
|
||||
}
|
||||
|
||||
func lock() throws {
|
||||
_ = try flockWrapper(LOCK_EX)
|
||||
}
|
||||
|
||||
func unlock() throws {
|
||||
_ = try flockWrapper(LOCK_UN)
|
||||
}
|
||||
|
||||
func flockWrapper(_ operation: Int32) throws -> Bool {
|
||||
let ret = flock(fd, operation)
|
||||
if ret != 0 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
if (operation & LOCK_NB) != 0 && details == .wouldBlock {
|
||||
return false
|
||||
}
|
||||
|
||||
throw FileLockError.Failed("failed to lock \(url): \(details)")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
class IPSWCache: PrunableStorage {
|
||||
let baseURL: URL
|
||||
|
||||
init() throws {
|
||||
baseURL = try Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func locationFor(fileName: String) -> URL {
|
||||
baseURL.appendingPathComponent(fileName, isDirectory: false)
|
||||
}
|
||||
|
||||
func prunables() throws -> [Prunable] {
|
||||
try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)
|
||||
.filter { $0.lastPathComponent.hasSuffix(".ipsw")}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
public protocol Logger {
|
||||
func appendNewLine(_ line: String) -> Void
|
||||
func updateLastLine(_ line: String) -> Void
|
||||
}
|
||||
|
||||
var defaultLogger: Logger = {
|
||||
if ProcessInfo.processInfo.environment["CI"] != nil {
|
||||
return SimpleConsoleLogger()
|
||||
} else {
|
||||
return InteractiveConsoleLogger()
|
||||
}
|
||||
}()
|
||||
|
||||
public class InteractiveConsoleLogger: Logger {
|
||||
private let eraseCursorDown = "\u{001B}[J" // clear entire line
|
||||
private let moveUp = "\u{001B}[1A" // move one line up
|
||||
private let moveBeginningOfLine = "\r" //
|
||||
|
||||
public init() {
|
||||
|
||||
}
|
||||
|
||||
public func appendNewLine(_ line: String) {
|
||||
print(line, terminator: "\n")
|
||||
}
|
||||
|
||||
public func updateLastLine(_ line: String) {
|
||||
print(moveUp, moveBeginningOfLine, eraseCursorDown, line, separator: "", terminator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
public class SimpleConsoleLogger: Logger {
|
||||
public init() {
|
||||
|
||||
}
|
||||
|
||||
public func appendNewLine(_ line: String) {
|
||||
print(line, terminator: "\n")
|
||||
}
|
||||
|
||||
public func updateLastLine(_ line: String) {
|
||||
print(line, terminator: "\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
public class ProgressObserver: NSObject {
|
||||
@objc var progressToObserve: Progress
|
||||
var observation: NSKeyValueObservation?
|
||||
var lastTimeUpdated = Date.now
|
||||
|
||||
public init(_ progress: Progress) {
|
||||
progressToObserve = progress
|
||||
}
|
||||
|
||||
func log(_ renderer: Logger) {
|
||||
renderer.appendNewLine(ProgressObserver.lineToRender(progressToObserve))
|
||||
observation = observe(\.progressToObserve.fractionCompleted) { progress, _ in
|
||||
let currentTime = Date.now
|
||||
if self.progressToObserve.isFinished || currentTime.timeIntervalSince(self.lastTimeUpdated) >= 1.0 {
|
||||
self.lastTimeUpdated = currentTime
|
||||
renderer.updateLastLine(ProgressObserver.lineToRender(self.progressToObserve))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func lineToRender(_ progress: Progress) -> String {
|
||||
String(Int(100 * progress.fractionCompleted)) + "%"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
public class URLSessionLogger: NSObject, URLSessionTaskDelegate {
|
||||
let renderer: Logger
|
||||
|
||||
public init(_ renderer: Logger) {
|
||||
self.renderer = renderer
|
||||
}
|
||||
|
||||
public func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
|
||||
renderer.updateLastLine(URLSessionLogger.lineToRender(task.progress))
|
||||
}
|
||||
|
||||
private static func lineToRender(_ progress: Progress) -> String {
|
||||
String(100 * progress.completedUnitCount / progress.totalUnitCount) + "%"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Virtualization
|
||||
|
||||
struct ARPCommandFailedError: Error, CustomStringConvertible {
|
||||
var terminationReason: Process.TerminationReason
|
||||
var terminationStatus: Int32
|
||||
|
||||
var description: String {
|
||||
var reason: String
|
||||
|
||||
switch terminationReason {
|
||||
case .exit:
|
||||
reason = "exit code \(terminationStatus)"
|
||||
case .uncaughtSignal:
|
||||
reason = "uncaught signal"
|
||||
default:
|
||||
reason = "unknown reason"
|
||||
}
|
||||
|
||||
return "arp command failed: \(reason)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
|
||||
var explanation: String
|
||||
|
||||
var description: String {
|
||||
"arp command yielded invalid output: \(explanation)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCacheInternalError: Error, CustomStringConvertible {
|
||||
var explanation: String
|
||||
|
||||
var description: String {
|
||||
"ARPCache internal error: \(explanation)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCache {
|
||||
let arpCommandOutput: Data
|
||||
|
||||
init() throws {
|
||||
let process = Process.init()
|
||||
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
||||
process.arguments = ["-an"]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
throw ARPCommandFailedError(
|
||||
terminationReason: process.terminationReason,
|
||||
terminationStatus: process.terminationStatus)
|
||||
}
|
||||
|
||||
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||
}
|
||||
|
||||
self.arpCommandOutput = arpCommandOutput
|
||||
}
|
||||
|
||||
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||
let lines = String(decoding: arpCommandOutput, as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: "\n")
|
||||
|
||||
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
|
||||
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
|
||||
|
||||
for line in lines {
|
||||
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
|
||||
|
||||
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
|
||||
}
|
||||
|
||||
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
|
||||
guard let ip = IPv4Address(rawIP) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
|
||||
}
|
||||
|
||||
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
|
||||
if rawMAC == "(incomplete)" {
|
||||
continue
|
||||
}
|
||||
guard let mac = MACAddress(fromString: rawMAC) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
|
||||
}
|
||||
|
||||
if macAddress == mac {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension NSTextCheckingResult {
|
||||
func getCaptureGroup(name: String, for string: String) throws -> String {
|
||||
let nsRange = self.range(withName: name)
|
||||
|
||||
if nsRange.location == NSNotFound {
|
||||
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
|
||||
}
|
||||
|
||||
guard let range = Range.init(nsRange, in: string) else {
|
||||
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
|
||||
}
|
||||
|
||||
return String(string[range])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import SwiftRadix
|
||||
|
||||
struct Lease {
|
||||
var mac: MACAddress
|
||||
var ip: IPv4Address
|
||||
var expiresAt: Date
|
||||
|
||||
init?(fromRawLease: [String : String]) {
|
||||
// Retrieve the required fields
|
||||
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
||||
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
||||
guard let lease = fromRawLease["lease"] else { return nil }
|
||||
|
||||
// Parse MAC address
|
||||
let hwAddressSplits = hwAddress.split(separator: ",")
|
||||
if hwAddressSplits.count != 2 {
|
||||
return nil
|
||||
}
|
||||
if let hwAddressProto = Int(hwAddressSplits[0]), hwAddressProto != ARPHRD_ETHER {
|
||||
return nil
|
||||
}
|
||||
guard let mac = MACAddress(fromString: String(hwAddressSplits[1])) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse IP address
|
||||
guard let ip = IPv4Address(ipAddress) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse expiration timestamp
|
||||
guard let leaseTimestamp = lease.hex?.value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.ip = ip
|
||||
self.mac = mac
|
||||
self.expiresAt = Date(timeIntervalSince1970: TimeInterval(leaseTimestamp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
enum LeasesError: Error {
|
||||
case UnexpectedFormat(name: String = "unexpected DHCPD leases file format", message: String, line: Int)
|
||||
case Truncated(name: String = "truncated DHCPD leases file")
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
|
||||
case .UnexpectedFormat(name: let name, message: let message, line: let line):
|
||||
return "\(name) on line \(line): \(message)"
|
||||
case .Truncated(name: let name):
|
||||
return "\(name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Leases {
|
||||
private let leases: [MACAddress : Lease]
|
||||
|
||||
convenience init?() throws {
|
||||
try self.init(URL(fileURLWithPath: "/var/db/dhcpd_leases"))
|
||||
}
|
||||
|
||||
convenience init?(_ fromURL: URL) throws {
|
||||
do {
|
||||
let urlContents = try String(contentsOf: fromURL, encoding: .utf8)
|
||||
try self.init(urlContents)
|
||||
} catch {
|
||||
if error.isFileNotFound() {
|
||||
return nil
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
init(_ fromString: String) throws {
|
||||
let leases = try Self.retrieveRawLeases(fromString).compactMap({ rawLease in
|
||||
Lease(fromRawLease: rawLease)
|
||||
}).filter({ lease in
|
||||
lease.expiresAt.isInFuture
|
||||
}).map({ lease in
|
||||
(lease.mac, lease)
|
||||
})
|
||||
|
||||
self.leases = Dictionary(uniqueKeysWithValues: leases)
|
||||
}
|
||||
|
||||
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
|
||||
///
|
||||
/// [1]: https://github.com/apple-opensource/bootp/blob/master/bootplib/NICache.c#L285-L391
|
||||
private static func retrieveRawLeases(_ dhcpdLeasesContents: String) throws -> [[String : String]] {
|
||||
var rawLeases: [[String : String]] = Array()
|
||||
|
||||
enum State {
|
||||
case Nowhere
|
||||
case Start
|
||||
case Body
|
||||
case End
|
||||
}
|
||||
var state = State.Nowhere
|
||||
|
||||
var currentRawLease: [String : String] = Dictionary()
|
||||
|
||||
for (lineNumber, line) in dhcpdLeasesContents.split(separator: "\n").enumerated().map({ ($0 + 1, $1) }) {
|
||||
if line == "{" {
|
||||
// Handle lease block start
|
||||
if state != .Nowhere && state != .End {
|
||||
throw LeasesError.UnexpectedFormat(message: "unexpected lease block start ({)", line: lineNumber)
|
||||
}
|
||||
|
||||
state = .Start
|
||||
} else if line == "}" {
|
||||
// Handle lease block end
|
||||
if state != .Body {
|
||||
throw LeasesError.UnexpectedFormat(message: "unexpected lease block end (})", line: lineNumber)
|
||||
}
|
||||
|
||||
rawLeases.append(currentRawLease)
|
||||
currentRawLease = Dictionary()
|
||||
|
||||
state = .End
|
||||
} else {
|
||||
// Handle lease block contents
|
||||
let lineWithoutTabs = String(line.drop { $0 == " " || $0 == "\t"})
|
||||
|
||||
if lineWithoutTabs.isEmpty {
|
||||
continue
|
||||
}
|
||||
|
||||
let splits = lineWithoutTabs.split(separator: "=", maxSplits: 1)
|
||||
if splits.count != 2 {
|
||||
throw LeasesError.UnexpectedFormat(message: "key-value pair with only a key", line: lineNumber)
|
||||
}
|
||||
let (key, value) = (String(splits[0]), String(splits[1]))
|
||||
|
||||
currentRawLease[key] = value
|
||||
|
||||
state = .Body
|
||||
}
|
||||
}
|
||||
|
||||
if state == .Start || state == .Body {
|
||||
throw LeasesError.Truncated()
|
||||
}
|
||||
|
||||
return rawLeases
|
||||
}
|
||||
|
||||
func ResolveMACAddress(macAddress: MACAddress) -> IPv4Address? {
|
||||
leases[macAddress]?.ip
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
struct MACAddress: Equatable, Hashable, CustomStringConvertible {
|
||||
var mac: [UInt8] = Array(repeating: 0, count: 6)
|
||||
|
||||
init?(fromString: String) {
|
||||
let components = fromString.components(separatedBy: ":")
|
||||
|
||||
if components.count != 6 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for (index, component) in components.enumerated() {
|
||||
mac[index] = UInt8(component, radix: 16)!
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
||||
}
|
||||
}
|
||||
@@ -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,21 @@
|
||||
import Foundation
|
||||
|
||||
protocol Authentication {
|
||||
func header() -> (String, String)
|
||||
func isValid() -> Bool
|
||||
}
|
||||
|
||||
struct BasicAuthentication: Authentication {
|
||||
let user: String
|
||||
let password: String
|
||||
|
||||
func header() -> (String, String) {
|
||||
let creds = Data("\(user):\(password)".utf8).base64EncodedString()
|
||||
|
||||
return ("Authorization", "Basic \(creds)")
|
||||
}
|
||||
|
||||
func isValid() -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
enum DigestError: Error {
|
||||
case InvalidOffset
|
||||
case InvalidSize
|
||||
}
|
||||
|
||||
class Digest {
|
||||
var hash: SHA256 = SHA256()
|
||||
|
||||
func update(_ data: Data) {
|
||||
hash.update(data: data)
|
||||
}
|
||||
|
||||
func finalize() -> String {
|
||||
hash.finalize().hexdigest()
|
||||
}
|
||||
|
||||
static func hash(_ data: Data) -> String {
|
||||
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 {
|
||||
func hexdigest() -> String {
|
||||
"sha256:" + self.map {
|
||||
String(format: "%02x", $0)
|
||||
}
|
||||
.joined()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
|
||||
// OCI manifest and OCI config media types
|
||||
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
|
||||
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
|
||||
|
||||
// Layer media types
|
||||
let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
|
||||
let diskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
||||
let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2"
|
||||
let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
||||
|
||||
// Manifest annotations
|
||||
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 {
|
||||
var schemaVersion: Int = 2
|
||||
var mediaType: String = ociManifestMediaType
|
||||
var config: OCIManifestConfig
|
||||
var layers: [OCIManifestLayer] = Array()
|
||||
var annotations: Dictionary<String, String>?
|
||||
|
||||
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil, uploadDate: Date? = nil) {
|
||||
self.config = config
|
||||
self.layers = layers
|
||||
|
||||
var annotations: [String: String] = [:]
|
||||
|
||||
if let uncompressedDiskSize = uncompressedDiskSize {
|
||||
annotations[uncompressedDiskSizeAnnotation] = String(uncompressedDiskSize)
|
||||
}
|
||||
|
||||
if let uploadDate = uploadDate {
|
||||
annotations[uploadTimeAnnotation] = uploadDate.toISO()
|
||||
}
|
||||
|
||||
self.annotations = annotations
|
||||
}
|
||||
|
||||
init(fromJSON: Data) throws {
|
||||
self = try Config.jsonDecoder().decode(Self.self, from: fromJSON)
|
||||
}
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
}
|
||||
|
||||
func digest() throws -> String {
|
||||
try Digest.hash(toJSON())
|
||||
}
|
||||
|
||||
func uncompressedDiskSize() -> UInt64? {
|
||||
guard let value = annotations?[uncompressedDiskSizeAnnotation] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return UInt64(value)
|
||||
}
|
||||
}
|
||||
|
||||
struct OCIConfig: Codable {
|
||||
var architecture: Architecture = .arm64
|
||||
var os: OS = .darwin
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
}
|
||||
}
|
||||
|
||||
struct OCIManifestConfig: Codable, Equatable {
|
||||
var mediaType: String = ociConfigMediaType
|
||||
var size: Int
|
||||
var digest: String
|
||||
}
|
||||
|
||||
struct OCIManifestLayer: Codable, Equatable {
|
||||
var mediaType: String
|
||||
var size: Int
|
||||
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 {
|
||||
var size: Int
|
||||
var digest: String
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
token literal names:
|
||||
null
|
||||
':'
|
||||
'/'
|
||||
'.'
|
||||
'-'
|
||||
'@'
|
||||
'_'
|
||||
null
|
||||
null
|
||||
|
||||
token symbolic names:
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
DIGIT
|
||||
LETTER
|
||||
|
||||
rule names:
|
||||
root
|
||||
host
|
||||
port
|
||||
host_component
|
||||
namespace
|
||||
namespace_component
|
||||
reference
|
||||
tag
|
||||
separator
|
||||
name
|
||||
|
||||
|
||||
atn:
|
||||
[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]
|
||||
@@ -0,0 +1,14 @@
|
||||
T__0=1
|
||||
T__1=2
|
||||
T__2=3
|
||||
T__3=4
|
||||
T__4=5
|
||||
T__5=6
|
||||
DIGIT=7
|
||||
LETTER=8
|
||||
':'=1
|
||||
'/'=2
|
||||
'.'=3
|
||||
'-'=4
|
||||
'@'=5
|
||||
'_'=6
|
||||
@@ -0,0 +1,167 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
|
||||
import Antlr4
|
||||
|
||||
|
||||
/**
|
||||
* This class provides an empty implementation of {@link ReferenceListener},
|
||||
* which can be extended to create a listener which only needs to handle a subset
|
||||
* of the available methods.
|
||||
*/
|
||||
open class ReferenceBaseListener: ReferenceListener {
|
||||
public init() { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterRoot(_ ctx: ReferenceParser.RootContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitRoot(_ ctx: ReferenceParser.RootContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterHost(_ ctx: ReferenceParser.HostContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitHost(_ ctx: ReferenceParser.HostContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterPort(_ ctx: ReferenceParser.PortContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
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}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterNamespace(_ ctx: ReferenceParser.NamespaceContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitNamespace(_ ctx: ReferenceParser.NamespaceContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterReference(_ ctx: ReferenceParser.ReferenceContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitReference(_ ctx: ReferenceParser.ReferenceContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterTag(_ ctx: ReferenceParser.TagContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitTag(_ ctx: ReferenceParser.TagContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterSeparator(_ ctx: ReferenceParser.SeparatorContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitSeparator(_ ctx: ReferenceParser.SeparatorContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterName(_ ctx: ReferenceParser.NameContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitName(_ ctx: ReferenceParser.NameContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterEveryRule(_ ctx: ParserRuleContext) throws { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitEveryRule(_ ctx: ParserRuleContext) throws { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func visitTerminal(_ node: TerminalNode) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func visitErrorNode(_ node: ErrorNode) { }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
token literal names:
|
||||
null
|
||||
':'
|
||||
'/'
|
||||
'.'
|
||||
'-'
|
||||
'@'
|
||||
'_'
|
||||
null
|
||||
null
|
||||
|
||||
token symbolic names:
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
DIGIT
|
||||
LETTER
|
||||
|
||||
rule names:
|
||||
T__0
|
||||
T__1
|
||||
T__2
|
||||
T__3
|
||||
T__4
|
||||
T__5
|
||||
DIGIT
|
||||
LETTER
|
||||
|
||||
channel names:
|
||||
DEFAULT_TOKEN_CHANNEL
|
||||
HIDDEN
|
||||
|
||||
mode names:
|
||||
DEFAULT_MODE
|
||||
|
||||
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, 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]
|
||||
@@ -0,0 +1,90 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
import Antlr4
|
||||
|
||||
open class ReferenceLexer: Lexer {
|
||||
|
||||
internal static var _decisionToDFA: [DFA] = {
|
||||
var decisionToDFA = [DFA]()
|
||||
let length = ReferenceLexer._ATN.getNumberOfDecisions()
|
||||
for i in 0..<length {
|
||||
decisionToDFA.append(DFA(ReferenceLexer._ATN.getDecisionState(i)!, i))
|
||||
}
|
||||
return decisionToDFA
|
||||
}()
|
||||
|
||||
internal static let _sharedContextCache = PredictionContextCache()
|
||||
|
||||
public
|
||||
static let T__0=1, T__1=2, T__2=3, T__3=4, T__4=5, T__5=6, DIGIT=7, LETTER=8
|
||||
|
||||
public
|
||||
static let channelNames: [String] = [
|
||||
"DEFAULT_TOKEN_CHANNEL", "HIDDEN"
|
||||
]
|
||||
|
||||
public
|
||||
static let modeNames: [String] = [
|
||||
"DEFAULT_MODE"
|
||||
]
|
||||
|
||||
public
|
||||
static let ruleNames: [String] = [
|
||||
"T__0", "T__1", "T__2", "T__3", "T__4", "T__5", "DIGIT", "LETTER"
|
||||
]
|
||||
|
||||
private static let _LITERAL_NAMES: [String?] = [
|
||||
nil, "':'", "'/'", "'.'", "'-'", "'@'", "'_'"
|
||||
]
|
||||
private static let _SYMBOLIC_NAMES: [String?] = [
|
||||
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
|
||||
]
|
||||
public
|
||||
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
||||
|
||||
|
||||
override open
|
||||
func getVocabulary() -> Vocabulary {
|
||||
return ReferenceLexer.VOCABULARY
|
||||
}
|
||||
|
||||
public
|
||||
required init(_ input: CharStream) {
|
||||
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
||||
super.init(input)
|
||||
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
|
||||
}
|
||||
|
||||
override open
|
||||
func getGrammarFileName() -> String { return "Reference.g4" }
|
||||
|
||||
override open
|
||||
func getRuleNames() -> [String] { return ReferenceLexer.ruleNames }
|
||||
|
||||
override open
|
||||
func getSerializedATN() -> [Int] { return ReferenceLexer._serializedATN }
|
||||
|
||||
override open
|
||||
func getChannelNames() -> [String] { return ReferenceLexer.channelNames }
|
||||
|
||||
override open
|
||||
func getModeNames() -> [String] { return ReferenceLexer.modeNames }
|
||||
|
||||
override open
|
||||
func getATN() -> ATN { return ReferenceLexer._ATN }
|
||||
|
||||
static let _serializedATN:[Int] = [
|
||||
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
|
||||
]
|
||||
|
||||
public
|
||||
static let _ATN: ATN = try! ATNDeserializer().deserialize(_serializedATN)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
T__0=1
|
||||
T__1=2
|
||||
T__2=3
|
||||
T__3=4
|
||||
T__4=5
|
||||
T__5=6
|
||||
DIGIT=7
|
||||
LETTER=8
|
||||
':'=1
|
||||
'/'=2
|
||||
'.'=3
|
||||
'-'=4
|
||||
'@'=5
|
||||
'_'=6
|
||||
@@ -0,0 +1,129 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
import Antlr4
|
||||
|
||||
/**
|
||||
* This interface defines a complete listener for a parse tree produced by
|
||||
* {@link ReferenceParser}.
|
||||
*/
|
||||
public protocol ReferenceListener: ParseTreeListener {
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#root}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterRoot(_ ctx: ReferenceParser.RootContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#root}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitRoot(_ ctx: ReferenceParser.RootContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#host}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterHost(_ ctx: ReferenceParser.HostContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#host}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitHost(_ ctx: ReferenceParser.HostContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#port}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterPort(_ ctx: ReferenceParser.PortContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#port}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
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}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterNamespace(_ ctx: ReferenceParser.NamespaceContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#namespace}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitNamespace(_ ctx: ReferenceParser.NamespaceContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#namespace_component}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#namespace_component}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#reference}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterReference(_ ctx: ReferenceParser.ReferenceContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#reference}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitReference(_ ctx: ReferenceParser.ReferenceContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#tag}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterTag(_ ctx: ReferenceParser.TagContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#tag}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitTag(_ ctx: ReferenceParser.TagContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#separator}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterSeparator(_ ctx: ReferenceParser.SeparatorContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#separator}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitSeparator(_ ctx: ReferenceParser.SeparatorContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#name}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterName(_ ctx: ReferenceParser.NameContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#name}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitName(_ ctx: ReferenceParser.NameContext)
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
import Antlr4
|
||||
|
||||
open class ReferenceParser: Parser {
|
||||
|
||||
internal static var _decisionToDFA: [DFA] = {
|
||||
var decisionToDFA = [DFA]()
|
||||
let length = ReferenceParser._ATN.getNumberOfDecisions()
|
||||
for i in 0..<length {
|
||||
decisionToDFA.append(DFA(ReferenceParser._ATN.getDecisionState(i)!, i))
|
||||
}
|
||||
return decisionToDFA
|
||||
}()
|
||||
|
||||
internal static let _sharedContextCache = PredictionContextCache()
|
||||
|
||||
public
|
||||
enum Tokens: Int {
|
||||
case EOF = -1, T__0 = 1, T__1 = 2, T__2 = 3, T__3 = 4, T__4 = 5, T__5 = 6,
|
||||
DIGIT = 7, LETTER = 8
|
||||
}
|
||||
|
||||
public
|
||||
static let RULE_root = 0, RULE_host = 1, RULE_port = 2, RULE_host_component = 3,
|
||||
RULE_namespace = 4, RULE_namespace_component = 5, RULE_reference = 6,
|
||||
RULE_tag = 7, RULE_separator = 8, RULE_name = 9
|
||||
|
||||
public
|
||||
static let ruleNames: [String] = [
|
||||
"root", "host", "port", "host_component", "namespace", "namespace_component",
|
||||
"reference", "tag", "separator", "name"
|
||||
]
|
||||
|
||||
private static let _LITERAL_NAMES: [String?] = [
|
||||
nil, "':'", "'/'", "'.'", "'-'", "'@'", "'_'"
|
||||
]
|
||||
private static let _SYMBOLIC_NAMES: [String?] = [
|
||||
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
|
||||
]
|
||||
public
|
||||
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
||||
|
||||
override open
|
||||
func getGrammarFileName() -> String { return "java-escape" }
|
||||
|
||||
override open
|
||||
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
|
||||
|
||||
override open
|
||||
func getSerializedATN() -> [Int] { return ReferenceParser._serializedATN }
|
||||
|
||||
override open
|
||||
func getATN() -> ATN { return ReferenceParser._ATN }
|
||||
|
||||
|
||||
override open
|
||||
func getVocabulary() -> Vocabulary {
|
||||
return ReferenceParser.VOCABULARY
|
||||
}
|
||||
|
||||
override public
|
||||
init(_ input:TokenStream) throws {
|
||||
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
||||
try super.init(input)
|
||||
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
|
||||
}
|
||||
|
||||
|
||||
public class RootContext: ParserRuleContext {
|
||||
open
|
||||
func host() -> HostContext? {
|
||||
return getRuleContext(HostContext.self, 0)
|
||||
}
|
||||
open
|
||||
func namespace() -> NamespaceContext? {
|
||||
return getRuleContext(NamespaceContext.self, 0)
|
||||
}
|
||||
open
|
||||
func EOF() -> TerminalNode? {
|
||||
return getToken(ReferenceParser.Tokens.EOF.rawValue, 0)
|
||||
}
|
||||
open
|
||||
func port() -> PortContext? {
|
||||
return getRuleContext(PortContext.self, 0)
|
||||
}
|
||||
open
|
||||
func reference() -> ReferenceContext? {
|
||||
return getRuleContext(ReferenceContext.self, 0)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_root
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterRoot(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitRoot(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func root() throws -> RootContext {
|
||||
var _localctx: RootContext
|
||||
_localctx = RootContext(_ctx, getState())
|
||||
try enterRule(_localctx, 0, ReferenceParser.RULE_root)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(20)
|
||||
try host()
|
||||
setState(23)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
if (_la == ReferenceParser.Tokens.T__0.rawValue) {
|
||||
setState(21)
|
||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||
setState(22)
|
||||
try port()
|
||||
|
||||
}
|
||||
|
||||
setState(25)
|
||||
try match(ReferenceParser.Tokens.T__1.rawValue)
|
||||
setState(26)
|
||||
try namespace()
|
||||
setState(28)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
if (_la == ReferenceParser.Tokens.T__0.rawValue || _la == ReferenceParser.Tokens.T__4.rawValue) {
|
||||
setState(27)
|
||||
try reference()
|
||||
|
||||
}
|
||||
|
||||
setState(30)
|
||||
try match(ReferenceParser.Tokens.EOF.rawValue)
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class HostContext: ParserRuleContext {
|
||||
open
|
||||
func host_component() -> [Host_componentContext] {
|
||||
return getRuleContexts(Host_componentContext.self)
|
||||
}
|
||||
open
|
||||
func host_component(_ i: Int) -> Host_componentContext? {
|
||||
return getRuleContext(Host_componentContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_host
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterHost(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitHost(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func host() throws -> HostContext {
|
||||
var _localctx: HostContext
|
||||
_localctx = HostContext(_ctx, getState())
|
||||
try enterRule(_localctx, 2, ReferenceParser.RULE_host)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(32)
|
||||
try host_component()
|
||||
setState(37)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
while (_la == ReferenceParser.Tokens.T__2.rawValue) {
|
||||
setState(33)
|
||||
try match(ReferenceParser.Tokens.T__2.rawValue)
|
||||
setState(34)
|
||||
try host_component()
|
||||
|
||||
|
||||
setState(39)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
}
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class PortContext: ParserRuleContext {
|
||||
open
|
||||
func DIGIT() -> [TerminalNode] {
|
||||
return getTokens(ReferenceParser.Tokens.DIGIT.rawValue)
|
||||
}
|
||||
open
|
||||
func DIGIT(_ i:Int) -> TerminalNode? {
|
||||
return getToken(ReferenceParser.Tokens.DIGIT.rawValue, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_port
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterPort(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitPort(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func port() throws -> PortContext {
|
||||
var _localctx: PortContext
|
||||
_localctx = PortContext(_ctx, getState())
|
||||
try enterRule(_localctx, 4, ReferenceParser.RULE_port)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(41)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
repeat {
|
||||
setState(40)
|
||||
try match(ReferenceParser.Tokens.DIGIT.rawValue)
|
||||
|
||||
|
||||
setState(43);
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
} while (_la == ReferenceParser.Tokens.DIGIT.rawValue)
|
||||
|
||||
}
|
||||
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) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class NamespaceContext: ParserRuleContext {
|
||||
open
|
||||
func namespace_component() -> [Namespace_componentContext] {
|
||||
return getRuleContexts(Namespace_componentContext.self)
|
||||
}
|
||||
open
|
||||
func namespace_component(_ i: Int) -> Namespace_componentContext? {
|
||||
return getRuleContext(Namespace_componentContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_namespace
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterNamespace(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitNamespace(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func namespace() throws -> NamespaceContext {
|
||||
var _localctx: NamespaceContext
|
||||
_localctx = NamespaceContext(_ctx, getState())
|
||||
try enterRule(_localctx, 8, ReferenceParser.RULE_namespace)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(53)
|
||||
try namespace_component()
|
||||
setState(58)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
while (_la == ReferenceParser.Tokens.T__1.rawValue) {
|
||||
setState(54)
|
||||
try match(ReferenceParser.Tokens.T__1.rawValue)
|
||||
setState(55)
|
||||
try namespace_component()
|
||||
|
||||
|
||||
setState(60)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
}
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class Namespace_componentContext: ParserRuleContext {
|
||||
open
|
||||
func name() -> [NameContext] {
|
||||
return getRuleContexts(NameContext.self)
|
||||
}
|
||||
open
|
||||
func name(_ i: Int) -> NameContext? {
|
||||
return getRuleContext(NameContext.self, i)
|
||||
}
|
||||
open
|
||||
func separator() -> [SeparatorContext] {
|
||||
return getRuleContexts(SeparatorContext.self)
|
||||
}
|
||||
open
|
||||
func separator(_ i: Int) -> SeparatorContext? {
|
||||
return getRuleContext(SeparatorContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_namespace_component
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterNamespace_component(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitNamespace_component(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func namespace_component() throws -> Namespace_componentContext {
|
||||
var _localctx: Namespace_componentContext
|
||||
_localctx = Namespace_componentContext(_ctx, getState())
|
||||
try enterRule(_localctx, 10, ReferenceParser.RULE_namespace_component)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(65)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
repeat {
|
||||
setState(61)
|
||||
try name()
|
||||
setState(63)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
if ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
||||
setState(62)
|
||||
try separator()
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
setState(67);
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
} while (_la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue)
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class ReferenceContext: ParserRuleContext {
|
||||
open
|
||||
func tag() -> TagContext? {
|
||||
return getRuleContext(TagContext.self, 0)
|
||||
}
|
||||
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_reference
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterReference(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitReference(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func reference() throws -> ReferenceContext {
|
||||
var _localctx: ReferenceContext
|
||||
_localctx = ReferenceContext(_ctx, getState())
|
||||
try enterRule(_localctx, 12, ReferenceParser.RULE_reference)
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
setState(76)
|
||||
try _errHandler.sync(self)
|
||||
switch (ReferenceParser.Tokens(rawValue: try _input.LA(1))!) {
|
||||
case .T__0:
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(69)
|
||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||
setState(70)
|
||||
try tag()
|
||||
|
||||
|
||||
break
|
||||
|
||||
case .T__4:
|
||||
try enterOuterAlt(_localctx, 2)
|
||||
setState(71)
|
||||
try match(ReferenceParser.Tokens.T__4.rawValue)
|
||||
setState(72)
|
||||
try name()
|
||||
setState(73)
|
||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||
setState(74)
|
||||
try name()
|
||||
|
||||
|
||||
break
|
||||
default:
|
||||
throw ANTLRException.recognition(e: NoViableAltException(self))
|
||||
}
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class TagContext: ParserRuleContext {
|
||||
open
|
||||
func name() -> [NameContext] {
|
||||
return getRuleContexts(NameContext.self)
|
||||
}
|
||||
open
|
||||
func name(_ i: Int) -> NameContext? {
|
||||
return getRuleContext(NameContext.self, i)
|
||||
}
|
||||
open
|
||||
func separator() -> [SeparatorContext] {
|
||||
return getRuleContexts(SeparatorContext.self)
|
||||
}
|
||||
open
|
||||
func separator(_ i: Int) -> SeparatorContext? {
|
||||
return getRuleContext(SeparatorContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_tag
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterTag(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitTag(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func tag() throws -> TagContext {
|
||||
var _localctx: TagContext
|
||||
_localctx = TagContext(_ctx, getState())
|
||||
try enterRule(_localctx, 14, ReferenceParser.RULE_tag)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(78)
|
||||
try name()
|
||||
setState(84)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
while ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
||||
setState(79)
|
||||
try separator()
|
||||
setState(80)
|
||||
try name()
|
||||
|
||||
|
||||
setState(86)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
}
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class SeparatorContext: ParserRuleContext {
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_separator
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterSeparator(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitSeparator(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func separator() throws -> SeparatorContext {
|
||||
var _localctx: SeparatorContext
|
||||
_localctx = SeparatorContext(_ctx, getState())
|
||||
try enterRule(_localctx, 16, ReferenceParser.RULE_separator)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(87)
|
||||
_la = try _input.LA(1)
|
||||
if (!((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
|
||||
try _errHandler.recoverInline(self)
|
||||
}
|
||||
else {
|
||||
_errHandler.reportMatch(self)
|
||||
try consume()
|
||||
}
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class NameContext: ParserRuleContext {
|
||||
open
|
||||
func LETTER() -> [TerminalNode] {
|
||||
return getTokens(ReferenceParser.Tokens.LETTER.rawValue)
|
||||
}
|
||||
open
|
||||
func LETTER(_ i:Int) -> TerminalNode? {
|
||||
return getToken(ReferenceParser.Tokens.LETTER.rawValue, i)
|
||||
}
|
||||
open
|
||||
func DIGIT() -> [TerminalNode] {
|
||||
return getTokens(ReferenceParser.Tokens.DIGIT.rawValue)
|
||||
}
|
||||
open
|
||||
func DIGIT(_ i:Int) -> TerminalNode? {
|
||||
return getToken(ReferenceParser.Tokens.DIGIT.rawValue, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_name
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterName(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitName(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func name() throws -> NameContext {
|
||||
var _localctx: NameContext
|
||||
_localctx = NameContext(_ctx, getState())
|
||||
try enterRule(_localctx, 18, ReferenceParser.RULE_name)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
var _alt:Int
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(90);
|
||||
try _errHandler.sync(self)
|
||||
_alt = 1;
|
||||
repeat {
|
||||
switch (_alt) {
|
||||
case 1:
|
||||
setState(89)
|
||||
_la = try _input.LA(1)
|
||||
if (!(_la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue)) {
|
||||
try _errHandler.recoverInline(self)
|
||||
}
|
||||
else {
|
||||
_errHandler.reportMatch(self)
|
||||
try consume()
|
||||
}
|
||||
|
||||
|
||||
break
|
||||
default:
|
||||
throw ANTLRException.recognition(e: NoViableAltException(self))
|
||||
}
|
||||
setState(92);
|
||||
try _errHandler.sync(self)
|
||||
_alt = try getInterpreter().adaptivePredict(_input,10,_ctx)
|
||||
} while (_alt != 2 && _alt != ATN.INVALID_ALT_NUMBER)
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
static let _serializedATN:[Int] = [
|
||||
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
|
||||
]
|
||||
|
||||
public
|
||||
static let _ATN = try! ATNDeserializer().deserialize(_serializedATN)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
all: clean
|
||||
antlr -o Generated -Dlanguage=Swift Reference.g4
|
||||
|
||||
clean:
|
||||
rm -rf Generated
|
||||
@@ -0,0 +1,14 @@
|
||||
grammar Reference;
|
||||
|
||||
root: host (':' port)? '/' namespace reference? EOF;
|
||||
host: host_component ('.' host_component)*;
|
||||
port: DIGIT+;
|
||||
host_component: name ('-' name)*;
|
||||
namespace: namespace_component ('/' namespace_component)*;
|
||||
namespace_component: (name separator?)+;
|
||||
reference: (':' tag) | ('@' name ':' name);
|
||||
tag: name (separator name)*;
|
||||
separator: '.' | '-' | '_';
|
||||
name: (LETTER | DIGIT)+;
|
||||
DIGIT: [0-9];
|
||||
LETTER: [A-Za-z];
|
||||
@@ -0,0 +1,405 @@
|
||||
import Foundation
|
||||
import Algorithms
|
||||
import AsyncAlgorithms
|
||||
|
||||
enum RegistryError: Error {
|
||||
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
|
||||
case MissingLocationHeader
|
||||
case AuthFailed(why: String, details: String = "")
|
||||
case MalformedHeader(why: String)
|
||||
}
|
||||
|
||||
enum HTTPMethod: String {
|
||||
case GET = "GET"
|
||||
case POST = "POST"
|
||||
case PUT = "PUT"
|
||||
case PATCH = "PATCH"
|
||||
}
|
||||
|
||||
enum HTTPCode: Int {
|
||||
case Ok = 200
|
||||
case Created = 201
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
struct TokenResponse: Decodable, Authentication {
|
||||
let defaultIssuedAt = Date()
|
||||
let defaultExpiresIn = 60
|
||||
|
||||
var token: String?
|
||||
var accessToken: String?
|
||||
var expiresIn: Int?
|
||||
var issuedAt: Date?
|
||||
|
||||
static func parse(fromData: Data) throws -> Self {
|
||||
let decoder = Config.jsonDecoder()
|
||||
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
dateFormatter.formatOptions = [.withInternetDateTime]
|
||||
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
|
||||
decoder.dateDecodingStrategy = .custom { decoder in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let dateString = try container.decode(String.self)
|
||||
|
||||
return dateFormatter.date(from: dateString) ?? Date()
|
||||
}
|
||||
|
||||
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 {
|
||||
get {
|
||||
// Tokens can expire and expire_in field is used to determine when:
|
||||
//
|
||||
// >The duration in seconds since the token was issued that it will remain valid.
|
||||
// >When omitted, this defaults to 60 seconds. For compatibility with older clients,
|
||||
// >a token should never be returned with less than 60 seconds to live.
|
||||
//
|
||||
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
|
||||
|
||||
(issuedAt ?? defaultIssuedAt) + TimeInterval(expiresIn ?? defaultExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
func header() -> (String, String) {
|
||||
return ("Authorization", "Bearer \(token ?? accessToken ?? "")")
|
||||
}
|
||||
|
||||
func isValid() -> Bool {
|
||||
Date() < tokenExpiresAt
|
||||
}
|
||||
}
|
||||
|
||||
class Registry {
|
||||
private let baseURL: URL
|
||||
let namespace: String
|
||||
let credentialsProviders: [CredentialsProvider]
|
||||
let authenticationKeeper = AuthenticationKeeper()
|
||||
|
||||
var host: String? {
|
||||
guard let host = baseURL.host else { return nil }
|
||||
|
||||
if let port = baseURL.port {
|
||||
return "\(host):\(port)"
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
init(urlComponents: URLComponents,
|
||||
namespace: String,
|
||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
baseURL = urlComponents.url!
|
||||
self.namespace = namespace
|
||||
self.credentialsProviders = credentialsProviders
|
||||
}
|
||||
|
||||
convenience init(
|
||||
host: String,
|
||||
namespace: String,
|
||||
insecure: Bool = false,
|
||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
let proto = insecure ? "http" : "https"
|
||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||
|
||||
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProviders: credentialsProviders)
|
||||
}
|
||||
|
||||
func ping() async throws {
|
||||
let (_, response) = try await dataRequest(.GET, endpointURL("/v2/"))
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "doing ping", code: response.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func pushManifest(reference: String, manifest: OCIManifest) async throws -> String {
|
||||
let manifestJSON = try manifest.toJSON()
|
||||
|
||||
let (data, response) = try await dataRequest(.PUT, endpointURL("\(namespace)/manifests/\(reference)"),
|
||||
headers: ["Content-Type": manifest.mediaType],
|
||||
body: manifestJSON)
|
||||
if response.statusCode != HTTPCode.Created.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
|
||||
details: data.asText())
|
||||
}
|
||||
|
||||
return Digest.hash(manifestJSON)
|
||||
}
|
||||
|
||||
public func pullManifest(reference: String) async throws -> (OCIManifest, Data) {
|
||||
let (data, response) = try await dataRequest(.GET, endpointURL("\(namespace)/manifests/\(reference)"),
|
||||
headers: ["Accept": ociManifestMediaType])
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
|
||||
details: data.asText())
|
||||
}
|
||||
|
||||
let manifest = try OCIManifest(fromJSON: data)
|
||||
|
||||
return (manifest, data)
|
||||
}
|
||||
|
||||
private func uploadLocationFromResponse(_ response: HTTPURLResponse) throws -> URLComponents {
|
||||
guard let uploadLocationRaw = response.value(forHTTPHeaderField: "Location") else {
|
||||
throw RegistryError.MissingLocationHeader
|
||||
}
|
||||
|
||||
guard let uploadLocation = URL(string: uploadLocationRaw) else {
|
||||
throw RegistryError.MalformedHeader(why: "Location header contains invalid URL: \"\(uploadLocationRaw)\"")
|
||||
}
|
||||
|
||||
return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)!
|
||||
}
|
||||
|
||||
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
|
||||
// Initiate a blob upload
|
||||
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
|
||||
headers: ["Content-Length": "0"])
|
||||
if postResponse.statusCode != HTTPCode.Accepted.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
|
||||
details: data.asText())
|
||||
}
|
||||
|
||||
// Figure out where to upload the blob
|
||||
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||
|
||||
let digest = Digest.hash(fromData)
|
||||
|
||||
if chunkSizeMb == 0 {
|
||||
// monolithic upload
|
||||
let (data, response) = try await dataRequest(
|
||||
.PUT,
|
||||
uploadLocation,
|
||||
headers: [
|
||||
"Content-Type": "application/octet-stream",
|
||||
],
|
||||
parameters: ["digest": digest],
|
||||
body: fromData
|
||||
)
|
||||
if response.statusCode != HTTPCode.Created.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
|
||||
code: response.statusCode, details: data.asText())
|
||||
}
|
||||
return digest
|
||||
}
|
||||
|
||||
// chunked upload
|
||||
var uploadedBytes = 0
|
||||
let chunks = fromData.chunks(ofCount: chunkSizeMb == 0 ? fromData.count : chunkSizeMb * 1_000_000)
|
||||
for (index, chunk) in chunks.enumerated() {
|
||||
let lastChunk = index == (chunks.count - 1)
|
||||
let (data, response) = try await dataRequest(
|
||||
lastChunk ? .PUT : .PATCH,
|
||||
uploadLocation,
|
||||
headers: [
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Range": "\(uploadedBytes)-\(uploadedBytes + chunk.count - 1)",
|
||||
],
|
||||
parameters: lastChunk ? ["digest": digest] : [:],
|
||||
body: chunk
|
||||
)
|
||||
// always accept both statuses since AWS ECR is not following specification
|
||||
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
|
||||
code: response.statusCode, details: data.asText())
|
||||
}
|
||||
uploadedBytes += chunk.count
|
||||
// Update location for the next chunk
|
||||
uploadLocation = try uploadLocationFromResponse(response)
|
||||
}
|
||||
|
||||
return digest
|
||||
}
|
||||
|
||||
public func pullBlob(_ digest: String, handler: (Data) async throws -> Void) async throws {
|
||||
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
let body = try await channel.asData().asText()
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
|
||||
details: body)
|
||||
}
|
||||
|
||||
for try await part in channel {
|
||||
try Task.checkCancellation()
|
||||
|
||||
try await handler(part)
|
||||
}
|
||||
}
|
||||
|
||||
private func endpointURL(_ endpoint: String) -> URLComponents {
|
||||
let url = URL(string: endpoint, relativeTo: baseURL)!
|
||||
|
||||
return URLComponents(url: url, resolvingAgainstBaseURL: true)!
|
||||
}
|
||||
|
||||
private func dataRequest(
|
||||
_ method: HTTPMethod,
|
||||
_ urlComponents: URLComponents,
|
||||
headers: Dictionary<String, String> = Dictionary(),
|
||||
parameters: Dictionary<String, String> = Dictionary(),
|
||||
body: Data? = nil,
|
||||
doAuth: Bool = true
|
||||
) 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
|
||||
|
||||
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
||||
urlComponents.queryItems = []
|
||||
}
|
||||
urlComponents.queryItems?.append(contentsOf: parameters.map { key, value -> URLQueryItem in
|
||||
URLQueryItem(name: key, value: value)
|
||||
})
|
||||
|
||||
var request = URLRequest(url: urlComponents.url!)
|
||||
request.httpMethod = method.rawValue
|
||||
for (key, value) in headers {
|
||||
request.addValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
if let body = body {
|
||||
request.addValue("\(body.count)", forHTTPHeaderField: "Content-Length")
|
||||
request.httpBody = body
|
||||
}
|
||||
|
||||
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||
|
||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||
_ = try await channel.asData()
|
||||
try await auth(response: response)
|
||||
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||
}
|
||||
|
||||
return (channel, response)
|
||||
}
|
||||
|
||||
private func auth(response: HTTPURLResponse) async throws {
|
||||
// Process WWW-Authenticate header
|
||||
guard let wwwAuthenticateRaw = response.value(forHTTPHeaderField: "WWW-Authenticate") else {
|
||||
throw RegistryError.AuthFailed(why: "got HTTP 401, but WWW-Authenticate header is missing")
|
||||
}
|
||||
|
||||
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
||||
|
||||
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
||||
if let (user, password) = try lookupCredentials() {
|
||||
await authenticationKeeper.set(BasicAuthentication(user: user, password: password))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if wwwAuthenticate.scheme.lowercased() != "bearer" {
|
||||
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
|
||||
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
|
||||
}
|
||||
guard let realm = wwwAuthenticate.kvs["realm"] else {
|
||||
throw RegistryError.AuthFailed(why: "WWW-Authenticate header is missing a \"realm\" directive")
|
||||
}
|
||||
|
||||
// Request a token
|
||||
guard var authenticateURL = URLComponents(string: realm) else {
|
||||
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's realm directive "
|
||||
+ "\"\(realm)\" doesn't look like URL")
|
||||
}
|
||||
|
||||
// Token Authentication Specification[1]:
|
||||
//
|
||||
// >To respond to this challenge, the client will need to make a GET request
|
||||
// >[...] using the service and scope values from the WWW-Authenticate header.
|
||||
//
|
||||
// [1]: https://docs.docker.com/registry/spec/auth/token/
|
||||
authenticateURL.queryItems = ["scope", "service"].compactMap { key in
|
||||
if let value = wwwAuthenticate.kvs[key] {
|
||||
return URLQueryItem(name: key, value: value)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var headers: Dictionary<String, String> = Dictionary()
|
||||
|
||||
if let (user, password) = try lookupCredentials() {
|
||||
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
|
||||
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
||||
}
|
||||
|
||||
let (data, response) = try await dataRequest(.GET, authenticateURL, headers: headers, doAuth: false)
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.statusCode) "
|
||||
+ "while retrieving an authentication token", details: data.asText())
|
||||
}
|
||||
|
||||
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
|
||||
}
|
||||
|
||||
private func lookupCredentials() throws -> (String, String)? {
|
||||
var host = baseURL.host!
|
||||
|
||||
if let port = baseURL.port {
|
||||
host += ":\(port)"
|
||||
}
|
||||
|
||||
for provider in credentialsProviders {
|
||||
if let (user, password) = try provider.retrieve(host: host) {
|
||||
return (user, password)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
var request = request
|
||||
|
||||
if let (name, value) = await authenticationKeeper.header() {
|
||||
request.addValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
|
||||
request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))",
|
||||
forHTTPHeaderField: "User-Agent")
|
||||
|
||||
return try await Fetcher.fetch(request, viaFile: viaFile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import Foundation
|
||||
import Antlr4
|
||||
|
||||
struct Reference: Comparable, Hashable, CustomStringConvertible {
|
||||
enum ReferenceType: Comparable {
|
||||
case Tag
|
||||
case Digest
|
||||
}
|
||||
|
||||
let type: ReferenceType
|
||||
let value: String
|
||||
|
||||
var fullyQualified: String {
|
||||
get {
|
||||
switch type {
|
||||
case .Tag:
|
||||
return ":" + value
|
||||
case .Digest:
|
||||
return "@" + value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(tag: String) {
|
||||
type = .Tag
|
||||
value = tag
|
||||
}
|
||||
|
||||
init(digest: String) {
|
||||
type = .Digest
|
||||
value = digest
|
||||
}
|
||||
|
||||
static func <(lhs: Reference, rhs: Reference) -> Bool {
|
||||
if lhs.type != rhs.type {
|
||||
return lhs.type < rhs.type
|
||||
} else {
|
||||
return lhs.value < rhs.value
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
get {
|
||||
fullyQualified
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ReferenceCollector: ReferenceBaseListener {
|
||||
var host: String? = nil
|
||||
var port: String? = nil
|
||||
var namespace: String? = nil
|
||||
var reference: String? = nil
|
||||
|
||||
override func exitHost(_ ctx: ReferenceParser.HostContext) {
|
||||
host = ctx.getText()
|
||||
}
|
||||
|
||||
override func exitPort(_ ctx: ReferenceParser.PortContext) {
|
||||
port = ctx.getText()
|
||||
}
|
||||
|
||||
override func exitNamespace(_ ctx: ReferenceParser.NamespaceContext) {
|
||||
namespace = ctx.getText()
|
||||
}
|
||||
|
||||
override func exitReference(_ ctx: ReferenceParser.ReferenceContext) {
|
||||
reference = ctx.getText()
|
||||
}
|
||||
}
|
||||
|
||||
class ErrorCollector: BaseErrorListener {
|
||||
var error: String? = nil
|
||||
|
||||
override func syntaxError<T>(_ recognizer: Recognizer<T>, _ offendingSymbol: AnyObject?, _ line: Int, _ charPositionInLine: Int, _ msg: String, _ e: AnyObject?) {
|
||||
if error == nil {
|
||||
error = "\(msg) (character \(charPositionInLine + 1))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteName: Comparable, Hashable, CustomStringConvertible {
|
||||
var host: String
|
||||
var namespace: String
|
||||
var reference: Reference
|
||||
|
||||
init(host: String, namespace: String, reference: Reference) {
|
||||
self.host = host
|
||||
self.namespace = namespace
|
||||
self.reference = reference
|
||||
}
|
||||
|
||||
init(_ name: String) throws {
|
||||
let errorCollector = ErrorCollector()
|
||||
let inputStream = ANTLRInputStream(Array(name.unicodeScalars), name.count)
|
||||
let lexer = ReferenceLexer(inputStream)
|
||||
lexer.removeErrorListeners()
|
||||
lexer.addErrorListener(errorCollector)
|
||||
|
||||
let tokenStream = CommonTokenStream(lexer)
|
||||
let parser = try ReferenceParser(tokenStream)
|
||||
parser.removeErrorListeners()
|
||||
parser.addErrorListener(errorCollector)
|
||||
|
||||
let referenceCollector = ReferenceCollector()
|
||||
try ParseTreeWalker().walk(referenceCollector, try parser.root())
|
||||
|
||||
if let error = errorCollector.error {
|
||||
throw RuntimeError.FailedToParseRemoteName("\(error)")
|
||||
}
|
||||
|
||||
host = referenceCollector.host!
|
||||
if let port = referenceCollector.port {
|
||||
host += ":" + port
|
||||
}
|
||||
namespace = referenceCollector.namespace!
|
||||
if let reference = referenceCollector.reference {
|
||||
if reference.starts(with: "@sha256:") {
|
||||
self.reference = Reference(digest: String(reference.dropFirst(1)))
|
||||
} else if reference.starts(with: ":") {
|
||||
self.reference = Reference(tag: String(reference.dropFirst(1)))
|
||||
} else {
|
||||
throw RuntimeError.FailedToParseRemoteName("unknown reference format")
|
||||
}
|
||||
} else {
|
||||
self.reference = Reference(tag: "latest")
|
||||
}
|
||||
}
|
||||
|
||||
static func <(lhs: RemoteName, rhs: RemoteName) -> Bool {
|
||||
if lhs.host != rhs.host {
|
||||
return lhs.host < rhs.host
|
||||
} else if lhs.namespace != rhs.namespace {
|
||||
return lhs.namespace < rhs.namespace
|
||||
} else {
|
||||
return lhs.reference < rhs.reference
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"\(host)/\(namespace)\(reference.fullyQualified)"
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Self.Element == ClosedRange<UInt8> {
|
||||
func asCharacterSet() -> CharacterSet {
|
||||
let characters = self.joined().map { String(UnicodeScalar($0)) }.joined()
|
||||
return CharacterSet(charactersIn: characters)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension URL {
|
||||
func absolutize(_ baseURL: URL) -> Self {
|
||||
URL(string: absoluteString, relativeTo: baseURL)!
|
||||
}
|
||||
}
|
||||