Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -6,6 +6,6 @@ export VERSION="${CIRRUS_TAG:-0}"
|
|||||||
|
|
||||||
mkdir -p .ci/pkg/
|
mkdir -p .ci/pkg/
|
||||||
cp .build/arm64-apple-macosx/debug/tart .ci/pkg/
|
cp .build/arm64-apple-macosx/debug/tart .ci/pkg/
|
||||||
pkgbuild --root .ci/pkg --version $VERSION --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Fedor Korotkov (9M2P8L4D89)" "./dist/Tart-$VERSION.pkg"
|
pkgbuild --root .ci/pkg --version $VERSION --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Cirrus Labs, Inc. (9M2P8L4D89)" "./dist/Tart-$VERSION.pkg"
|
||||||
xcrun notarytool submit "./dist/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
|
xcrun notarytool submit "./dist/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
|
||||||
xcrun stapler staple "./dist/Tart-$VERSION.pkg"
|
xcrun stapler staple "./dist/Tart-$VERSION.pkg"
|
||||||
|
|||||||
@@ -1,13 +1,44 @@
|
|||||||
|
use_compute_credits: true
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Test on Ventura
|
name: Test on Ventura
|
||||||
|
alias: test
|
||||||
persistent_worker:
|
persistent_worker:
|
||||||
labels:
|
labels:
|
||||||
name: Mac-Mini-M1
|
name: scaleway-m1
|
||||||
build_script: swift test
|
test_script:
|
||||||
test_script: swift test
|
- swift test
|
||||||
|
integration_test_script:
|
||||||
|
# Build Tart
|
||||||
|
- swift build
|
||||||
|
- codesign --sign - --entitlements Resources/tart.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: Lint
|
||||||
|
alias: lint
|
||||||
|
macos_instance:
|
||||||
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||||
|
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:
|
task:
|
||||||
name: Build
|
name: Build
|
||||||
|
alias: build
|
||||||
only_if: $CIRRUS_TAG == ''
|
only_if: $CIRRUS_TAG == ''
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||||
@@ -19,13 +50,20 @@ task:
|
|||||||
task:
|
task:
|
||||||
name: Release
|
name: Release
|
||||||
only_if: $CIRRUS_TAG != ''
|
only_if: $CIRRUS_TAG != ''
|
||||||
|
depends_on:
|
||||||
|
- lint
|
||||||
|
- test
|
||||||
|
- build
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||||
env:
|
env:
|
||||||
MACOS_CERTIFICATE: ENCRYPTED[8a6930a8c1286e7e536ea41b7647ea40e99174ad15e9cfcc753754fea55a619b355415629dff515b54a8921643e314e5]
|
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||||
|
SENTRY_ORG: cirrus-labs
|
||||||
|
SENTRY_PROJECT: persistent-workers
|
||||||
|
SENTRY_AUTH_TOKEN: ENCRYPTED[!c16a5cf7da5f856b4bc2f21fe8cb7aa2a6c981f851c094ed4d3025fd02ea59a58a86cee8b193a69a1fc20fa217e56ac3!]
|
||||||
setup_script:
|
setup_script:
|
||||||
- cd $HOME
|
- cd $HOME
|
||||||
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
||||||
@@ -36,10 +74,36 @@ task:
|
|||||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
||||||
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||||
install_script:
|
install_script:
|
||||||
- brew install go goreleaser/tap/goreleaser-pro
|
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
|
||||||
- brew install mitchellh/gon/gon
|
- brew install mitchellh/gon/gon
|
||||||
info_script:
|
info_script:
|
||||||
- security find-identity -v
|
- security find-identity -v
|
||||||
- xcodebuild -version
|
- xcodebuild -version
|
||||||
- swift -version
|
- swift -version
|
||||||
release_script: goreleaser
|
release_script: goreleaser
|
||||||
|
upload_sentry_debug_files_script:
|
||||||
|
- cd .build/arm64-apple-macosx/debug/
|
||||||
|
# Generate and upload symbols
|
||||||
|
- dsymutil 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
|
||||||
|
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/squidfunk/mkdocs-material:latest
|
||||||
|
env:
|
||||||
|
DEPLOY_TOKEN: ENCRYPTED[!45ed45666558902ed1c2400add734ec063103bec31841847e8c8764802fca229bfa6d85c690e16ad159e047574b48793!]
|
||||||
|
deploy_script:
|
||||||
|
- git config --global user.name "Cirrus CI"
|
||||||
|
- git config --global user.name "hello@cirruslabs.org"
|
||||||
|
- git remote set-url origin https://$DEPLOY_TOKEN@github.com/cirruslabs/tart/
|
||||||
|
- mkdocs --verbose gh-deploy --force --remote-branch gh-pages
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
*.png filter=lfs diff=lfs merge=lfs -text
|
|
||||||
*.gif filter=lfs diff=lfs merge=lfs -text
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* @edigaryev @fkorotkov
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
github: [cirruslabs]
|
||||||
@@ -13,3 +13,6 @@ tart.xcodeproj/
|
|||||||
|
|
||||||
# GoReleaser
|
# GoReleaser
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# mkdocs
|
||||||
|
.cache
|
||||||
|
|||||||
@@ -39,10 +39,15 @@ brews:
|
|||||||
name: homebrew-cli
|
name: homebrew-cli
|
||||||
caveats: See the GitHub repository for more information
|
caveats: See the GitHub repository for more information
|
||||||
homepage: https://github.com/cirruslabs/tart
|
homepage: https://github.com/cirruslabs/tart
|
||||||
|
license: "AGPL-3.0"
|
||||||
description: Run macOS VMs on Apple Silicon
|
description: Run macOS VMs on Apple Silicon
|
||||||
skip_upload: auto
|
skip_upload: auto
|
||||||
dependencies:
|
dependencies:
|
||||||
- "cirruslabs/cli/softnet"
|
- "cirruslabs/cli/softnet"
|
||||||
|
post_install: |
|
||||||
|
ENV["SENTRY_DSN"] = "https://606fab64ced94f0991109ac5467e23da@o4504250314522624.ingest.sentry.io/4504606552424448"
|
||||||
|
system "#{bin}/tart report-installation"
|
||||||
|
ENV.delete("SENTRY_DSN")
|
||||||
custom_block: |
|
custom_block: |
|
||||||
depends_on :macos => :monterey
|
depends_on :macos => :monterey
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
--disable all
|
||||||
|
--enable indent
|
||||||
|
--indent 2
|
||||||
|
--exclude Sources/tart/OCI/Reference/Generated
|
||||||
|
--swiftversion 5.7
|
||||||
@@ -5,17 +5,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/antlr/antlr4",
|
"location" : "https://github.com/antlr/antlr4",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "4.10.1",
|
"branch" : "dev",
|
||||||
"revision" : "44d87bc1d130c88aa452894aa5f7e2f710f68253"
|
"revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2"
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "async-http-client",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/swift-server/async-http-client",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "df87a860fdc41a595d5ca67f74cde9adbccc099a",
|
|
||||||
"version" : "1.11.4"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -27,6 +18,15 @@
|
|||||||
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "sentry-cocoa",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "c3c19e29f775ee95b77aa3168f3e2fd6c20deba6",
|
||||||
|
"version" : "7.31.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-algorithms",
|
"identity" : "swift-algorithms",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -45,6 +45,15 @@
|
|||||||
"version" : "1.1.2"
|
"version" : "1.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-async-algorithms",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-async-algorithms",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "f05e450f0b909c0e80670a47516c4b9700b9e5da"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-atomics",
|
"identity" : "swift-atomics",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -55,57 +64,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-log",
|
"identity" : "swift-collections",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-log.git",
|
"location" : "https://github.com/apple/swift-collections.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "5d66f7ba25daf4f94100e7022febf3c75e37a6c7",
|
"revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
|
||||||
"version" : "1.4.2"
|
"version" : "1.0.3"
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-nio",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-nio.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "124119f0bb12384cef35aa041d7c3a686108722d",
|
|
||||||
"version" : "2.40.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-nio-extras",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-nio-extras.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "8eea84ec6144167354387ef9244b0939f5852dc8",
|
|
||||||
"version" : "1.11.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-nio-http2",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-nio-http2.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "108ac15087ea9b79abb6f6742699cf31de0e8772",
|
|
||||||
"version" : "1.22.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-nio-ssl",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-nio-ssl.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "1750873bce84b4129b5303655cce2c3d35b9ed3a",
|
|
||||||
"version" : "2.19.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-nio-transport-services",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-nio-transport-services.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "1a4692acb88156e3da1b0c6732a8a38b2a744166",
|
|
||||||
"version" : "1.12.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -125,6 +89,24 @@
|
|||||||
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
||||||
"version" : "6.3.1"
|
"version" : "6.3.1"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftformat",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/nicklockwood/SwiftFormat",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
|
||||||
|
"version" : "0.50.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "texttable",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/cfilipov/TextTable",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "master",
|
||||||
|
"revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 2
|
"version" : 2
|
||||||
|
|||||||
@@ -12,19 +12,33 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"),
|
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"),
|
||||||
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
||||||
.package(url: "https://github.com/swift-server/async-http-client", from: "1.11.4"),
|
|
||||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
|
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
|
||||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
|
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
|
||||||
.package(url: "https://github.com/antlr/antlr4", revision: "4.10.1")
|
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
|
||||||
|
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
||||||
|
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
|
||||||
|
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "7.31.3"),
|
||||||
|
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(name: "tart", dependencies: [
|
.executableTarget(name: "tart", dependencies: [
|
||||||
.product(name: "Algorithms", package: "swift-algorithms"),
|
.product(name: "Algorithms", package: "swift-algorithms"),
|
||||||
|
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
.product(name: "AsyncHTTPClient", package: "async-http-client"),
|
|
||||||
.product(name: "Dynamic", package: "Dynamic"),
|
.product(name: "Dynamic", package: "Dynamic"),
|
||||||
.product(name: "SwiftDate", package: "SwiftDate"),
|
.product(name: "SwiftDate", package: "SwiftDate"),
|
||||||
.product(name: "Antlr4", package: "Antlr4"),
|
.product(name: "Antlr4Static", package: "Antlr4"),
|
||||||
|
.product(name: "Atomics", package: "swift-atomics"),
|
||||||
|
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||||
|
.product(name: "TextTable", package: "TextTable"),
|
||||||
|
], exclude: [
|
||||||
|
"OCI/Reference/Makefile",
|
||||||
|
"OCI/Reference/Reference.g4",
|
||||||
|
"OCI/Reference/Generated/Reference.interp",
|
||||||
|
"OCI/Reference/Generated/Reference.tokens",
|
||||||
|
"OCI/Reference/Generated/ReferenceLexer.interp",
|
||||||
|
"OCI/Reference/Generated/ReferenceLexer.tokens",
|
||||||
]),
|
]),
|
||||||
.testTarget(name: "TartTests", dependencies: ["tart"])
|
.testTarget(name: "TartTests", dependencies: ["tart"])
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||

|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
|
||||||
|
|
||||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
|
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon.
|
||||||
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
||||||
|
|
||||||
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/14966395?baseline=14966339).
|
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722).
|
||||||
* Push/Pull virtual machines from any OCI-compatible container registry.
|
* Push/Pull virtual machines from any OCI-compatible container registry.
|
||||||
* Use Tart Packer Plugin to automate VM creation.
|
* Use Tart Packer Plugin to automate VM creation.
|
||||||
* Built-in CI integration.
|
* Built-in CI integration.
|
||||||
@@ -22,249 +22,34 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
Many more companies are using Tart in their internal setups. Here are a few of them:
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://ahrefs.com/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://krisp.ai/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||||
|
</a>
|
||||||
|
<a href="https://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>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
|
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
|
||||||
|
|
||||||
```shell
|
```bash
|
||||||
brew install cirruslabs/cli/tart
|
brew install cirruslabs/cli/tart
|
||||||
tart clone ghcr.io/cirruslabs/macos-monterey-base:latest monterey-base
|
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
|
||||||
tart run monterey-base
|
tart run ventura-base
|
||||||
```
|
```
|
||||||
|
|
||||||

|
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
|
||||||
|
for remaining questions.
|
||||||
## CI Integration
|
|
||||||
|
|
||||||
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
|
|
||||||
also developed by Cirrus Labs. [Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is a command line tool with
|
|
||||||
one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
|
|
||||||
We built Cirrus CLI to solve "But it works on my machine!" problem.
|
|
||||||
|
|
||||||
Here is an example of a `.cirrus.yml` configuration file which will start a Tart VM, will copy over working directory and
|
|
||||||
will run scripts and [other instructions](https://cirrus-ci.org/guide/writing-tasks/#supported-instructions) inside the virtual machine:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
task:
|
|
||||||
name: hello
|
|
||||||
macos_instance:
|
|
||||||
# can be a remote or a local virtual machine
|
|
||||||
image: ghcr.io/cirruslabs/macos-monterey-base:latest
|
|
||||||
hello_script:
|
|
||||||
- echo "Hello from within a Tart VM!"
|
|
||||||
- echo "Here is my CPU info:"
|
|
||||||
- sysctl -n machdep.cpu.brand_string
|
|
||||||
- sleep 15
|
|
||||||
```
|
|
||||||
|
|
||||||
Put the above `.cirrus.yml` file in the root of your repository and run it with the following command:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
brew install cirruslabs/cli/cirrus
|
|
||||||
cirrus run
|
|
||||||
```
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
|
|
||||||
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
|
|
||||||
|
|
||||||
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
|
||||||
|
|
||||||
### Retrieving artifacts from within Tart VMs
|
|
||||||
|
|
||||||
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
|
|
||||||
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
|
|
||||||
exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tasks/#artifacts-instruction):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
task:
|
|
||||||
name: Build
|
|
||||||
macos_instance:
|
|
||||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
|
|
||||||
build_script: swift build --product tart
|
|
||||||
binary_artifacts:
|
|
||||||
path: .build/debug/tart
|
|
||||||
```
|
|
||||||
|
|
||||||
Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cirrus run --artifacts-dir artifacts
|
|
||||||
```
|
|
||||||
|
|
||||||
Note that all retrieved artifacts will be prefixed with the associated task name and `artifacts` instruction name.
|
|
||||||
For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/binary/.build/debug/tart`.
|
|
||||||
|
|
||||||
## Virtual Machine Management
|
|
||||||
|
|
||||||
### Creating from scratch
|
|
||||||
|
|
||||||
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regarding of the underlying OS a particular VM image has.
|
|
||||||
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
|
|
||||||
|
|
||||||
#### Creating a macOS VM image from scratch
|
|
||||||
|
|
||||||
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
|
|
||||||
use `latest` instead of a path to `*.ipsw` to download the latest available version:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
tart create --from-ipsw=latest monterey-vanilla
|
|
||||||
tart run monterey-vanilla
|
|
||||||
```
|
|
||||||
|
|
||||||
After the initial booting of the VM you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
|
|
||||||
|
|
||||||
1. Enable Auto-Login. Users & Groups -> Login Options -> Automatic login -> admin.
|
|
||||||
2. Allow SSH. Sharing -> Remote Login
|
|
||||||
3. Disable Lock Screen. Preferences -> Lock Screen -> disable "Require Password" after 5.
|
|
||||||
4. Disable Screen Saver.
|
|
||||||
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
|
|
||||||
|
|
||||||
#### Creating a Linux VM image from scratch
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a bare VM
|
|
||||||
tart create --linux ubuntu
|
|
||||||
|
|
||||||
# Install Ubuntu
|
|
||||||
tart run --disk focal-desktop-arm64.iso ubuntu
|
|
||||||
|
|
||||||
# Run VM
|
|
||||||
tart run ubuntu
|
|
||||||
```
|
|
||||||
|
|
||||||
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install -y openssh-server
|
|
||||||
sudo ufw allow ssh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuring a VM
|
|
||||||
|
|
||||||
By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command.
|
|
||||||
Please refer to `tart set --help` for additional details.
|
|
||||||
|
|
||||||
### Building with Packer
|
|
||||||
|
|
||||||
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
|
|
||||||
Here is an example of a template to build `monterey-base` local image based of a remote image:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"builders": [
|
|
||||||
{
|
|
||||||
"name": "tart",
|
|
||||||
"type": "tart-cli",
|
|
||||||
"vm_base_name": "tartvm/vanilla:latest",
|
|
||||||
"vm_name": "monterey-base",
|
|
||||||
"cpu_count": 4,
|
|
||||||
"memory_gb": 8,
|
|
||||||
"disk_size_gb": 32,
|
|
||||||
"ssh_username": "admin",
|
|
||||||
"ssh_password": "admin",
|
|
||||||
"ssh_timeout": "120s"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"provisioners": [
|
|
||||||
{
|
|
||||||
"inline": [
|
|
||||||
"echo 'Disabling spotlight indexing...'",
|
|
||||||
"sudo mdutil -a -i off"
|
|
||||||
],
|
|
||||||
"type": "shell"
|
|
||||||
},
|
|
||||||
# more provisioners
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Here is a [repository with Packer templates](https://github.com/cirruslabs/macos-image-templates) used to build [all the images managed by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
|
||||||
|
|
||||||
### Working with a Remote OCI Container Registry
|
|
||||||
|
|
||||||
For example, let's say you want to push/pull images to a registry hosted at https://acme.io/.
|
|
||||||
|
|
||||||
#### Registry Authorization
|
|
||||||
|
|
||||||
First, you need to log in and save credential for `acme.io` host via `tart login` command:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
tart login acme.io
|
|
||||||
```
|
|
||||||
|
|
||||||
Credentials are securely stored in Keychain.
|
|
||||||
|
|
||||||
#### Pushing a Local Image
|
|
||||||
|
|
||||||
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Pulling a Remote Image
|
|
||||||
|
|
||||||
You can either pull an image:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
tart pull acme.io/remoteorg/name:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
...or instantiate a VM from a remote image:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
tart clone acme.io/remoteorg/name:latest my-local-vm-name
|
|
||||||
```
|
|
||||||
|
|
||||||
This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>How Tart is different from Anka</summary>
|
|
||||||
|
|
||||||
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
|
|
||||||
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
|
|
||||||
|
|
||||||
Instead of Anka Registry, Tart can work with any OCI-compatible container registry.
|
|
||||||
|
|
||||||
Tart doesn't yet have an analogue of Anka Controller for managing long living VMs. Please take a look at [CI integration](#ci-integration)
|
|
||||||
section for an option to run ephemeral VMs for your needs.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Why Tart is free and open sourced?</summary>
|
|
||||||
|
|
||||||
Tart is a relatively small project, and it didn't feel right to try to monetize it.
|
|
||||||
Apple did all the heavy lifting with their `Virtualization.Framework`.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>How to change VM's disk size?</summary>
|
|
||||||
|
|
||||||
You can choose disk size upon creation of a virtual machine:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
tart create --from-ipsw=latest --disk-size=25 monterey-vanilla
|
|
||||||
```
|
|
||||||
|
|
||||||
For an existing VM please use [Packer Plugin](https://github.com/cirruslabs/packer-plugin-tart) which can increase
|
|
||||||
disk size for new virtual machines. Here is an example of [how to change disk size in a Packer template](https://github.com/cirruslabs/macos-image-templates/blob/fb0bcf68e0b093129136875c050205a66729b596/templates/base.pkr.hcl#L15).
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>VM location on disk</summary>
|
|
||||||
|
|
||||||
Tart stores all it's files in `~/.tart/` directory. Local images that you can run are stored in `~/.tart/vms/`.
|
|
||||||
Remote images are pulled into `~/.tart/vms/cache/OCIs/`.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Nested virtualization support?</summary>
|
|
||||||
|
|
||||||
Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
|
|
||||||
doesn't support nested virtualization.
|
|
||||||
</details>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 131 B After Width: | Height: | Size: 120 KiB |
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:8a3a324193c4bd7797102765ab16f44adf58e49ca615bac3963cefd0d3a10594
|
|
||||||
size 339678
|
|
||||||
|
Before Width: | Height: | Size: 131 B After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 131 B After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 589 B |
|
Before Width: | Height: | Size: 129 B After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 130 B After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
If you'd like to highlight your use of Tart, please create a `456px` by `130px` logo and create a PR
|
||||||
|
that adds it to `README.md` in alphabetical order. Don't forget to include a small description of your usage pattern.
|
||||||
|
|
||||||
|
You can refer to `Background.png` as a base for your logo.
|
||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 130 B After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@@ -5,4 +5,4 @@
|
|||||||
<key>com.apple.security.virtualization</key>
|
<key>com.apple.security.virtualization</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ struct CI {
|
|||||||
static var version: String {
|
static var version: String {
|
||||||
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
|
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var release: String? {
|
||||||
|
rawVersion.expanded() ? "tart@\(rawVersion)" : nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension String {
|
private extension String {
|
||||||
|
|||||||
@@ -21,49 +21,35 @@ struct Clone: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
let ociStorage = VMStorageOCI()
|
||||||
let ociStorage = VMStorageOCI()
|
let localStorage = VMStorageLocal()
|
||||||
let localStorage = VMStorageLocal()
|
|
||||||
|
|
||||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
||||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||||
try await ociStorage.pull(remoteName, registry: registry)
|
try await ociStorage.pull(remoteName, registry: registry)
|
||||||
}
|
|
||||||
|
|
||||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
|
||||||
|
|
||||||
let tmpVMDir = try VMDirectory.temporary()
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
|
||||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
|
||||||
try lock.lock()
|
|
||||||
|
|
||||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
|
||||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
|
||||||
try localStorage.move(newName, from: tmpVMDir)
|
|
||||||
|
|
||||||
try lock.unlock()
|
|
||||||
}, onCancel: {
|
|
||||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
|
||||||
})
|
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate extension VMDirectory {
|
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||||
func macAddress() throws -> String {
|
|
||||||
try VMConfig(fromURL: configURL).macAddress.string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate extension VMStorageLocal {
|
let tmpVMDir = try VMDirectory.temporary()
|
||||||
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
|
||||||
try list().contains { try $1.macAddress() == macAddress }
|
// 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())
|
||||||
|
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||||
|
try localStorage.move(newName, from: tmpVMDir)
|
||||||
|
|
||||||
|
try lock.unlock()
|
||||||
|
}, onCancel: {
|
||||||
|
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ struct Create: AsyncParsableCommand {
|
|||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name")
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file (or \"latest\") to fetch the latest appropriate IPSW", valueName: "path"))
|
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file or URL (or \"latest\", to fetch the latest supported IPSW automatically)", valueName: "path"))
|
||||||
var fromIPSW: String?
|
var fromIPSW: String?
|
||||||
|
|
||||||
@Flag(help: "create a Linux VM")
|
@Flag(help: "create a Linux VM")
|
||||||
@@ -25,35 +25,38 @@ struct Create: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
let tmpVMDir = try VMDirectory.temporary()
|
||||||
let tmpVMDir = try VMDirectory.temporary()
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
// Lock the temporary VM directory to prevent it's garbage collection
|
||||||
if let fromIPSW = fromIPSW {
|
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||||
if fromIPSW == "latest" {
|
try tmpVMDirLock.lock()
|
||||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
|
|
||||||
} else {
|
try await withTaskCancellationHandler(operation: {
|
||||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW), diskSizeGB: diskSize)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
if linux {
|
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||||
if #available(macOS 13, *) {
|
}
|
||||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
|
||||||
} else {
|
if linux {
|
||||||
throw UnsupportedOSError("Linux VMs", "are")
|
if #available(macOS 13, *) {
|
||||||
}
|
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||||
|
} else {
|
||||||
|
throw UnsupportedOSError("Linux VMs", "are")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||||
}, onCancel: {
|
}, onCancel: {
|
||||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||||
})
|
})
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,11 @@ struct Delete: AsyncParsableCommand {
|
|||||||
static var configuration = CommandConfiguration(abstract: "Delete a VM")
|
static var configuration = CommandConfiguration(abstract: "Delete a VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name")
|
||||||
var name: String
|
var name: [String]
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
for it in name {
|
||||||
try VMStorageHelper.delete(name)
|
try VMStorageHelper.delete(it)
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
|
||||||
|
struct Export: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(abstract: "Export VM to a file")
|
||||||
|
|
||||||
|
@Argument(help: "Source VM name.")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
@Argument(help: "Path to the destination file.")
|
||||||
|
var path: String
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
print("exporting...")
|
||||||
|
try VMStorageHelper.open(name).exportToArchive(path: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
fileprivate struct VMInfo: Encodable {
|
||||||
|
let CPU: Int
|
||||||
|
let Memory: UInt64
|
||||||
|
let Disk: Int
|
||||||
|
let Display: String
|
||||||
|
let Running: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
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 running = try PIDLock(lockURL: vmDir.configURL).pid() > 0
|
||||||
|
|
||||||
|
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb, Display: vmConfig.display.description, Running: running)
|
||||||
|
print(format.renderSingle(info))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import ArgumentParser
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import SystemConfiguration
|
import SystemConfiguration
|
||||||
|
import Sentry
|
||||||
|
|
||||||
struct IP: AsyncParsableCommand {
|
struct IP: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
||||||
@@ -13,42 +14,49 @@ struct IP: AsyncParsableCommand {
|
|||||||
var wait: UInt16 = 0
|
var wait: UInt16 = 0
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
|
||||||
|
|
||||||
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
||||||
print("no IP address found, is your VM running?")
|
throw RuntimeError.NoIPAddressFound("no IP address found, is your VM running?")
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let ipViaARP = try ARPCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
|
||||||
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
|
||||||
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
|
||||||
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
|
||||||
}
|
|
||||||
|
|
||||||
print(ipViaDHCP)
|
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let arpCache = try ARPCache()
|
||||||
|
|
||||||
|
if let ipViaARP = try arpCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
||||||
|
// Capture the warning into Sentry
|
||||||
|
SentrySDK.capture(message: "DHCP lease and ARP cache entries for a single MAC address differ") { scope in
|
||||||
|
scope.setLevel(.warning)
|
||||||
|
|
||||||
|
scope.setContext(value: [
|
||||||
|
"MAC address": vmMACAddress,
|
||||||
|
"IP via ARP": ipViaARP,
|
||||||
|
"IP via DHCP": ipViaDHCP,
|
||||||
|
], key: "Address conflict details")
|
||||||
|
|
||||||
|
scope.add(Attachment(path: "/var/db/dhcpd_leases", filename: "dhcpd_leases.txt", contentType: "text/plain"))
|
||||||
|
scope.add(Attachment(data: arpCache.arpCommandOutput, filename: "arp-an-output.txt", contentType: "text/plain"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
||||||
|
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
||||||
|
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
print(ipViaDHCP)
|
||||||
}
|
}
|
||||||
|
|
||||||
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
|
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
|
||||||
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
||||||
|
|
||||||
repeat {
|
repeat {
|
||||||
if let ip = try Leases().resolveMACAddress(macAddress: vmMACAddress) {
|
if let leases = try Leases(), let ip = try leases.resolveMACAddress(macAddress: vmMACAddress) {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|
||||||
try await Task.sleep(nanoseconds: 1_000_000)
|
// wait a second
|
||||||
|
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
} while Date.now < waitUntil
|
} while Date.now < waitUntil
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Import: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(abstract: "Import VM from a file")
|
||||||
|
|
||||||
|
@Argument(help: "Path to a file created with \"tart export\".")
|
||||||
|
var path: String
|
||||||
|
|
||||||
|
@Argument(help: "Destination VM name.")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
func validate() throws {
|
||||||
|
if name.contains("/") {
|
||||||
|
throw ValidationError("<name> should be a local name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let localStorage = VMStorageLocal()
|
||||||
|
|
||||||
|
// Create a temporary VM directory to which we will load the export file
|
||||||
|
let tmpVMDir = try VMDirectory.temporary()
|
||||||
|
|
||||||
|
// Lock the temporary VM directory to prevent it's garbage collection
|
||||||
|
// while we're running
|
||||||
|
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||||
|
try tmpVMDirLock.lock()
|
||||||
|
|
||||||
|
// Populate the temporary VM directory with the export file contents
|
||||||
|
print("importing...")
|
||||||
|
try tmpVMDir.importFromArchive(path: path)
|
||||||
|
|
||||||
|
try await withTaskCancellationHandler(operation: {
|
||||||
|
// Acquire a global lock
|
||||||
|
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||||
|
try lock.lock()
|
||||||
|
|
||||||
|
// Re-generate the VM's MAC address importing it will result in address collision
|
||||||
|
if try localStorage.hasVMsWithMACAddress(macAddress: tmpVMDir.macAddress()) {
|
||||||
|
try tmpVMDir.regenerateMACAddress()
|
||||||
|
}
|
||||||
|
|
||||||
|
try localStorage.move(name, from: tmpVMDir)
|
||||||
|
|
||||||
|
try lock.unlock()
|
||||||
|
}, onCancel: {
|
||||||
|
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,27 +2,57 @@ import ArgumentParser
|
|||||||
import Dispatch
|
import Dispatch
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
fileprivate struct VMInfo: Encodable {
|
||||||
|
let Source: String
|
||||||
|
let Name: String
|
||||||
|
let Size: Int
|
||||||
|
}
|
||||||
|
|
||||||
struct List: AsyncParsableCommand {
|
struct List: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "List created VMs")
|
static var configuration = CommandConfiguration(abstract: "List created VMs")
|
||||||
|
|
||||||
func run() async throws {
|
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
|
||||||
do {
|
var source: String?
|
||||||
print("Source\tName")
|
|
||||||
|
|
||||||
displayTable("local", try VMStorageLocal().list())
|
@Option(help: "Output format: text or json")
|
||||||
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
|
var format: Format = .text
|
||||||
|
|
||||||
Foundation.exit(0)
|
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
|
||||||
} catch {
|
var quiet: Bool = false
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
func validate() throws {
|
||||||
|
guard let source = source else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !["local", "oci"].contains(source) {
|
||||||
|
throw ValidationError("'\(source)' is not a valid <source>")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
|
func run() async throws {
|
||||||
for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) {
|
var infos: [VMInfo] = []
|
||||||
print("\(source)\t\(name)")
|
if source == nil || source == "local" {
|
||||||
|
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
||||||
|
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if source == nil || source == "oci" {
|
||||||
|
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
||||||
|
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,40 +27,30 @@ struct Login: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
var user: String
|
||||||
var user: String
|
var password: String
|
||||||
var password: String
|
|
||||||
|
|
||||||
if let username = username {
|
if let username = username {
|
||||||
user = username
|
user = username
|
||||||
|
|
||||||
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
||||||
password = String(decoding: passwordData, as: UTF8.self)
|
password = String(decoding: passwordData, as: UTF8.self)
|
||||||
} else {
|
} else {
|
||||||
(user, password) = try StdinCredentials.retrieve()
|
(user, password) = try StdinCredentials.retrieve()
|
||||||
}
|
|
||||||
let credentialsProvider = DictionaryCredentialsProvider([
|
|
||||||
host: (user, password)
|
|
||||||
])
|
|
||||||
|
|
||||||
do {
|
|
||||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
|
||||||
credentialsProviders: [credentialsProvider])
|
|
||||||
try await registry.ping()
|
|
||||||
} catch {
|
|
||||||
print("invalid credentials: \(error)")
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
}
|
||||||
|
let credentialsProvider = DictionaryCredentialsProvider([
|
||||||
|
host: (user, password)
|
||||||
|
])
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import Dispatch
|
import Dispatch
|
||||||
|
import Sentry
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftDate
|
import SwiftDate
|
||||||
|
|
||||||
@@ -7,13 +8,13 @@ struct Prune: AsyncParsableCommand {
|
|||||||
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
|
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("Remove cache entries last accessed more than n days ago",
|
@Option(help: ArgumentHelp("Remove cache entries last accessed more than n days ago",
|
||||||
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
|
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
|
||||||
valueName: "n"))
|
valueName: "n"))
|
||||||
var olderThan: UInt?
|
var olderThan: UInt?
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("Remove least recently used cache entries that do not fit the specified cache size budget n, expressed in gigabytes",
|
@Option(help: ArgumentHelp("Remove least recently used cache entries that do not fit the specified cache size budget n, expressed in gigabytes",
|
||||||
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
|
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
|
||||||
valueName: "n"))
|
valueName: "n"))
|
||||||
var cacheBudget: UInt?
|
var cacheBudget: UInt?
|
||||||
|
|
||||||
@Flag(help: .hidden)
|
@Flag(help: .hidden)
|
||||||
@@ -26,29 +27,21 @@ struct Prune: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
if gc {
|
||||||
if gc {
|
try VMStorageOCI().gc()
|
||||||
try VMStorageOCI().gc()
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up cache entries based on last accessed date
|
// Clean up cache entries based on last accessed date
|
||||||
if let olderThan = olderThan {
|
if let olderThan = olderThan {
|
||||||
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
||||||
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
|
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
|
||||||
|
|
||||||
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
|
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
||||||
if let cacheBudget = cacheBudget {
|
if let cacheBudget = cacheBudget {
|
||||||
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
|
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
|
||||||
}
|
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,29 +55,33 @@ struct Prune: AsyncParsableCommand {
|
|||||||
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
|
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
|
||||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||||
let prunables: [Prunable] = try prunableStorages
|
let prunables: [Prunable] = try prunableStorages
|
||||||
.flatMap { try $0.prunables() }
|
.flatMap { try $0.prunables() }
|
||||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
.sorted { try $0.accessDate() > $1.accessDate() }
|
||||||
|
|
||||||
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
var cacheBudgetBytes = cacheBudgetBytes
|
||||||
var cacheReclaimedBytes: Int = 0
|
var prunablesToDelete: [Prunable] = []
|
||||||
|
|
||||||
var it = prunables.makeIterator()
|
for prunable in prunables {
|
||||||
|
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
|
||||||
|
|
||||||
while (cacheUsedBytes - cacheReclaimedBytes) > cacheBudgetBytes {
|
if prunableSizeBytes <= cacheBudgetBytes {
|
||||||
guard let prunable = it.next() else {
|
// Don't mark for deletion as
|
||||||
break
|
// there's a budget available
|
||||||
|
cacheBudgetBytes -= prunableSizeBytes
|
||||||
|
} else {
|
||||||
|
// Mark for deletion
|
||||||
|
prunablesToDelete.append(prunable)
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheReclaimedBytes -= try prunable.sizeBytes()
|
|
||||||
try prunable.delete()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try prunablesToDelete.forEach { try $0.delete() }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func pruneReclaim(reclaimBytes: UInt64) throws {
|
static func pruneReclaim(reclaimBytes: UInt64) throws {
|
||||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||||
let prunables: [Prunable] = try prunableStorages
|
let prunables: [Prunable] = try prunableStorages
|
||||||
.flatMap { try $0.prunables() }
|
.flatMap { try $0.prunables() }
|
||||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||||
|
|
||||||
// Does it even make sense to start?
|
// Does it even make sense to start?
|
||||||
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
||||||
@@ -103,6 +100,10 @@ struct Prune: AsyncParsableCommand {
|
|||||||
|
|
||||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||||
try prunable.delete()
|
try prunable.delete()
|
||||||
|
|
||||||
|
try SentrySDK.span?.setExtra(value: prunable.sizeBytes(), key: prunable.url.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,27 +12,19 @@ struct Pull: AsyncParsableCommand {
|
|||||||
var insecure: Bool = false
|
var insecure: Bool = false
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
// Be more liberal when accepting local image as argument,
|
||||||
// Be more liberal when accepting local image as argument,
|
// see https://github.com/cirruslabs/tart/issues/36
|
||||||
// see https://github.com/cirruslabs/tart/issues/36
|
if VMStorageLocal().exists(remoteName) {
|
||||||
if VMStorageLocal().exists(remoteName) {
|
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
||||||
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
|
||||||
|
|
||||||
Foundation.exit(0)
|
return
|
||||||
}
|
|
||||||
|
|
||||||
let remoteName = try RemoteName(remoteName)
|
|
||||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
|
||||||
|
|
||||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
|
||||||
|
|
||||||
try await VMStorageOCI().pull(remoteName, registry: registry)
|
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,66 +16,58 @@ struct Push: AsyncParsableCommand {
|
|||||||
var insecure: Bool = false
|
var insecure: Bool = false
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
|
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
|
||||||
discussion: """
|
discussion: """
|
||||||
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
|
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
|
||||||
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.
|
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.
|
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
|
var chunkSize: Int = 0
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp("cache pushed images locally",
|
@Flag(help: ArgumentHelp("cache pushed images locally",
|
||||||
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
|
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
|
||||||
var populateCache: Bool = false
|
var populateCache: Bool = false
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
let localVMDir = try VMStorageLocal().open(localName)
|
||||||
let localVMDir = try VMStorageLocal().open(localName)
|
|
||||||
|
|
||||||
// Parse remote names supplied by the user
|
// Parse remote names supplied by the user
|
||||||
let remoteNames = try remoteNames.map{
|
let remoteNames = try remoteNames.map{
|
||||||
try RemoteName($0)
|
try RemoteName($0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group remote names by registry
|
// Group remote names by registry
|
||||||
struct RegistryIdentifier: Hashable, Equatable {
|
struct RegistryIdentifier: Hashable, Equatable {
|
||||||
var host: String
|
var host: String
|
||||||
var namespace: String
|
var namespace: String
|
||||||
}
|
}
|
||||||
|
|
||||||
let registryGroups = Dictionary(grouping: remoteNames, by: {
|
let registryGroups = Dictionary(grouping: remoteNames, by: {
|
||||||
RegistryIdentifier(host: $0.host, namespace: $0.namespace)
|
RegistryIdentifier(host: $0.host, namespace: $0.namespace)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Push VM
|
// Push VM
|
||||||
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
||||||
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
|
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
|
||||||
insecure: insecure)
|
insecure: insecure)
|
||||||
|
|
||||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||||
|
|
||||||
let pushedRemoteName = try await localVMDir.pushToRegistry(
|
let pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||||
registry: registry,
|
registry: registry,
|
||||||
references: remoteNamesForRegistry.map{ $0.reference.value },
|
references: remoteNamesForRegistry.map{ $0.reference.value },
|
||||||
chunkSizeMb: chunkSize
|
chunkSizeMb: chunkSize
|
||||||
)
|
)
|
||||||
|
|
||||||
// Populate the local cache (if requested)
|
// Populate the local cache (if requested)
|
||||||
if populateCache {
|
if populateCache {
|
||||||
let ociStorage = VMStorageOCI()
|
let ociStorage = VMStorageOCI()
|
||||||
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
||||||
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
||||||
for remoteName in remoteNamesForRegistry {
|
for remoteName in remoteNamesForRegistry {
|
||||||
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,25 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import Sentry
|
||||||
|
|
||||||
|
struct ReportInstallation: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(
|
||||||
|
commandName: "report-installation",
|
||||||
|
abstract: "Send installation event to Sentry if configured",
|
||||||
|
discussion: """
|
||||||
|
Reports macOS version and device model for analytics purposes.
|
||||||
|
Helps Cirrus Labs team to prioritize testing on most popular devices.
|
||||||
|
""",
|
||||||
|
shouldDisplay: false
|
||||||
|
)
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let installationEvent = Event()
|
||||||
|
installationEvent.message = SentryMessage(formatted: "installed")
|
||||||
|
installationEvent.level = SentryLevel.info
|
||||||
|
installationEvent.user = nil
|
||||||
|
installationEvent.stacktrace = nil
|
||||||
|
let id = SentrySDK.capture(event: installationEvent)
|
||||||
|
print("Captured installation event #\(id)!")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import ArgumentParser
|
|||||||
import Dispatch
|
import Dispatch
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
import Sentry
|
||||||
|
|
||||||
var vm: VM?
|
var vm: VM?
|
||||||
|
|
||||||
@@ -15,17 +16,20 @@ struct Run: AsyncParsableCommand {
|
|||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp(
|
@Flag(help: ArgumentHelp(
|
||||||
"Don't open a UI window.",
|
"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."))
|
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
|
||||||
var noGraphics: Bool = false
|
var noGraphics: Bool = false
|
||||||
|
|
||||||
|
@Flag(help: "Force open a UI window, even when VNC is enabled.")
|
||||||
|
var graphics: Bool = false
|
||||||
|
|
||||||
@Flag(help: "Boot into recovery mode")
|
@Flag(help: "Boot into recovery mode")
|
||||||
var recovery: Bool = false
|
var recovery: Bool = false
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp(
|
@Flag(help: ArgumentHelp(
|
||||||
"Use screen sharing instead of the built-in UI.",
|
"Use screen sharing instead of the built-in UI.",
|
||||||
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
|
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
|
||||||
+ "Note that Remote Login option should be enabled inside the VM."))
|
+ "Note that Remote Login option should be enabled inside the VM."))
|
||||||
var vnc: Bool = false
|
var vnc: Bool = false
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp(
|
@Flag(help: ArgumentHelp(
|
||||||
@@ -34,38 +38,87 @@ struct Run: AsyncParsableCommand {
|
|||||||
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
||||||
var vncExperimental: Bool = false
|
var vncExperimental: Bool = false
|
||||||
|
|
||||||
@Flag var withSoftnet: Bool = false
|
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("""
|
@Option(help: ArgumentHelp("""
|
||||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
|
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
|
||||||
""", discussion: """
|
""", discussion: """
|
||||||
Learn how to create a disk image using Disk Utility here:
|
Learn how to create a disk image using Disk Utility here:
|
||||||
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
|
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
|
||||||
""", valueName: "path[:ro]"))
|
""", valueName: "path[:ro]"))
|
||||||
var disk: [String] = []
|
var disk: [String] = []
|
||||||
|
|
||||||
|
@Option(name: [.customLong("rosetta")], help: ArgumentHelp(
|
||||||
|
"Attaches a Rosetta share to the guest Linux VM with a specific tag (e.g. --rosetta=\"rosetta\")",
|
||||||
|
discussion: """
|
||||||
|
Requires host to be macOS 13.0 (Ventura) with Rosetta installed. The latter can be done
|
||||||
|
by running "softwareupdate --install-rosetta" (without quotes) in the Terminal.app.
|
||||||
|
|
||||||
|
Note that you also have to configure Rosetta in the guest Linux VM by following the
|
||||||
|
steps from "Mount the Shared Directory and Register Rosetta" section here:
|
||||||
|
https://developer.apple.com/documentation/virtualization/running_intel_binaries_in_linux_vms_with_rosetta#3978496
|
||||||
|
""",
|
||||||
|
valueName: "tag"
|
||||||
|
))
|
||||||
|
var rosettaTag: String?
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("""
|
@Option(help: ArgumentHelp("""
|
||||||
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
|
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
|
||||||
""", discussion: """
|
""", discussion: """
|
||||||
All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS,
|
Requires host to be macOS 13.0 (Ventura) or newer.
|
||||||
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
|
All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS,
|
||||||
""", valueName: "name:path[:ro]"))
|
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.
|
||||||
|
""", valueName: "name:path[:ro]"))
|
||||||
var dir: [String] = []
|
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
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
if vnc && vncExperimental {
|
if vnc && vncExperimental {
|
||||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||||
}
|
}
|
||||||
|
if netBridged != nil && netSoftnet {
|
||||||
|
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if graphics && noGraphics {
|
||||||
|
throw ValidationError("--graphics and --no-graphics are mutually exclusive")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
|
|
||||||
|
let additionalDiskAttachments = try additionalDiskAttachments()
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
for additionalDiskAttachment in additionalDiskAttachments {
|
||||||
|
// Read-only attachments do not seem to acquire the lock
|
||||||
|
if additionalDiskAttachment.isReadOnly {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if try !FileLock(lockURL: additionalDiskAttachment.url).trylock() {
|
||||||
|
throw RuntimeError.DiskAlreadyInUse("disk \(additionalDiskAttachment.url.path) seems to be already in use, "
|
||||||
|
+ "unmount it first in Finder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
vm = try VM(
|
vm = try VM(
|
||||||
vmDir: vmDir,
|
vmDir: vmDir,
|
||||||
withSoftnet: withSoftnet,
|
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||||
additionalDiskAttachments: additionalDiskAttachments(),
|
additionalDiskAttachments: additionalDiskAttachments,
|
||||||
directoryShares: directoryShares()
|
directorySharingDevices: directoryShares() + rosettaDirectoryShare()
|
||||||
)
|
)
|
||||||
|
|
||||||
let vncImpl: VNC? = try {
|
let vncImpl: VNC? = try {
|
||||||
@@ -79,6 +132,23 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Lock the VM
|
||||||
|
//
|
||||||
|
// More specifically, lock the "config.json", because we can't lock
|
||||||
|
// directories with fcntl(2)-based locking and we better not interfere
|
||||||
|
// with the VM's disk and NVRAM, because they are opened (and even seem
|
||||||
|
// to be locked) directly by the Virtualization.Framework's process.
|
||||||
|
//
|
||||||
|
// Note that due to "completely stupid semantics"[1] of the fcntl-based
|
||||||
|
// file locking, we need to acquire the lock after we read the VM's
|
||||||
|
// configuration file, otherwise we will loose the lock.
|
||||||
|
//
|
||||||
|
// [1]: https://man.openbsd.org/fcntl
|
||||||
|
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||||
|
if try !lock.trylock() {
|
||||||
|
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
|
||||||
|
}
|
||||||
|
|
||||||
let task = Task {
|
let task = Task {
|
||||||
do {
|
do {
|
||||||
if let vncImpl = vncImpl {
|
if let vncImpl = vncImpl {
|
||||||
@@ -100,12 +170,12 @@ struct Run: AsyncParsableCommand {
|
|||||||
|
|
||||||
Foundation.exit(0)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
|
// Capture the error into Sentry
|
||||||
print("Virtual machine \"\(name)\" is already running!")
|
SentrySDK.capture(error: error)
|
||||||
Foundation.exit(2)
|
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||||
}
|
|
||||||
|
|
||||||
print(error)
|
print(error)
|
||||||
|
|
||||||
Foundation.exit(1)
|
Foundation.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,18 +186,61 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
sigintSrc.activate()
|
sigintSrc.activate()
|
||||||
|
|
||||||
if noGraphics || vnc || vncExperimental {
|
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||||
|
if noGraphics || useVNCWithoutGraphics {
|
||||||
dispatchMain()
|
dispatchMain()
|
||||||
} else {
|
} else {
|
||||||
runUI()
|
runUI()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
|
||||||
|
if netSoftnet {
|
||||||
|
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
|
|
||||||
|
return try Softnet(vmMACAddress: config.macAddress.string)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let netBridged = netBridged {
|
||||||
|
let matchingInterfaces = VZBridgedNetworkInterface.networkInterfaces.filter { interface in
|
||||||
|
interface.identifier == netBridged || interface.localizedDisplayName == netBridged
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchingInterfaces.isEmpty {
|
||||||
|
let available = bridgeInterfaces().joined(separator: ", ")
|
||||||
|
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
|
||||||
|
+ "available interfaces: \(available)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchingInterfaces.count > 1 {
|
||||||
|
throw ValidationError("more than one bridge interface matched \"\(netBridged)\", "
|
||||||
|
+ "consider refining the search criteria")
|
||||||
|
}
|
||||||
|
|
||||||
|
return NetworkBridged(interface: matchingInterfaces.first!)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 -> [VZDiskImageStorageDeviceAttachment] {
|
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
|
||||||
var result: [VZDiskImageStorageDeviceAttachment] = []
|
var result: [VZDiskImageStorageDeviceAttachment] = []
|
||||||
let readOnlySuffix = ":ro"
|
let readOnlySuffix = ":ro"
|
||||||
|
let expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
|
||||||
|
|
||||||
for rawDisk in disk {
|
for rawDisk in expandedDiskPaths {
|
||||||
if rawDisk.hasSuffix(readOnlySuffix) {
|
if rawDisk.hasSuffix(readOnlySuffix) {
|
||||||
result.append(try VZDiskImageStorageDeviceAttachment(
|
result.append(try VZDiskImageStorageDeviceAttachment(
|
||||||
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
|
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
|
||||||
@@ -144,8 +257,22 @@ struct Run: AsyncParsableCommand {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func directoryShares() throws -> [DirectoryShare] {
|
func directoryShares() throws -> [VZDirectorySharingDeviceConfiguration] {
|
||||||
var result: [DirectoryShare] = []
|
if dir.isEmpty {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
guard #available(macOS 13, *) else {
|
||||||
|
throw UnsupportedOSError("directory sharing", "is")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DirectoryShare {
|
||||||
|
let name: String
|
||||||
|
let path: URL
|
||||||
|
let readOnly: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var directoryShares: [DirectoryShare] = []
|
||||||
|
|
||||||
for rawDir in dir {
|
for rawDir in dir {
|
||||||
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
|
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
|
||||||
@@ -166,10 +293,46 @@ struct Run: AsyncParsableCommand {
|
|||||||
|
|
||||||
let (name, path) = (String(splits[0]), String(splits[1]))
|
let (name, path) = (String(splits[0]), String(splits[1]))
|
||||||
|
|
||||||
result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: path), readOnly: readOnly))
|
directoryShares.append(DirectoryShare(
|
||||||
|
name: name,
|
||||||
|
path: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath),
|
||||||
|
readOnly: readOnly)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
var directories: [String : VZSharedDirectory] = Dictionary()
|
||||||
|
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
|
||||||
|
|
||||||
|
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
||||||
|
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
|
||||||
|
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
|
||||||
|
|
||||||
|
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() {
|
private func runUI() {
|
||||||
@@ -190,16 +353,16 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
||||||
}.commands {
|
}.commands {
|
||||||
// Remove some standard menu options
|
// Remove some standard menu options
|
||||||
CommandGroup(replacing: .help, addition: {})
|
CommandGroup(replacing: .help, addition: {})
|
||||||
CommandGroup(replacing: .newItem, addition: {})
|
CommandGroup(replacing: .newItem, addition: {})
|
||||||
CommandGroup(replacing: .pasteboard, addition: {})
|
CommandGroup(replacing: .pasteboard, addition: {})
|
||||||
CommandGroup(replacing: .textEditing, addition: {})
|
CommandGroup(replacing: .textEditing, addition: {})
|
||||||
CommandGroup(replacing: .undoRedo, addition: {})
|
CommandGroup(replacing: .undoRedo, addition: {})
|
||||||
CommandGroup(replacing: .windowSize, addition: {})
|
CommandGroup(replacing: .windowSize, addition: {})
|
||||||
// Replace some standard menu options
|
// Replace some standard menu options
|
||||||
CommandGroup(replacing: .appInfo) { AboutTart() }
|
CommandGroup(replacing: .appInfo) { AboutTart() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ struct Set: AsyncParsableCommand {
|
|||||||
var cpu: UInt16?
|
var cpu: UInt16?
|
||||||
|
|
||||||
@Option(help: "VM memory size in megabytes")
|
@Option(help: "VM memory size in megabytes")
|
||||||
var memory: UInt16?
|
var memory: UInt64?
|
||||||
|
|
||||||
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||||
var display: VMDisplayConfig?
|
var display: VMDisplayConfig?
|
||||||
@@ -20,38 +20,30 @@ struct Set: AsyncParsableCommand {
|
|||||||
var diskSize: UInt16?
|
var diskSize: UInt16?
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
|
||||||
|
|
||||||
if let cpu = cpu {
|
if let cpu = cpu {
|
||||||
try vmConfig.setCPU(cpuCount: Int(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) {
|
||||||
if let memory = memory {
|
vmConfig.display.height = display.height
|
||||||
try vmConfig.setMemory(memorySize: UInt64(memory) * 1024 * 1024)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let display = display {
|
try vmConfig.save(toURL: vmDir.configURL)
|
||||||
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!)
|
||||||
if diskSize != nil {
|
|
||||||
try vmDir.resizeDisk(diskSize!)
|
|
||||||
}
|
|
||||||
|
|
||||||
Foundation.exit(0)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
|
|
||||||
Foundation.exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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)
|
||||||
|
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||||
|
|
||||||
|
// Find the VM's PID
|
||||||
|
var pid = try lock.pid()
|
||||||
|
if pid == 0 {
|
||||||
|
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import Foundation
|
|||||||
struct Config {
|
struct Config {
|
||||||
let tartHomeDir: URL
|
let tartHomeDir: URL
|
||||||
let tartCacheDir: URL
|
let tartCacheDir: URL
|
||||||
|
let tartTmpDir: URL
|
||||||
|
|
||||||
init() throws {
|
init() throws {
|
||||||
var tartHomeDir: URL
|
var tartHomeDir: URL
|
||||||
@@ -11,14 +12,30 @@ struct Config {
|
|||||||
tartHomeDir = URL(fileURLWithPath: customTartHome)
|
tartHomeDir = URL(fileURLWithPath: customTartHome)
|
||||||
} else {
|
} else {
|
||||||
tartHomeDir = FileManager.default
|
tartHomeDir = FileManager.default
|
||||||
.homeDirectoryForCurrentUser
|
.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".tart", isDirectory: true)
|
.appendingPathComponent(".tart", isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.tartHomeDir = tartHomeDir
|
self.tartHomeDir = tartHomeDir
|
||||||
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
|
|
||||||
|
|
||||||
|
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: tartCacheDir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: tartCacheDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
tartTmpDir = tartHomeDir.appendingPathComponent("tmp", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: tartTmpDir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func gc() throws {
|
||||||
|
for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
|
||||||
|
includingPropertiesForKeys: [], options: []) {
|
||||||
|
let lock = try FileLock(lockURL: entry)
|
||||||
|
if try !lock.trylock() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try FileManager.default.removeItem(at: entry)
|
||||||
|
|
||||||
|
try lock.unlock()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func jsonEncoder() -> JSONEncoder {
|
static func jsonEncoder() -> JSONEncoder {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum CredentialsProviderError: Error {
|
enum CredentialsProviderError: Error {
|
||||||
case Failed(message: String)
|
case Failed(message: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol CredentialsProvider {
|
protocol CredentialsProvider {
|
||||||
func retrieve(host: String) throws -> (String, String)?
|
func retrieve(host: String) throws -> (String, String)?
|
||||||
func store(host: String, user: String, password: String) throws
|
func store(host: String, user: String, password: String) throws
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
guard let executableURL = resolveBinaryPath(binaryName) else {
|
guard let executableURL = resolveBinaryPath(binaryName) else {
|
||||||
throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH")
|
throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH")
|
||||||
}
|
}
|
||||||
|
|
||||||
let process = Process.init()
|
let process = Process.init()
|
||||||
process.executableURL = executableURL
|
process.executableURL = executableURL
|
||||||
process.arguments = ["get"]
|
process.arguments = ["get"]
|
||||||
|
|
||||||
let outPipe = Pipe()
|
let outPipe = Pipe()
|
||||||
let inPipe = Pipe()
|
let inPipe = Pipe()
|
||||||
|
|
||||||
process.standardOutput = outPipe
|
process.standardOutput = outPipe
|
||||||
process.standardError = outPipe
|
process.standardError = outPipe
|
||||||
process.standardInput = inPipe
|
process.standardInput = inPipe
|
||||||
@@ -38,7 +38,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
|
|
||||||
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
||||||
inPipe.fileHandleForWriting.closeFile()
|
inPipe.fileHandleForWriting.closeFile()
|
||||||
|
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
|
|
||||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class EnvironmentCredentialsProvider: CredentialsProvider {
|
||||||
|
func retrieve(host: String) throws -> (String, String)? {
|
||||||
|
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 {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +1,66 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class KeychainCredentialsProvider: CredentialsProvider {
|
class KeychainCredentialsProvider: CredentialsProvider {
|
||||||
func retrieve(host: String) throws -> (String, String)? {
|
func retrieve(host: String) throws -> (String, String)? {
|
||||||
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||||
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
||||||
kSecAttrServer as String: host,
|
kSecAttrServer as String: host,
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
kSecReturnAttributes as String: true,
|
kSecReturnAttributes as String: true,
|
||||||
kSecReturnData as String: true,
|
kSecReturnData as String: true,
|
||||||
kSecAttrLabel as String: "Tart Credentials",
|
kSecAttrLabel as String: "Tart Credentials",
|
||||||
]
|
]
|
||||||
|
|
||||||
var item: CFTypeRef?
|
var item: CFTypeRef?
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||||
|
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
if status == errSecItemNotFound {
|
if status == errSecItemNotFound {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
|
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 {
|
guard let item = item as? [String: Any],
|
||||||
let passwordData = password.data(using: .utf8)
|
let user = item[kSecAttrAccount as String] as? String,
|
||||||
let key: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
let passwordData = item[kSecValueData as String] as? Data,
|
||||||
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
let password = String(data: passwordData, encoding: .utf8)
|
||||||
kSecAttrServer as String: host,
|
else {
|
||||||
kSecAttrLabel as String: "Tart Credentials",
|
throw CredentialsProviderError.Failed(message: "Keychain item has unexpected format")
|
||||||
]
|
|
||||||
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())")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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())")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension OSStatus {
|
extension OSStatus {
|
||||||
|
|||||||
@@ -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 fh = try FileHandle(forReadingFrom: fileURL)
|
||||||
|
try FileManager.default.removeItem(at: fileURL)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
while let data = try fh.read(upToCount: 64 * 1024 * 1024) {
|
||||||
|
await dataCh.send(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -2,47 +2,47 @@ import Foundation
|
|||||||
import System
|
import System
|
||||||
|
|
||||||
enum FileLockError: Error, Equatable {
|
enum FileLockError: Error, Equatable {
|
||||||
case Failed(_ message: String)
|
case Failed(_ message: String)
|
||||||
case AlreadyLocked
|
case AlreadyLocked
|
||||||
}
|
}
|
||||||
|
|
||||||
class FileLock {
|
class FileLock {
|
||||||
let url: URL
|
let url: URL
|
||||||
let fd: Int32
|
let fd: Int32
|
||||||
|
|
||||||
init(lockURL: URL) throws {
|
init(lockURL: URL) throws {
|
||||||
url = lockURL
|
url = lockURL
|
||||||
fd = open(lockURL.path, 0)
|
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)")
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
return true
|
||||||
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,41 @@
|
|||||||
|
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().map { (_, element) in
|
||||||
|
let fieldName = element.label!
|
||||||
|
return Column(title: fieldName, value: element.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
case .json:
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = .prettyPrinted
|
||||||
|
return try! encoder.encode(data).asText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,12 @@ class IPSWCache: PrunableStorage {
|
|||||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func locationFor(image: VZMacOSRestoreImage) -> URL {
|
func locationFor(fileName: String) -> URL {
|
||||||
baseURL.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false)
|
baseURL.appendingPathComponent(fileName, isDirectory: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func prunables() throws -> [Prunable] {
|
func prunables() throws -> [Prunable] {
|
||||||
try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)
|
try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)
|
||||||
.filter { $0.lastPathComponent.hasSuffix(".ipsw")}
|
.filter { $0.lastPathComponent.hasSuffix(".ipsw")}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,117 +3,124 @@ import Network
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
struct ARPCommandFailedError: Error, CustomStringConvertible {
|
struct ARPCommandFailedError: Error, CustomStringConvertible {
|
||||||
var terminationReason: Process.TerminationReason
|
var terminationReason: Process.TerminationReason
|
||||||
var terminationStatus: Int32
|
var terminationStatus: Int32
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
var reason: String
|
var reason: String
|
||||||
|
|
||||||
switch terminationReason {
|
switch terminationReason {
|
||||||
case .exit:
|
case .exit:
|
||||||
reason = "exit code \(terminationStatus)"
|
reason = "exit code \(terminationStatus)"
|
||||||
case .uncaughtSignal:
|
case .uncaughtSignal:
|
||||||
reason = "uncaught signal"
|
reason = "uncaught signal"
|
||||||
default:
|
default:
|
||||||
reason = "unknown reason"
|
reason = "unknown reason"
|
||||||
}
|
|
||||||
|
|
||||||
return "arp command failed: \(reason)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return "arp command failed: \(reason)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
|
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
|
||||||
var explanation: String
|
var explanation: String
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
"arp command yielded invalid output: \(explanation)"
|
"arp command yielded invalid output: \(explanation)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ARPCacheInternalError: Error, CustomStringConvertible {
|
struct ARPCacheInternalError: Error, CustomStringConvertible {
|
||||||
var explanation: String
|
var explanation: String
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
"ARPCache internal error: \(explanation)"
|
"ARPCache internal error: \(explanation)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ARPCache {
|
struct ARPCache {
|
||||||
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
let arpCommandOutput: Data
|
||||||
let process = Process.init()
|
|
||||||
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
|
||||||
process.arguments = ["-an"]
|
|
||||||
|
|
||||||
let pipe = Pipe()
|
init() throws {
|
||||||
process.standardOutput = pipe
|
let process = Process.init()
|
||||||
process.standardError = pipe
|
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
||||||
process.standardInput = FileHandle.nullDevice
|
process.arguments = ["-an"]
|
||||||
|
|
||||||
try process.run()
|
let pipe = Pipe()
|
||||||
process.waitUntilExit()
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
process.standardInput = FileHandle.nullDevice
|
||||||
|
|
||||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
try process.run()
|
||||||
throw ARPCommandFailedError(
|
process.waitUntilExit()
|
||||||
terminationReason: process.terminationReason,
|
|
||||||
terminationStatus: process.terminationStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
|
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
throw ARPCommandFailedError(
|
||||||
}
|
terminationReason: process.terminationReason,
|
||||||
let lines = String(decoding: rawLines, as: UTF8.self)
|
terminationStatus: process.terminationStatus)
|
||||||
.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)")
|
|
||||||
}
|
|
||||||
|
|
||||||
let interface = try match.getCaptureGroup(name: "interface", for: line)
|
|
||||||
if bridgeOnly && !interface.starts(with: "bridge") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if macAddress == mac {
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
||||||
|
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||||
|
}
|
||||||
|
|
||||||
|
self.arpCommandOutput = arpCommandOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) 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)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let interface = try match.getCaptureGroup(name: "interface", for: line)
|
||||||
|
if bridgeOnly && !interface.starts(with: "bridge") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if macAddress == mac {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension NSTextCheckingResult {
|
extension NSTextCheckingResult {
|
||||||
func getCaptureGroup(name: String, for string: String) throws -> String {
|
func getCaptureGroup(name: String, for string: String) throws -> String {
|
||||||
let nsRange = self.range(withName: name)
|
let nsRange = self.range(withName: name)
|
||||||
|
|
||||||
if nsRange.location == NSNotFound {
|
if nsRange.location == NSNotFound {
|
||||||
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
|
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])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let range = Range.init(nsRange, in: string) else {
|
||||||
|
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(string[range])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,14 +19,21 @@ enum LeasesError: Error {
|
|||||||
class Leases {
|
class Leases {
|
||||||
private let leases: [MACAddress : Lease]
|
private let leases: [MACAddress : Lease]
|
||||||
|
|
||||||
convenience init() throws {
|
convenience init?() throws {
|
||||||
try self.init(URL(fileURLWithPath: "/var/db/dhcpd_leases"))
|
try self.init(URL(fileURLWithPath: "/var/db/dhcpd_leases"))
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(_ fromURL: URL) throws {
|
convenience init?(_ fromURL: URL) throws {
|
||||||
let fileContents = try String(contentsOf: fromURL, encoding: .utf8)
|
do {
|
||||||
|
let urlContents = try String(contentsOf: fromURL, encoding: .utf8)
|
||||||
|
try self.init(urlContents)
|
||||||
|
} catch {
|
||||||
|
if error.isFileNotFound() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
try self.init(fileContents)
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(_ fromString: String) throws {
|
init(_ fromString: String) throws {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Virtualization
|
||||||
|
|
||||||
|
protocol Network {
|
||||||
|
func attachment() -> VZNetworkDeviceAttachment
|
||||||
|
func run(_ sema: DispatchSemaphore) throws
|
||||||
|
func stop() async throws
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
class NetworkBridged: Network {
|
||||||
|
let interface: VZBridgedNetworkInterface
|
||||||
|
|
||||||
|
init(interface: VZBridgedNetworkInterface) {
|
||||||
|
self.interface = interface
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachment() -> VZNetworkDeviceAttachment {
|
||||||
|
VZBridgedNetworkDeviceAttachment(interface: interface)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 attachment() -> 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,94 @@
|
|||||||
|
import Foundation
|
||||||
|
import Virtualization
|
||||||
|
import Atomics
|
||||||
|
|
||||||
|
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 binaryName = "softnet"
|
||||||
|
|
||||||
|
guard let executableURL = resolveBinaryPath(binaryName) else {
|
||||||
|
throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||||
|
|
||||||
|
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
||||||
|
if ret != 0 {
|
||||||
|
throw SoftnetError.InitializationFailed(why: "socketpair() failed with exit code \(ret)")
|
||||||
|
}
|
||||||
|
|
||||||
|
vmFD = fds[0]
|
||||||
|
let softnetFD = fds[1]
|
||||||
|
|
||||||
|
try setSocketBuffers(vmFD, 1 * 1024 * 1024);
|
||||||
|
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
||||||
|
|
||||||
|
process.executableURL = executableURL
|
||||||
|
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress]
|
||||||
|
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(_ 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 attachment() -> VZNetworkDeviceAttachment {
|
||||||
|
let fh = FileHandle.init(fileDescriptor: vmFD)
|
||||||
|
return VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,8 @@ class Digest {
|
|||||||
extension SHA256.Digest {
|
extension SHA256.Digest {
|
||||||
func hexdigest() -> String {
|
func hexdigest() -> String {
|
||||||
"sha256:" + self.map {
|
"sha256:" + self.map {
|
||||||
String(format: "%02x", $0)
|
String(format: "%02x", $0)
|
||||||
}
|
}
|
||||||
.joined()
|
.joined()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ null
|
|||||||
':'
|
':'
|
||||||
'/'
|
'/'
|
||||||
'.'
|
'.'
|
||||||
'@'
|
|
||||||
'-'
|
'-'
|
||||||
|
'@'
|
||||||
'_'
|
'_'
|
||||||
null
|
null
|
||||||
null
|
null
|
||||||
@@ -24,6 +24,7 @@ rule names:
|
|||||||
root
|
root
|
||||||
host
|
host
|
||||||
port
|
port
|
||||||
|
host_component
|
||||||
namespace
|
namespace
|
||||||
namespace_component
|
namespace_component
|
||||||
reference
|
reference
|
||||||
@@ -33,4 +34,4 @@ name
|
|||||||
|
|
||||||
|
|
||||||
atn:
|
atn:
|
||||||
[4, 1, 8, 85, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 1, 0, 1, 0, 1, 0, 3, 0, 22, 8, 0, 1, 0, 1, 0, 1, 0, 3, 0, 27, 8, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 5, 1, 34, 8, 1, 10, 1, 12, 1, 37, 9, 1, 1, 2, 4, 2, 40, 8, 2, 11, 2, 12, 2, 41, 1, 3, 1, 3, 1, 3, 5, 3, 47, 8, 3, 10, 3, 12, 3, 50, 9, 3, 1, 4, 1, 4, 3, 4, 54, 8, 4, 4, 4, 56, 8, 4, 11, 4, 12, 4, 57, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 3, 5, 67, 8, 5, 1, 6, 1, 6, 1, 6, 1, 6, 5, 6, 73, 8, 6, 10, 6, 12, 6, 76, 9, 6, 1, 7, 1, 7, 1, 8, 4, 8, 81, 8, 8, 11, 8, 12, 8, 82, 1, 8, 0, 0, 9, 0, 2, 4, 6, 8, 10, 12, 14, 16, 0, 2, 2, 0, 3, 3, 5, 6, 1, 0, 7, 8, 85, 0, 18, 1, 0, 0, 0, 2, 30, 1, 0, 0, 0, 4, 39, 1, 0, 0, 0, 6, 43, 1, 0, 0, 0, 8, 55, 1, 0, 0, 0, 10, 66, 1, 0, 0, 0, 12, 68, 1, 0, 0, 0, 14, 77, 1, 0, 0, 0, 16, 80, 1, 0, 0, 0, 18, 21, 3, 2, 1, 0, 19, 20, 5, 1, 0, 0, 20, 22, 3, 4, 2, 0, 21, 19, 1, 0, 0, 0, 21, 22, 1, 0, 0, 0, 22, 23, 1, 0, 0, 0, 23, 24, 5, 2, 0, 0, 24, 26, 3, 6, 3, 0, 25, 27, 3, 10, 5, 0, 26, 25, 1, 0, 0, 0, 26, 27, 1, 0, 0, 0, 27, 28, 1, 0, 0, 0, 28, 29, 5, 0, 0, 1, 29, 1, 1, 0, 0, 0, 30, 35, 3, 16, 8, 0, 31, 32, 5, 3, 0, 0, 32, 34, 3, 16, 8, 0, 33, 31, 1, 0, 0, 0, 34, 37, 1, 0, 0, 0, 35, 33, 1, 0, 0, 0, 35, 36, 1, 0, 0, 0, 36, 3, 1, 0, 0, 0, 37, 35, 1, 0, 0, 0, 38, 40, 5, 7, 0, 0, 39, 38, 1, 0, 0, 0, 40, 41, 1, 0, 0, 0, 41, 39, 1, 0, 0, 0, 41, 42, 1, 0, 0, 0, 42, 5, 1, 0, 0, 0, 43, 48, 3, 8, 4, 0, 44, 45, 5, 2, 0, 0, 45, 47, 3, 8, 4, 0, 46, 44, 1, 0, 0, 0, 47, 50, 1, 0, 0, 0, 48, 46, 1, 0, 0, 0, 48, 49, 1, 0, 0, 0, 49, 7, 1, 0, 0, 0, 50, 48, 1, 0, 0, 0, 51, 53, 3, 16, 8, 0, 52, 54, 3, 14, 7, 0, 53, 52, 1, 0, 0, 0, 53, 54, 1, 0, 0, 0, 54, 56, 1, 0, 0, 0, 55, 51, 1, 0, 0, 0, 56, 57, 1, 0, 0, 0, 57, 55, 1, 0, 0, 0, 57, 58, 1, 0, 0, 0, 58, 9, 1, 0, 0, 0, 59, 60, 5, 1, 0, 0, 60, 67, 3, 12, 6, 0, 61, 62, 5, 4, 0, 0, 62, 63, 3, 16, 8, 0, 63, 64, 5, 1, 0, 0, 64, 65, 3, 16, 8, 0, 65, 67, 1, 0, 0, 0, 66, 59, 1, 0, 0, 0, 66, 61, 1, 0, 0, 0, 67, 11, 1, 0, 0, 0, 68, 74, 3, 16, 8, 0, 69, 70, 3, 14, 7, 0, 70, 71, 3, 16, 8, 0, 71, 73, 1, 0, 0, 0, 72, 69, 1, 0, 0, 0, 73, 76, 1, 0, 0, 0, 74, 72, 1, 0, 0, 0, 74, 75, 1, 0, 0, 0, 75, 13, 1, 0, 0, 0, 76, 74, 1, 0, 0, 0, 77, 78, 7, 0, 0, 0, 78, 15, 1, 0, 0, 0, 79, 81, 7, 1, 0, 0, 80, 79, 1, 0, 0, 0, 81, 82, 1, 0, 0, 0, 82, 80, 1, 0, 0, 0, 82, 83, 1, 0, 0, 0, 83, 17, 1, 0, 0, 0, 10, 21, 26, 35, 41, 48, 53, 57, 66, 74, 82]
|
[4, 1, 8, 95, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 1, 0, 1, 0, 1, 0, 3, 0, 24, 8, 0, 1, 0, 1, 0, 1, 0, 3, 0, 29, 8, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 5, 1, 36, 8, 1, 10, 1, 12, 1, 39, 9, 1, 1, 2, 4, 2, 42, 8, 2, 11, 2, 12, 2, 43, 1, 3, 1, 3, 1, 3, 5, 3, 49, 8, 3, 10, 3, 12, 3, 52, 9, 3, 1, 4, 1, 4, 1, 4, 5, 4, 57, 8, 4, 10, 4, 12, 4, 60, 9, 4, 1, 5, 1, 5, 3, 5, 64, 8, 5, 4, 5, 66, 8, 5, 11, 5, 12, 5, 67, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 3, 6, 77, 8, 6, 1, 7, 1, 7, 1, 7, 1, 7, 5, 7, 83, 8, 7, 10, 7, 12, 7, 86, 9, 7, 1, 8, 1, 8, 1, 9, 4, 9, 91, 8, 9, 11, 9, 12, 9, 92, 1, 9, 0, 0, 10, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 0, 2, 2, 0, 3, 4, 6, 6, 1, 0, 7, 8, 95, 0, 20, 1, 0, 0, 0, 2, 32, 1, 0, 0, 0, 4, 41, 1, 0, 0, 0, 6, 45, 1, 0, 0, 0, 8, 53, 1, 0, 0, 0, 10, 65, 1, 0, 0, 0, 12, 76, 1, 0, 0, 0, 14, 78, 1, 0, 0, 0, 16, 87, 1, 0, 0, 0, 18, 90, 1, 0, 0, 0, 20, 23, 3, 2, 1, 0, 21, 22, 5, 1, 0, 0, 22, 24, 3, 4, 2, 0, 23, 21, 1, 0, 0, 0, 23, 24, 1, 0, 0, 0, 24, 25, 1, 0, 0, 0, 25, 26, 5, 2, 0, 0, 26, 28, 3, 8, 4, 0, 27, 29, 3, 12, 6, 0, 28, 27, 1, 0, 0, 0, 28, 29, 1, 0, 0, 0, 29, 30, 1, 0, 0, 0, 30, 31, 5, 0, 0, 1, 31, 1, 1, 0, 0, 0, 32, 37, 3, 6, 3, 0, 33, 34, 5, 3, 0, 0, 34, 36, 3, 6, 3, 0, 35, 33, 1, 0, 0, 0, 36, 39, 1, 0, 0, 0, 37, 35, 1, 0, 0, 0, 37, 38, 1, 0, 0, 0, 38, 3, 1, 0, 0, 0, 39, 37, 1, 0, 0, 0, 40, 42, 5, 7, 0, 0, 41, 40, 1, 0, 0, 0, 42, 43, 1, 0, 0, 0, 43, 41, 1, 0, 0, 0, 43, 44, 1, 0, 0, 0, 44, 5, 1, 0, 0, 0, 45, 50, 3, 18, 9, 0, 46, 47, 5, 4, 0, 0, 47, 49, 3, 18, 9, 0, 48, 46, 1, 0, 0, 0, 49, 52, 1, 0, 0, 0, 50, 48, 1, 0, 0, 0, 50, 51, 1, 0, 0, 0, 51, 7, 1, 0, 0, 0, 52, 50, 1, 0, 0, 0, 53, 58, 3, 10, 5, 0, 54, 55, 5, 2, 0, 0, 55, 57, 3, 10, 5, 0, 56, 54, 1, 0, 0, 0, 57, 60, 1, 0, 0, 0, 58, 56, 1, 0, 0, 0, 58, 59, 1, 0, 0, 0, 59, 9, 1, 0, 0, 0, 60, 58, 1, 0, 0, 0, 61, 63, 3, 18, 9, 0, 62, 64, 3, 16, 8, 0, 63, 62, 1, 0, 0, 0, 63, 64, 1, 0, 0, 0, 64, 66, 1, 0, 0, 0, 65, 61, 1, 0, 0, 0, 66, 67, 1, 0, 0, 0, 67, 65, 1, 0, 0, 0, 67, 68, 1, 0, 0, 0, 68, 11, 1, 0, 0, 0, 69, 70, 5, 1, 0, 0, 70, 77, 3, 14, 7, 0, 71, 72, 5, 5, 0, 0, 72, 73, 3, 18, 9, 0, 73, 74, 5, 1, 0, 0, 74, 75, 3, 18, 9, 0, 75, 77, 1, 0, 0, 0, 76, 69, 1, 0, 0, 0, 76, 71, 1, 0, 0, 0, 77, 13, 1, 0, 0, 0, 78, 84, 3, 18, 9, 0, 79, 80, 3, 16, 8, 0, 80, 81, 3, 18, 9, 0, 81, 83, 1, 0, 0, 0, 82, 79, 1, 0, 0, 0, 83, 86, 1, 0, 0, 0, 84, 82, 1, 0, 0, 0, 84, 85, 1, 0, 0, 0, 85, 15, 1, 0, 0, 0, 86, 84, 1, 0, 0, 0, 87, 88, 7, 0, 0, 0, 88, 17, 1, 0, 0, 0, 89, 91, 7, 1, 0, 0, 90, 89, 1, 0, 0, 0, 91, 92, 1, 0, 0, 0, 92, 90, 1, 0, 0, 0, 92, 93, 1, 0, 0, 0, 93, 19, 1, 0, 0, 0, 11, 23, 28, 37, 43, 50, 58, 63, 67, 76, 84, 92]
|
||||||
@@ -9,6 +9,6 @@ LETTER=8
|
|||||||
':'=1
|
':'=1
|
||||||
'/'=2
|
'/'=2
|
||||||
'.'=3
|
'.'=3
|
||||||
'@'=4
|
'-'=4
|
||||||
'-'=5
|
'@'=5
|
||||||
'_'=6
|
'_'=6
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated from Reference.g4 by ANTLR 4.10.1
|
// Generated from java-escape by ANTLR 4.11.1
|
||||||
|
|
||||||
import Antlr4
|
import Antlr4
|
||||||
|
|
||||||
@@ -49,6 +49,19 @@ open class ReferenceBaseListener: ReferenceListener {
|
|||||||
*/
|
*/
|
||||||
open func exitPort(_ ctx: ReferenceParser.PortContext) { }
|
open func exitPort(_ ctx: ReferenceParser.PortContext) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* <p>The default implementation does nothing.</p>
|
||||||
|
*/
|
||||||
|
open func enterHost_component(_ ctx: ReferenceParser.Host_componentContext) { }
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* <p>The default implementation does nothing.</p>
|
||||||
|
*/
|
||||||
|
open func exitHost_component(_ ctx: ReferenceParser.Host_componentContext) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ null
|
|||||||
':'
|
':'
|
||||||
'/'
|
'/'
|
||||||
'.'
|
'.'
|
||||||
'@'
|
|
||||||
'-'
|
'-'
|
||||||
|
'@'
|
||||||
'_'
|
'_'
|
||||||
null
|
null
|
||||||
null
|
null
|
||||||
@@ -38,4 +38,4 @@ mode names:
|
|||||||
DEFAULT_MODE
|
DEFAULT_MODE
|
||||||
|
|
||||||
atn:
|
atn:
|
||||||
[4, 0, 8, 33, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 6, 1, 6, 1, 7, 1, 7, 0, 0, 8, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 1, 0, 2, 1, 0, 48, 57, 2, 0, 65, 90, 97, 122, 32, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0, 1, 17, 1, 0, 0, 0, 3, 19, 1, 0, 0, 0, 5, 21, 1, 0, 0, 0, 7, 23, 1, 0, 0, 0, 9, 25, 1, 0, 0, 0, 11, 27, 1, 0, 0, 0, 13, 29, 1, 0, 0, 0, 15, 31, 1, 0, 0, 0, 17, 18, 5, 58, 0, 0, 18, 2, 1, 0, 0, 0, 19, 20, 5, 47, 0, 0, 20, 4, 1, 0, 0, 0, 21, 22, 5, 46, 0, 0, 22, 6, 1, 0, 0, 0, 23, 24, 5, 64, 0, 0, 24, 8, 1, 0, 0, 0, 25, 26, 5, 45, 0, 0, 26, 10, 1, 0, 0, 0, 27, 28, 5, 95, 0, 0, 28, 12, 1, 0, 0, 0, 29, 30, 7, 0, 0, 0, 30, 14, 1, 0, 0, 0, 31, 32, 7, 1, 0, 0, 32, 16, 1, 0, 0, 0, 1, 0, 0]
|
[4, 0, 8, 33, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 6, 1, 6, 1, 7, 1, 7, 0, 0, 8, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 1, 0, 2, 1, 0, 48, 57, 2, 0, 65, 90, 97, 122, 32, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0, 1, 17, 1, 0, 0, 0, 3, 19, 1, 0, 0, 0, 5, 21, 1, 0, 0, 0, 7, 23, 1, 0, 0, 0, 9, 25, 1, 0, 0, 0, 11, 27, 1, 0, 0, 0, 13, 29, 1, 0, 0, 0, 15, 31, 1, 0, 0, 0, 17, 18, 5, 58, 0, 0, 18, 2, 1, 0, 0, 0, 19, 20, 5, 47, 0, 0, 20, 4, 1, 0, 0, 0, 21, 22, 5, 46, 0, 0, 22, 6, 1, 0, 0, 0, 23, 24, 5, 45, 0, 0, 24, 8, 1, 0, 0, 0, 25, 26, 5, 64, 0, 0, 26, 10, 1, 0, 0, 0, 27, 28, 5, 95, 0, 0, 28, 12, 1, 0, 0, 0, 29, 30, 7, 0, 0, 0, 30, 14, 1, 0, 0, 0, 31, 32, 7, 1, 0, 0, 32, 16, 1, 0, 0, 0, 1, 0, 0]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated from Reference.g4 by ANTLR 4.10.1
|
// Generated from java-escape by ANTLR 4.11.1
|
||||||
import Antlr4
|
import Antlr4
|
||||||
|
|
||||||
open class ReferenceLexer: Lexer {
|
open class ReferenceLexer: Lexer {
|
||||||
@@ -33,7 +33,7 @@ open class ReferenceLexer: Lexer {
|
|||||||
]
|
]
|
||||||
|
|
||||||
private static let _LITERAL_NAMES: [String?] = [
|
private static let _LITERAL_NAMES: [String?] = [
|
||||||
nil, "':'", "'/'", "'.'", "'@'", "'-'", "'_'"
|
nil, "':'", "'/'", "'.'", "'-'", "'@'", "'_'"
|
||||||
]
|
]
|
||||||
private static let _SYMBOLIC_NAMES: [String?] = [
|
private static let _SYMBOLIC_NAMES: [String?] = [
|
||||||
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
|
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
|
||||||
@@ -49,7 +49,7 @@ open class ReferenceLexer: Lexer {
|
|||||||
|
|
||||||
public
|
public
|
||||||
required init(_ input: CharStream) {
|
required init(_ input: CharStream) {
|
||||||
RuntimeMetaData.checkVersion("4.10.1", RuntimeMetaData.VERSION)
|
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
||||||
super.init(input)
|
super.init(input)
|
||||||
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
|
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ open class ReferenceLexer: Lexer {
|
|||||||
1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,1,17,1,0,0,0,3,19,1,0,0,0,5,21,1,0,0,
|
1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,1,17,1,0,0,0,3,19,1,0,0,0,5,21,1,0,0,
|
||||||
0,7,23,1,0,0,0,9,25,1,0,0,0,11,27,1,0,0,0,13,29,1,0,0,0,15,31,1,0,0,0,
|
0,7,23,1,0,0,0,9,25,1,0,0,0,11,27,1,0,0,0,13,29,1,0,0,0,15,31,1,0,0,0,
|
||||||
17,18,5,58,0,0,18,2,1,0,0,0,19,20,5,47,0,0,20,4,1,0,0,0,21,22,5,46,0,0,
|
17,18,5,58,0,0,18,2,1,0,0,0,19,20,5,47,0,0,20,4,1,0,0,0,21,22,5,46,0,0,
|
||||||
22,6,1,0,0,0,23,24,5,64,0,0,24,8,1,0,0,0,25,26,5,45,0,0,26,10,1,0,0,0,
|
22,6,1,0,0,0,23,24,5,45,0,0,24,8,1,0,0,0,25,26,5,64,0,0,26,10,1,0,0,0,
|
||||||
27,28,5,95,0,0,28,12,1,0,0,0,29,30,7,0,0,0,30,14,1,0,0,0,31,32,7,1,0,0,
|
27,28,5,95,0,0,28,12,1,0,0,0,29,30,7,0,0,0,30,14,1,0,0,0,31,32,7,1,0,0,
|
||||||
32,16,1,0,0,0,1,0,0
|
32,16,1,0,0,0,1,0,0
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ LETTER=8
|
|||||||
':'=1
|
':'=1
|
||||||
'/'=2
|
'/'=2
|
||||||
'.'=3
|
'.'=3
|
||||||
'@'=4
|
'-'=4
|
||||||
'-'=5
|
'@'=5
|
||||||
'_'=6
|
'_'=6
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated from Reference.g4 by ANTLR 4.10.1
|
// Generated from java-escape by ANTLR 4.11.1
|
||||||
import Antlr4
|
import Antlr4
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +42,18 @@ public protocol ReferenceListener: ParseTreeListener {
|
|||||||
- ctx: the parse tree
|
- ctx: the parse tree
|
||||||
*/
|
*/
|
||||||
func exitPort(_ ctx: ReferenceParser.PortContext)
|
func exitPort(_ ctx: ReferenceParser.PortContext)
|
||||||
|
/**
|
||||||
|
* Enter a parse tree produced by {@link ReferenceParser#host_component}.
|
||||||
|
- Parameters:
|
||||||
|
- ctx: the parse tree
|
||||||
|
*/
|
||||||
|
func enterHost_component(_ ctx: ReferenceParser.Host_componentContext)
|
||||||
|
/**
|
||||||
|
* Exit a parse tree produced by {@link ReferenceParser#host_component}.
|
||||||
|
- Parameters:
|
||||||
|
- ctx: the parse tree
|
||||||
|
*/
|
||||||
|
func exitHost_component(_ ctx: ReferenceParser.Host_componentContext)
|
||||||
/**
|
/**
|
||||||
* Enter a parse tree produced by {@link ReferenceParser#namespace}.
|
* Enter a parse tree produced by {@link ReferenceParser#namespace}.
|
||||||
- Parameters:
|
- Parameters:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Generated from Reference.g4 by ANTLR 4.10.1
|
// Generated from java-escape by ANTLR 4.11.1
|
||||||
import Antlr4
|
import Antlr4
|
||||||
|
|
||||||
open class ReferenceParser: Parser {
|
open class ReferenceParser: Parser {
|
||||||
@@ -21,18 +21,18 @@ open class ReferenceParser: Parser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public
|
public
|
||||||
static let RULE_root = 0, RULE_host = 1, RULE_port = 2, RULE_namespace = 3,
|
static let RULE_root = 0, RULE_host = 1, RULE_port = 2, RULE_host_component = 3,
|
||||||
RULE_namespace_component = 4, RULE_reference = 5, RULE_tag = 6,
|
RULE_namespace = 4, RULE_namespace_component = 5, RULE_reference = 6,
|
||||||
RULE_separator = 7, RULE_name = 8
|
RULE_tag = 7, RULE_separator = 8, RULE_name = 9
|
||||||
|
|
||||||
public
|
public
|
||||||
static let ruleNames: [String] = [
|
static let ruleNames: [String] = [
|
||||||
"root", "host", "port", "namespace", "namespace_component", "reference",
|
"root", "host", "port", "host_component", "namespace", "namespace_component",
|
||||||
"tag", "separator", "name"
|
"reference", "tag", "separator", "name"
|
||||||
]
|
]
|
||||||
|
|
||||||
private static let _LITERAL_NAMES: [String?] = [
|
private static let _LITERAL_NAMES: [String?] = [
|
||||||
nil, "':'", "'/'", "'.'", "'@'", "'-'", "'_'"
|
nil, "':'", "'/'", "'.'", "'-'", "'@'", "'_'"
|
||||||
]
|
]
|
||||||
private static let _SYMBOLIC_NAMES: [String?] = [
|
private static let _SYMBOLIC_NAMES: [String?] = [
|
||||||
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
|
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
|
||||||
@@ -41,7 +41,7 @@ open class ReferenceParser: Parser {
|
|||||||
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
||||||
|
|
||||||
override open
|
override open
|
||||||
func getGrammarFileName() -> String { return "Reference.g4" }
|
func getGrammarFileName() -> String { return "java-escape" }
|
||||||
|
|
||||||
override open
|
override open
|
||||||
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
|
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
|
||||||
@@ -60,7 +60,7 @@ open class ReferenceParser: Parser {
|
|||||||
|
|
||||||
override public
|
override public
|
||||||
init(_ input:TokenStream) throws {
|
init(_ input:TokenStream) throws {
|
||||||
RuntimeMetaData.checkVersion("4.10.1", RuntimeMetaData.VERSION)
|
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
||||||
try super.init(input)
|
try super.init(input)
|
||||||
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
|
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
|
||||||
}
|
}
|
||||||
@@ -115,41 +115,33 @@ open class ReferenceParser: Parser {
|
|||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(18)
|
setState(20)
|
||||||
try host()
|
try host()
|
||||||
setState(21)
|
setState(23)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
if (//closure
|
if (_la == ReferenceParser.Tokens.T__0.rawValue) {
|
||||||
{ () -> Bool in
|
setState(21)
|
||||||
let testSet: Bool = _la == ReferenceParser.Tokens.T__0.rawValue
|
|
||||||
return testSet
|
|
||||||
}()) {
|
|
||||||
setState(19)
|
|
||||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||||
setState(20)
|
setState(22)
|
||||||
try port()
|
try port()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(23)
|
setState(25)
|
||||||
try match(ReferenceParser.Tokens.T__1.rawValue)
|
try match(ReferenceParser.Tokens.T__1.rawValue)
|
||||||
setState(24)
|
|
||||||
try namespace()
|
|
||||||
setState(26)
|
setState(26)
|
||||||
|
try namespace()
|
||||||
|
setState(28)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
if (//closure
|
if (_la == ReferenceParser.Tokens.T__0.rawValue || _la == ReferenceParser.Tokens.T__4.rawValue) {
|
||||||
{ () -> Bool in
|
setState(27)
|
||||||
let testSet: Bool = _la == ReferenceParser.Tokens.T__0.rawValue || _la == ReferenceParser.Tokens.T__3.rawValue
|
|
||||||
return testSet
|
|
||||||
}()) {
|
|
||||||
setState(25)
|
|
||||||
try reference()
|
try reference()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(28)
|
setState(30)
|
||||||
try match(ReferenceParser.Tokens.EOF.rawValue)
|
try match(ReferenceParser.Tokens.EOF.rawValue)
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -164,12 +156,12 @@ open class ReferenceParser: Parser {
|
|||||||
|
|
||||||
public class HostContext: ParserRuleContext {
|
public class HostContext: ParserRuleContext {
|
||||||
open
|
open
|
||||||
func name() -> [NameContext] {
|
func host_component() -> [Host_componentContext] {
|
||||||
return getRuleContexts(NameContext.self)
|
return getRuleContexts(Host_componentContext.self)
|
||||||
}
|
}
|
||||||
open
|
open
|
||||||
func name(_ i: Int) -> NameContext? {
|
func host_component(_ i: Int) -> Host_componentContext? {
|
||||||
return getRuleContext(NameContext.self, i)
|
return getRuleContext(Host_componentContext.self, i)
|
||||||
}
|
}
|
||||||
override open
|
override open
|
||||||
func getRuleIndex() -> Int {
|
func getRuleIndex() -> Int {
|
||||||
@@ -199,23 +191,19 @@ open class ReferenceParser: Parser {
|
|||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(30)
|
setState(32)
|
||||||
try name()
|
try host_component()
|
||||||
setState(35)
|
setState(37)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
while (//closure
|
while (_la == ReferenceParser.Tokens.T__2.rawValue) {
|
||||||
{ () -> Bool in
|
setState(33)
|
||||||
let testSet: Bool = _la == ReferenceParser.Tokens.T__2.rawValue
|
|
||||||
return testSet
|
|
||||||
}()) {
|
|
||||||
setState(31)
|
|
||||||
try match(ReferenceParser.Tokens.T__2.rawValue)
|
try match(ReferenceParser.Tokens.T__2.rawValue)
|
||||||
setState(32)
|
setState(34)
|
||||||
try name()
|
try host_component()
|
||||||
|
|
||||||
|
|
||||||
setState(37)
|
setState(39)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
}
|
}
|
||||||
@@ -267,22 +255,82 @@ open class ReferenceParser: Parser {
|
|||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(39)
|
setState(41)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
repeat {
|
repeat {
|
||||||
setState(38)
|
setState(40)
|
||||||
try match(ReferenceParser.Tokens.DIGIT.rawValue)
|
try match(ReferenceParser.Tokens.DIGIT.rawValue)
|
||||||
|
|
||||||
|
|
||||||
setState(41);
|
setState(43);
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
} while (//closure
|
} while (_la == ReferenceParser.Tokens.DIGIT.rawValue)
|
||||||
{ () -> Bool in
|
|
||||||
let testSet: Bool = _la == ReferenceParser.Tokens.DIGIT.rawValue
|
}
|
||||||
return testSet
|
catch ANTLRException.recognition(let re) {
|
||||||
}())
|
_localctx.exception = re
|
||||||
|
_errHandler.reportError(self, re)
|
||||||
|
try _errHandler.recover(self, re)
|
||||||
|
}
|
||||||
|
|
||||||
|
return _localctx
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Host_componentContext: ParserRuleContext {
|
||||||
|
open
|
||||||
|
func name() -> [NameContext] {
|
||||||
|
return getRuleContexts(NameContext.self)
|
||||||
|
}
|
||||||
|
open
|
||||||
|
func name(_ i: Int) -> NameContext? {
|
||||||
|
return getRuleContext(NameContext.self, i)
|
||||||
|
}
|
||||||
|
override open
|
||||||
|
func getRuleIndex() -> Int {
|
||||||
|
return ReferenceParser.RULE_host_component
|
||||||
|
}
|
||||||
|
override open
|
||||||
|
func enterRule(_ listener: ParseTreeListener) {
|
||||||
|
if let listener = listener as? ReferenceListener {
|
||||||
|
listener.enterHost_component(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override open
|
||||||
|
func exitRule(_ listener: ParseTreeListener) {
|
||||||
|
if let listener = listener as? ReferenceListener {
|
||||||
|
listener.exitHost_component(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@discardableResult
|
||||||
|
open func host_component() throws -> Host_componentContext {
|
||||||
|
var _localctx: Host_componentContext
|
||||||
|
_localctx = Host_componentContext(_ctx, getState())
|
||||||
|
try enterRule(_localctx, 6, ReferenceParser.RULE_host_component)
|
||||||
|
var _la: Int = 0
|
||||||
|
defer {
|
||||||
|
try! exitRule()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try enterOuterAlt(_localctx, 1)
|
||||||
|
setState(45)
|
||||||
|
try name()
|
||||||
|
setState(50)
|
||||||
|
try _errHandler.sync(self)
|
||||||
|
_la = try _input.LA(1)
|
||||||
|
while (_la == ReferenceParser.Tokens.T__3.rawValue) {
|
||||||
|
setState(46)
|
||||||
|
try match(ReferenceParser.Tokens.T__3.rawValue)
|
||||||
|
setState(47)
|
||||||
|
try name()
|
||||||
|
|
||||||
|
|
||||||
|
setState(52)
|
||||||
|
try _errHandler.sync(self)
|
||||||
|
_la = try _input.LA(1)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
catch ANTLRException.recognition(let re) {
|
catch ANTLRException.recognition(let re) {
|
||||||
@@ -324,30 +372,26 @@ open class ReferenceParser: Parser {
|
|||||||
open func namespace() throws -> NamespaceContext {
|
open func namespace() throws -> NamespaceContext {
|
||||||
var _localctx: NamespaceContext
|
var _localctx: NamespaceContext
|
||||||
_localctx = NamespaceContext(_ctx, getState())
|
_localctx = NamespaceContext(_ctx, getState())
|
||||||
try enterRule(_localctx, 6, ReferenceParser.RULE_namespace)
|
try enterRule(_localctx, 8, ReferenceParser.RULE_namespace)
|
||||||
var _la: Int = 0
|
var _la: Int = 0
|
||||||
defer {
|
defer {
|
||||||
try! exitRule()
|
try! exitRule()
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(43)
|
setState(53)
|
||||||
try namespace_component()
|
try namespace_component()
|
||||||
setState(48)
|
setState(58)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
while (//closure
|
while (_la == ReferenceParser.Tokens.T__1.rawValue) {
|
||||||
{ () -> Bool in
|
setState(54)
|
||||||
let testSet: Bool = _la == ReferenceParser.Tokens.T__1.rawValue
|
|
||||||
return testSet
|
|
||||||
}()) {
|
|
||||||
setState(44)
|
|
||||||
try match(ReferenceParser.Tokens.T__1.rawValue)
|
try match(ReferenceParser.Tokens.T__1.rawValue)
|
||||||
setState(45)
|
setState(55)
|
||||||
try namespace_component()
|
try namespace_component()
|
||||||
|
|
||||||
|
|
||||||
setState(50)
|
setState(60)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
}
|
}
|
||||||
@@ -400,45 +444,34 @@ open class ReferenceParser: Parser {
|
|||||||
open func namespace_component() throws -> Namespace_componentContext {
|
open func namespace_component() throws -> Namespace_componentContext {
|
||||||
var _localctx: Namespace_componentContext
|
var _localctx: Namespace_componentContext
|
||||||
_localctx = Namespace_componentContext(_ctx, getState())
|
_localctx = Namespace_componentContext(_ctx, getState())
|
||||||
try enterRule(_localctx, 8, ReferenceParser.RULE_namespace_component)
|
try enterRule(_localctx, 10, ReferenceParser.RULE_namespace_component)
|
||||||
var _la: Int = 0
|
var _la: Int = 0
|
||||||
defer {
|
defer {
|
||||||
try! exitRule()
|
try! exitRule()
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(55)
|
setState(65)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
repeat {
|
repeat {
|
||||||
setState(51)
|
setState(61)
|
||||||
try name()
|
try name()
|
||||||
setState(53)
|
setState(63)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
if (//closure
|
if ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
||||||
{ () -> Bool in
|
setState(62)
|
||||||
let testSet: Bool = { () -> Bool in
|
|
||||||
let testArray: [Int] = [_la, ReferenceParser.Tokens.T__2.rawValue,ReferenceParser.Tokens.T__4.rawValue,ReferenceParser.Tokens.T__5.rawValue]
|
|
||||||
return Utils.testBitLeftShiftArray(testArray, 0)
|
|
||||||
}()
|
|
||||||
return testSet
|
|
||||||
}()) {
|
|
||||||
setState(52)
|
|
||||||
try separator()
|
try separator()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setState(57);
|
setState(67);
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
} while (//closure
|
} while (_la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue)
|
||||||
{ () -> Bool in
|
|
||||||
let testSet: Bool = _la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue
|
|
||||||
return testSet
|
|
||||||
}())
|
|
||||||
|
|
||||||
}
|
}
|
||||||
catch ANTLRException.recognition(let re) {
|
catch ANTLRException.recognition(let re) {
|
||||||
@@ -484,33 +517,33 @@ open class ReferenceParser: Parser {
|
|||||||
open func reference() throws -> ReferenceContext {
|
open func reference() throws -> ReferenceContext {
|
||||||
var _localctx: ReferenceContext
|
var _localctx: ReferenceContext
|
||||||
_localctx = ReferenceContext(_ctx, getState())
|
_localctx = ReferenceContext(_ctx, getState())
|
||||||
try enterRule(_localctx, 10, ReferenceParser.RULE_reference)
|
try enterRule(_localctx, 12, ReferenceParser.RULE_reference)
|
||||||
defer {
|
defer {
|
||||||
try! exitRule()
|
try! exitRule()
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
setState(66)
|
setState(76)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
switch (ReferenceParser.Tokens(rawValue: try _input.LA(1))!) {
|
switch (ReferenceParser.Tokens(rawValue: try _input.LA(1))!) {
|
||||||
case .T__0:
|
case .T__0:
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(59)
|
setState(69)
|
||||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||||
setState(60)
|
setState(70)
|
||||||
try tag()
|
try tag()
|
||||||
|
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
case .T__3:
|
case .T__4:
|
||||||
try enterOuterAlt(_localctx, 2)
|
try enterOuterAlt(_localctx, 2)
|
||||||
setState(61)
|
setState(71)
|
||||||
try match(ReferenceParser.Tokens.T__3.rawValue)
|
try match(ReferenceParser.Tokens.T__4.rawValue)
|
||||||
setState(62)
|
setState(72)
|
||||||
try name()
|
try name()
|
||||||
setState(63)
|
setState(73)
|
||||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||||
setState(64)
|
setState(74)
|
||||||
try name()
|
try name()
|
||||||
|
|
||||||
|
|
||||||
@@ -566,33 +599,26 @@ open class ReferenceParser: Parser {
|
|||||||
open func tag() throws -> TagContext {
|
open func tag() throws -> TagContext {
|
||||||
var _localctx: TagContext
|
var _localctx: TagContext
|
||||||
_localctx = TagContext(_ctx, getState())
|
_localctx = TagContext(_ctx, getState())
|
||||||
try enterRule(_localctx, 12, ReferenceParser.RULE_tag)
|
try enterRule(_localctx, 14, ReferenceParser.RULE_tag)
|
||||||
var _la: Int = 0
|
var _la: Int = 0
|
||||||
defer {
|
defer {
|
||||||
try! exitRule()
|
try! exitRule()
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(68)
|
setState(78)
|
||||||
try name()
|
try name()
|
||||||
setState(74)
|
setState(84)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
while (//closure
|
while ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
||||||
{ () -> Bool in
|
setState(79)
|
||||||
let testSet: Bool = { () -> Bool in
|
|
||||||
let testArray: [Int] = [_la, ReferenceParser.Tokens.T__2.rawValue,ReferenceParser.Tokens.T__4.rawValue,ReferenceParser.Tokens.T__5.rawValue]
|
|
||||||
return Utils.testBitLeftShiftArray(testArray, 0)
|
|
||||||
}()
|
|
||||||
return testSet
|
|
||||||
}()) {
|
|
||||||
setState(69)
|
|
||||||
try separator()
|
try separator()
|
||||||
setState(70)
|
setState(80)
|
||||||
try name()
|
try name()
|
||||||
|
|
||||||
|
|
||||||
setState(76)
|
setState(86)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
}
|
}
|
||||||
@@ -629,23 +655,16 @@ open class ReferenceParser: Parser {
|
|||||||
open func separator() throws -> SeparatorContext {
|
open func separator() throws -> SeparatorContext {
|
||||||
var _localctx: SeparatorContext
|
var _localctx: SeparatorContext
|
||||||
_localctx = SeparatorContext(_ctx, getState())
|
_localctx = SeparatorContext(_ctx, getState())
|
||||||
try enterRule(_localctx, 14, ReferenceParser.RULE_separator)
|
try enterRule(_localctx, 16, ReferenceParser.RULE_separator)
|
||||||
var _la: Int = 0
|
var _la: Int = 0
|
||||||
defer {
|
defer {
|
||||||
try! exitRule()
|
try! exitRule()
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(77)
|
setState(87)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
if (!(//closure
|
if (!((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
|
||||||
{ () -> Bool in
|
|
||||||
let testSet: Bool = { () -> Bool in
|
|
||||||
let testArray: [Int] = [_la, ReferenceParser.Tokens.T__2.rawValue,ReferenceParser.Tokens.T__4.rawValue,ReferenceParser.Tokens.T__5.rawValue]
|
|
||||||
return Utils.testBitLeftShiftArray(testArray, 0)
|
|
||||||
}()
|
|
||||||
return testSet
|
|
||||||
}())) {
|
|
||||||
try _errHandler.recoverInline(self)
|
try _errHandler.recoverInline(self)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -701,7 +720,7 @@ open class ReferenceParser: Parser {
|
|||||||
open func name() throws -> NameContext {
|
open func name() throws -> NameContext {
|
||||||
var _localctx: NameContext
|
var _localctx: NameContext
|
||||||
_localctx = NameContext(_ctx, getState())
|
_localctx = NameContext(_ctx, getState())
|
||||||
try enterRule(_localctx, 16, ReferenceParser.RULE_name)
|
try enterRule(_localctx, 18, ReferenceParser.RULE_name)
|
||||||
var _la: Int = 0
|
var _la: Int = 0
|
||||||
defer {
|
defer {
|
||||||
try! exitRule()
|
try! exitRule()
|
||||||
@@ -709,19 +728,15 @@ open class ReferenceParser: Parser {
|
|||||||
do {
|
do {
|
||||||
var _alt:Int
|
var _alt:Int
|
||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(80);
|
setState(90);
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_alt = 1;
|
_alt = 1;
|
||||||
repeat {
|
repeat {
|
||||||
switch (_alt) {
|
switch (_alt) {
|
||||||
case 1:
|
case 1:
|
||||||
setState(79)
|
setState(89)
|
||||||
_la = try _input.LA(1)
|
_la = try _input.LA(1)
|
||||||
if (!(//closure
|
if (!(_la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue)) {
|
||||||
{ () -> Bool in
|
|
||||||
let testSet: Bool = _la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue
|
|
||||||
return testSet
|
|
||||||
}())) {
|
|
||||||
try _errHandler.recoverInline(self)
|
try _errHandler.recoverInline(self)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -734,9 +749,9 @@ open class ReferenceParser: Parser {
|
|||||||
default:
|
default:
|
||||||
throw ANTLRException.recognition(e: NoViableAltException(self))
|
throw ANTLRException.recognition(e: NoViableAltException(self))
|
||||||
}
|
}
|
||||||
setState(82);
|
setState(92);
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_alt = try getInterpreter().adaptivePredict(_input,9,_ctx)
|
_alt = try getInterpreter().adaptivePredict(_input,10,_ctx)
|
||||||
} while (_alt != 2 && _alt != ATN.INVALID_ALT_NUMBER)
|
} while (_alt != 2 && _alt != ATN.INVALID_ALT_NUMBER)
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -750,30 +765,33 @@ open class ReferenceParser: Parser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static let _serializedATN:[Int] = [
|
static let _serializedATN:[Int] = [
|
||||||
4,1,8,85,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,
|
4,1,8,95,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,
|
||||||
7,2,8,7,8,1,0,1,0,1,0,3,0,22,8,0,1,0,1,0,1,0,3,0,27,8,0,1,0,1,0,1,1,1,
|
7,2,8,7,8,2,9,7,9,1,0,1,0,1,0,3,0,24,8,0,1,0,1,0,1,0,3,0,29,8,0,1,0,1,
|
||||||
1,1,1,5,1,34,8,1,10,1,12,1,37,9,1,1,2,4,2,40,8,2,11,2,12,2,41,1,3,1,3,
|
0,1,1,1,1,1,1,5,1,36,8,1,10,1,12,1,39,9,1,1,2,4,2,42,8,2,11,2,12,2,43,
|
||||||
1,3,5,3,47,8,3,10,3,12,3,50,9,3,1,4,1,4,3,4,54,8,4,4,4,56,8,4,11,4,12,
|
1,3,1,3,1,3,5,3,49,8,3,10,3,12,3,52,9,3,1,4,1,4,1,4,5,4,57,8,4,10,4,12,
|
||||||
4,57,1,5,1,5,1,5,1,5,1,5,1,5,1,5,3,5,67,8,5,1,6,1,6,1,6,1,6,5,6,73,8,6,
|
4,60,9,4,1,5,1,5,3,5,64,8,5,4,5,66,8,5,11,5,12,5,67,1,6,1,6,1,6,1,6,1,
|
||||||
10,6,12,6,76,9,6,1,7,1,7,1,8,4,8,81,8,8,11,8,12,8,82,1,8,0,0,9,0,2,4,6,
|
6,1,6,1,6,3,6,77,8,6,1,7,1,7,1,7,1,7,5,7,83,8,7,10,7,12,7,86,9,7,1,8,1,
|
||||||
8,10,12,14,16,0,2,2,0,3,3,5,6,1,0,7,8,85,0,18,1,0,0,0,2,30,1,0,0,0,4,39,
|
8,1,9,4,9,91,8,9,11,9,12,9,92,1,9,0,0,10,0,2,4,6,8,10,12,14,16,18,0,2,
|
||||||
1,0,0,0,6,43,1,0,0,0,8,55,1,0,0,0,10,66,1,0,0,0,12,68,1,0,0,0,14,77,1,
|
2,0,3,4,6,6,1,0,7,8,95,0,20,1,0,0,0,2,32,1,0,0,0,4,41,1,0,0,0,6,45,1,0,
|
||||||
0,0,0,16,80,1,0,0,0,18,21,3,2,1,0,19,20,5,1,0,0,20,22,3,4,2,0,21,19,1,
|
0,0,8,53,1,0,0,0,10,65,1,0,0,0,12,76,1,0,0,0,14,78,1,0,0,0,16,87,1,0,0,
|
||||||
0,0,0,21,22,1,0,0,0,22,23,1,0,0,0,23,24,5,2,0,0,24,26,3,6,3,0,25,27,3,
|
0,18,90,1,0,0,0,20,23,3,2,1,0,21,22,5,1,0,0,22,24,3,4,2,0,23,21,1,0,0,
|
||||||
10,5,0,26,25,1,0,0,0,26,27,1,0,0,0,27,28,1,0,0,0,28,29,5,0,0,1,29,1,1,
|
0,23,24,1,0,0,0,24,25,1,0,0,0,25,26,5,2,0,0,26,28,3,8,4,0,27,29,3,12,6,
|
||||||
0,0,0,30,35,3,16,8,0,31,32,5,3,0,0,32,34,3,16,8,0,33,31,1,0,0,0,34,37,
|
0,28,27,1,0,0,0,28,29,1,0,0,0,29,30,1,0,0,0,30,31,5,0,0,1,31,1,1,0,0,0,
|
||||||
1,0,0,0,35,33,1,0,0,0,35,36,1,0,0,0,36,3,1,0,0,0,37,35,1,0,0,0,38,40,5,
|
32,37,3,6,3,0,33,34,5,3,0,0,34,36,3,6,3,0,35,33,1,0,0,0,36,39,1,0,0,0,
|
||||||
7,0,0,39,38,1,0,0,0,40,41,1,0,0,0,41,39,1,0,0,0,41,42,1,0,0,0,42,5,1,0,
|
37,35,1,0,0,0,37,38,1,0,0,0,38,3,1,0,0,0,39,37,1,0,0,0,40,42,5,7,0,0,41,
|
||||||
0,0,43,48,3,8,4,0,44,45,5,2,0,0,45,47,3,8,4,0,46,44,1,0,0,0,47,50,1,0,
|
40,1,0,0,0,42,43,1,0,0,0,43,41,1,0,0,0,43,44,1,0,0,0,44,5,1,0,0,0,45,50,
|
||||||
0,0,48,46,1,0,0,0,48,49,1,0,0,0,49,7,1,0,0,0,50,48,1,0,0,0,51,53,3,16,
|
3,18,9,0,46,47,5,4,0,0,47,49,3,18,9,0,48,46,1,0,0,0,49,52,1,0,0,0,50,48,
|
||||||
8,0,52,54,3,14,7,0,53,52,1,0,0,0,53,54,1,0,0,0,54,56,1,0,0,0,55,51,1,0,
|
1,0,0,0,50,51,1,0,0,0,51,7,1,0,0,0,52,50,1,0,0,0,53,58,3,10,5,0,54,55,
|
||||||
0,0,56,57,1,0,0,0,57,55,1,0,0,0,57,58,1,0,0,0,58,9,1,0,0,0,59,60,5,1,0,
|
5,2,0,0,55,57,3,10,5,0,56,54,1,0,0,0,57,60,1,0,0,0,58,56,1,0,0,0,58,59,
|
||||||
0,60,67,3,12,6,0,61,62,5,4,0,0,62,63,3,16,8,0,63,64,5,1,0,0,64,65,3,16,
|
1,0,0,0,59,9,1,0,0,0,60,58,1,0,0,0,61,63,3,18,9,0,62,64,3,16,8,0,63,62,
|
||||||
8,0,65,67,1,0,0,0,66,59,1,0,0,0,66,61,1,0,0,0,67,11,1,0,0,0,68,74,3,16,
|
1,0,0,0,63,64,1,0,0,0,64,66,1,0,0,0,65,61,1,0,0,0,66,67,1,0,0,0,67,65,
|
||||||
8,0,69,70,3,14,7,0,70,71,3,16,8,0,71,73,1,0,0,0,72,69,1,0,0,0,73,76,1,
|
1,0,0,0,67,68,1,0,0,0,68,11,1,0,0,0,69,70,5,1,0,0,70,77,3,14,7,0,71,72,
|
||||||
0,0,0,74,72,1,0,0,0,74,75,1,0,0,0,75,13,1,0,0,0,76,74,1,0,0,0,77,78,7,
|
5,5,0,0,72,73,3,18,9,0,73,74,5,1,0,0,74,75,3,18,9,0,75,77,1,0,0,0,76,69,
|
||||||
0,0,0,78,15,1,0,0,0,79,81,7,1,0,0,80,79,1,0,0,0,81,82,1,0,0,0,82,80,1,
|
1,0,0,0,76,71,1,0,0,0,77,13,1,0,0,0,78,84,3,18,9,0,79,80,3,16,8,0,80,81,
|
||||||
0,0,0,82,83,1,0,0,0,83,17,1,0,0,0,10,21,26,35,41,48,53,57,66,74,82
|
3,18,9,0,81,83,1,0,0,0,82,79,1,0,0,0,83,86,1,0,0,0,84,82,1,0,0,0,84,85,
|
||||||
|
1,0,0,0,85,15,1,0,0,0,86,84,1,0,0,0,87,88,7,0,0,0,88,17,1,0,0,0,89,91,
|
||||||
|
7,1,0,0,90,89,1,0,0,0,91,92,1,0,0,0,92,90,1,0,0,0,92,93,1,0,0,0,93,19,
|
||||||
|
1,0,0,0,11,23,28,37,43,50,58,63,67,76,84,92
|
||||||
]
|
]
|
||||||
|
|
||||||
public
|
public
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
grammar Reference;
|
grammar Reference;
|
||||||
|
|
||||||
root: host (':' port)? '/' namespace reference? EOF;
|
root: host (':' port)? '/' namespace reference? EOF;
|
||||||
host: name ('.' name)*;
|
host: host_component ('.' host_component)*;
|
||||||
port: DIGIT+;
|
port: DIGIT+;
|
||||||
|
host_component: name ('-' name)*;
|
||||||
namespace: namespace_component ('/' namespace_component)*;
|
namespace: namespace_component ('/' namespace_component)*;
|
||||||
namespace_component: (name separator?)+;
|
namespace_component: (name separator?)+;
|
||||||
reference: (':' tag) | ('@' name ':' name);
|
reference: (':' tag) | ('@' name ':' name);
|
||||||
|
|||||||
@@ -1,28 +1,42 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import NIOCore
|
|
||||||
import NIOHTTP1
|
|
||||||
import AsyncHTTPClient
|
|
||||||
import Algorithms
|
import Algorithms
|
||||||
import NIOPosix
|
import AsyncAlgorithms
|
||||||
|
|
||||||
enum RegistryError: Error {
|
enum RegistryError: Error {
|
||||||
case UnexpectedHTTPStatusCode(when: String, code: UInt, details: String = "")
|
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
|
||||||
case MissingLocationHeader
|
case MissingLocationHeader
|
||||||
case AuthFailed(why: String, details: String = "")
|
case AuthFailed(why: String, details: String = "")
|
||||||
case MalformedHeader(why: String)
|
case MalformedHeader(why: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension HTTPClientResponse.Body {
|
enum HTTPMethod: String {
|
||||||
func readTextResponse() async throws -> String? {
|
case GET = "GET"
|
||||||
let data = try await readResponse()
|
case POST = "POST"
|
||||||
return String(decoding: data, as: UTF8.self)
|
case PUT = "PUT"
|
||||||
}
|
case PATCH = "PATCH"
|
||||||
|
}
|
||||||
|
|
||||||
func readResponse() async throws -> Data {
|
enum HTTPCode: Int {
|
||||||
|
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()
|
var result = Data()
|
||||||
for try await part in self {
|
|
||||||
result.append(Data(buffer: part))
|
for try await chunk in self {
|
||||||
|
result += chunk
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,14 +92,6 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Registry {
|
class Registry {
|
||||||
private let httpClient = HTTPClient(
|
|
||||||
eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup(numberOfThreads: 1))
|
|
||||||
)
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
try! httpClient.syncShutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
let baseURL: URL
|
let baseURL: URL
|
||||||
let namespace: String
|
let namespace: String
|
||||||
let credentialsProviders: [CredentialsProvider]
|
let credentialsProviders: [CredentialsProvider]
|
||||||
@@ -94,7 +100,7 @@ class Registry {
|
|||||||
|
|
||||||
init(urlComponents: URLComponents,
|
init(urlComponents: URLComponents,
|
||||||
namespace: String,
|
namespace: String,
|
||||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||||
) throws {
|
) throws {
|
||||||
baseURL = urlComponents.url!
|
baseURL = urlComponents.url!
|
||||||
self.namespace = namespace
|
self.namespace = namespace
|
||||||
@@ -105,7 +111,7 @@ class Registry {
|
|||||||
host: String,
|
host: String,
|
||||||
namespace: String,
|
namespace: String,
|
||||||
insecure: Bool = false,
|
insecure: Bool = false,
|
||||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||||
) throws {
|
) throws {
|
||||||
let proto = insecure ? "http" : "https"
|
let proto = insecure ? "http" : "https"
|
||||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||||
@@ -114,43 +120,41 @@ class Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ping() async throws {
|
func ping() async throws {
|
||||||
let response = try await endpointRequest(.GET, "/v2/")
|
let (_, response) = try await dataRequest(.GET, endpointURL("/v2/"))
|
||||||
if response.status != .ok {
|
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "doing ping", code: response.status.code)
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "doing ping", code: response.statusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushManifest(reference: String, manifest: OCIManifest) async throws -> String {
|
func pushManifest(reference: String, manifest: OCIManifest) async throws -> String {
|
||||||
let manifestJSON = try manifest.toJSON()
|
let manifestJSON = try manifest.toJSON()
|
||||||
|
|
||||||
let response = try await endpointRequest(.PUT, "\(namespace)/manifests/\(reference)",
|
let (data, response) = try await dataRequest(.PUT, endpointURL("\(namespace)/manifests/\(reference)"),
|
||||||
headers: ["Content-Type": manifest.mediaType],
|
headers: ["Content-Type": manifest.mediaType],
|
||||||
body: manifestJSON)
|
body: manifestJSON)
|
||||||
if response.status != .created {
|
if response.statusCode != HTTPCode.Created.rawValue {
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.status.code,
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
|
||||||
details: try await response.body.readTextResponse() ?? "")
|
details: data.asText())
|
||||||
}
|
}
|
||||||
|
|
||||||
return Digest.hash(manifestJSON)
|
return Digest.hash(manifestJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func pullManifest(reference: String) async throws -> (OCIManifest, Data) {
|
public func pullManifest(reference: String) async throws -> (OCIManifest, Data) {
|
||||||
let response = try await endpointRequest(.GET, "\(namespace)/manifests/\(reference)",
|
let (data, response) = try await dataRequest(.GET, endpointURL("\(namespace)/manifests/\(reference)"),
|
||||||
headers: ["Accept": ociManifestMediaType])
|
headers: ["Accept": ociManifestMediaType])
|
||||||
if response.status != .ok {
|
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||||
let body = try await response.body.readTextResponse()
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.status.code,
|
details: data.asText())
|
||||||
details: body ?? "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let manifestData = try await response.body.readResponse()
|
let manifest = try OCIManifest(fromJSON: data)
|
||||||
let manifest = try OCIManifest(fromJSON: manifestData)
|
|
||||||
|
|
||||||
return (manifest, manifestData)
|
return (manifest, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uploadLocationFromResponse(_ response: HTTPClientResponse) throws -> URLComponents {
|
private func uploadLocationFromResponse(_ response: HTTPURLResponse) throws -> URLComponents {
|
||||||
guard let uploadLocationRaw = response.headers.first(name: "Location") else {
|
guard let uploadLocationRaw = response.value(forHTTPHeaderField: "Location") else {
|
||||||
throw RegistryError.MissingLocationHeader
|
throw RegistryError.MissingLocationHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,22 +167,21 @@ class Registry {
|
|||||||
|
|
||||||
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
|
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
|
||||||
// Initiate a blob upload
|
// Initiate a blob upload
|
||||||
let postResponse = try await endpointRequest(.POST, "\(namespace)/blobs/uploads/",
|
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
|
||||||
headers: ["Content-Length": "0"])
|
headers: ["Content-Length": "0"])
|
||||||
if postResponse.status != .accepted {
|
if postResponse.statusCode != HTTPCode.Accepted.rawValue {
|
||||||
let body = try await postResponse.body.readTextResponse()
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.status.code,
|
details: data.asText())
|
||||||
details: body ?? "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Figure out where to upload the blob
|
// Figure out where to upload the blob
|
||||||
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||||
|
|
||||||
let digest = Digest.hash(fromData)
|
let digest = Digest.hash(fromData)
|
||||||
|
|
||||||
if chunkSizeMb == 0 {
|
if chunkSizeMb == 0 {
|
||||||
// monolithic upload
|
// monolithic upload
|
||||||
let response = try await rawRequest(
|
let (data, response) = try await dataRequest(
|
||||||
.PUT,
|
.PUT,
|
||||||
uploadLocation,
|
uploadLocation,
|
||||||
headers: [
|
headers: [
|
||||||
@@ -187,10 +190,9 @@ class Registry {
|
|||||||
parameters: ["digest": digest],
|
parameters: ["digest": digest],
|
||||||
body: fromData
|
body: fromData
|
||||||
)
|
)
|
||||||
if response.status != .created {
|
if response.statusCode != HTTPCode.Created.rawValue {
|
||||||
let body = try await response.body.readTextResponse()
|
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
|
||||||
code: response.status.code, details: body ?? "")
|
code: response.statusCode, details: data.asText())
|
||||||
}
|
}
|
||||||
return digest
|
return digest
|
||||||
}
|
}
|
||||||
@@ -200,7 +202,7 @@ class Registry {
|
|||||||
let chunks = fromData.chunks(ofCount: chunkSizeMb == 0 ? fromData.count : chunkSizeMb * 1_000_000)
|
let chunks = fromData.chunks(ofCount: chunkSizeMb == 0 ? fromData.count : chunkSizeMb * 1_000_000)
|
||||||
for (index, chunk) in chunks.enumerated() {
|
for (index, chunk) in chunks.enumerated() {
|
||||||
let lastChunk = index == (chunks.count - 1)
|
let lastChunk = index == (chunks.count - 1)
|
||||||
let response = try await rawRequest(
|
let (data, response) = try await dataRequest(
|
||||||
lastChunk ? .PUT : .PATCH,
|
lastChunk ? .PUT : .PATCH,
|
||||||
uploadLocation,
|
uploadLocation,
|
||||||
headers: [
|
headers: [
|
||||||
@@ -210,56 +212,63 @@ class Registry {
|
|||||||
parameters: lastChunk ? ["digest": digest] : [:],
|
parameters: lastChunk ? ["digest": digest] : [:],
|
||||||
body: chunk
|
body: chunk
|
||||||
)
|
)
|
||||||
let expectedStatus: HTTPResponseStatus = lastChunk ? .created : .accepted
|
// always accept both statuses since AWS ECR is not following specification
|
||||||
if response.status != expectedStatus {
|
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
|
||||||
let body = try await response.body.readTextResponse()
|
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
|
||||||
code: response.status.code, details: body ?? "")
|
code: response.statusCode, details: data.asText())
|
||||||
}
|
}
|
||||||
uploadedBytes += chunk.count
|
uploadedBytes += chunk.count
|
||||||
// Update location for the next chunk
|
// Update location for the next chunk
|
||||||
uploadLocation = try uploadLocationFromResponse(response)
|
uploadLocation = try uploadLocationFromResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
return digest
|
return digest
|
||||||
}
|
}
|
||||||
|
|
||||||
public func pullBlob(_ digest: String, handler: (ByteBuffer) throws -> Void) async throws {
|
public func pullBlob(_ digest: String, handler: (Data) throws -> Void) async throws {
|
||||||
let response = try await endpointRequest(.GET, "\(namespace)/blobs/\(digest)")
|
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
|
||||||
if response.status != .ok {
|
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||||
let body = try await response.body.readTextResponse()
|
let body = try await channel.asData().asText()
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.status.code,
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
|
||||||
details: body ?? "")
|
details: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
for try await part in response.body {
|
for try await part in channel {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
try handler(part)
|
try handler(Data(part))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func endpointRequest(
|
private func endpointURL(_ endpoint: String) -> URLComponents {
|
||||||
_ method: HTTPMethod,
|
|
||||||
_ endpoint: String,
|
|
||||||
headers: Dictionary<String, String> = Dictionary(),
|
|
||||||
parameters: Dictionary<String, String> = Dictionary(),
|
|
||||||
body: Data? = nil
|
|
||||||
) async throws -> HTTPClientResponse {
|
|
||||||
let url = URL(string: endpoint, relativeTo: baseURL)!
|
let url = URL(string: endpoint, relativeTo: baseURL)!
|
||||||
let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true)!
|
|
||||||
|
|
||||||
return try await rawRequest(method, urlComponents, headers: headers, parameters: parameters, body: body)
|
return URLComponents(url: url, resolvingAgainstBaseURL: true)!
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rawRequest(
|
private func dataRequest(
|
||||||
_ method: HTTPMethod,
|
_ method: HTTPMethod,
|
||||||
_ urlComponents: URLComponents,
|
_ urlComponents: URLComponents,
|
||||||
headers: Dictionary<String, String> = Dictionary(),
|
headers: Dictionary<String, String> = Dictionary(),
|
||||||
parameters: Dictionary<String, String> = Dictionary(),
|
parameters: Dictionary<String, String> = Dictionary(),
|
||||||
body: Data? = nil,
|
body: Data? = nil,
|
||||||
doAuth: Bool = true
|
doAuth: Bool = true
|
||||||
) async throws -> HTTPClientResponse {
|
) async throws -> (Data, HTTPURLResponse) {
|
||||||
|
let (channel, response) = try await channelRequest(method, urlComponents,
|
||||||
|
headers: headers, parameters: parameters, body: body, doAuth: doAuth)
|
||||||
|
|
||||||
|
return (try await channel.asData(), response)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func channelRequest(
|
||||||
|
_ method: HTTPMethod,
|
||||||
|
_ urlComponents: URLComponents,
|
||||||
|
headers: Dictionary<String, String> = Dictionary(),
|
||||||
|
parameters: Dictionary<String, String> = Dictionary(),
|
||||||
|
body: Data? = nil,
|
||||||
|
doAuth: Bool = true,
|
||||||
|
viaFile: Bool = false
|
||||||
|
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
var urlComponents = urlComponents
|
var urlComponents = urlComponents
|
||||||
|
|
||||||
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
||||||
@@ -269,14 +278,14 @@ class Registry {
|
|||||||
URLQueryItem(name: key, value: value)
|
URLQueryItem(name: key, value: value)
|
||||||
})
|
})
|
||||||
|
|
||||||
var request = HTTPClientRequest(url: urlComponents.string!)
|
var request = URLRequest(url: urlComponents.url!)
|
||||||
request.method = method
|
request.httpMethod = method.rawValue
|
||||||
for (key, value) in headers {
|
for (key, value) in headers {
|
||||||
request.headers.add(name: key, value: value)
|
request.addValue(value, forHTTPHeaderField: key)
|
||||||
}
|
}
|
||||||
if body != nil {
|
if let body = body {
|
||||||
request.headers.add(name: "Content-Length", value: "\(body!.count)")
|
request.addValue("\(body.count)", forHTTPHeaderField: "Content-Length")
|
||||||
request.body = HTTPClientRequest.Body.bytes(body!)
|
request.httpBody = body
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate token if it has expired
|
// Invalidate token if it has expired
|
||||||
@@ -284,33 +293,34 @@ class Registry {
|
|||||||
currentAuthToken = nil
|
currentAuthToken = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = try await authAwareRequest(request: request)
|
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||||
|
|
||||||
if doAuth && response.status == .unauthorized {
|
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||||
|
_ = try await channel.asData()
|
||||||
try await auth(response: response)
|
try await auth(response: response)
|
||||||
response = try await authAwareRequest(request: request)
|
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return (channel, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func auth(response: HTTPClientResponse) async throws {
|
private func auth(response: HTTPURLResponse) async throws {
|
||||||
// Process WWW-Authenticate header
|
// Process WWW-Authenticate header
|
||||||
guard let wwwAuthenticateRaw = response.headers.first(name: "WWW-Authenticate") else {
|
guard let wwwAuthenticateRaw = response.value(forHTTPHeaderField: "WWW-Authenticate") else {
|
||||||
throw RegistryError.AuthFailed(why: "got HTTP 401, but WWW-Authenticate header is missing")
|
throw RegistryError.AuthFailed(why: "got HTTP 401, but WWW-Authenticate header is missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
||||||
|
|
||||||
if wwwAuthenticate.scheme == "Basic" {
|
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
||||||
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
if let (user, password) = try lookupCredentials() {
|
||||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
currentAuthToken = BasicAuthentication(user: user, password: password)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if wwwAuthenticate.scheme != "Bearer" {
|
if wwwAuthenticate.scheme.lowercased() != "bearer" {
|
||||||
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
|
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
|
||||||
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
|
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
|
||||||
}
|
}
|
||||||
@@ -340,23 +350,27 @@ class Registry {
|
|||||||
|
|
||||||
var headers: Dictionary<String, String> = Dictionary()
|
var headers: Dictionary<String, String> = Dictionary()
|
||||||
|
|
||||||
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
if let (user, password) = try lookupCredentials() {
|
||||||
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
|
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
|
||||||
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = try await rawRequest(.GET, authenticateURL, headers: headers, doAuth: false)
|
let (data, response) = try await dataRequest(.GET, authenticateURL, headers: headers, doAuth: false)
|
||||||
if response.status != .ok {
|
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||||
let body = try await response.body.readTextResponse() ?? ""
|
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.statusCode) "
|
||||||
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.status.code) "
|
+ "while retrieving an authentication token", details: data.asText())
|
||||||
+ "while retrieving an authentication token", details: body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let bodyData = try await response.body.readResponse()
|
currentAuthToken = try TokenResponse.parse(fromData: data)
|
||||||
currentAuthToken = try TokenResponse.parse(fromData: bodyData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func lookupCredentials(host: String) throws -> (String, String)? {
|
private func lookupCredentials() throws -> (String, String)? {
|
||||||
|
var host = baseURL.host!
|
||||||
|
|
||||||
|
if let port = baseURL.port {
|
||||||
|
host += ":\(port)"
|
||||||
|
}
|
||||||
|
|
||||||
for provider in credentialsProviders {
|
for provider in credentialsProviders {
|
||||||
if let (user, password) = try provider.retrieve(host: host) {
|
if let (user, password) = try provider.retrieve(host: host) {
|
||||||
return (user, password)
|
return (user, password)
|
||||||
@@ -365,14 +379,14 @@ class Registry {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func authAwareRequest(request: HTTPClientRequest) async throws -> HTTPClientResponse {
|
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
var request = request
|
var request = request
|
||||||
|
|
||||||
if let token = currentAuthToken {
|
if let token = currentAuthToken {
|
||||||
let (name, value) = token.header()
|
let (name, value) = token.header()
|
||||||
request.headers.add(name: name, value: value)
|
request.addValue(value, forHTTPHeaderField: name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return try await httpClient.execute(request, deadline: .distantFuture)
|
return try await Fetcher.fetch(request, viaFile: viaFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ struct RemoteName: Comparable, Hashable, CustomStringConvertible {
|
|||||||
try ParseTreeWalker().walk(referenceCollector, try parser.root())
|
try ParseTreeWalker().walk(referenceCollector, try parser.root())
|
||||||
|
|
||||||
if let error = errorCollector.error {
|
if let error = errorCollector.error {
|
||||||
throw RuntimeError("failed to parse remote name: \(error)")
|
throw RuntimeError.FailedToParseRemoteName("\(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
host = referenceCollector.host!
|
host = referenceCollector.host!
|
||||||
@@ -120,7 +120,7 @@ struct RemoteName: Comparable, Hashable, CustomStringConvertible {
|
|||||||
} else if reference.starts(with: ":") {
|
} else if reference.starts(with: ":") {
|
||||||
self.reference = Reference(tag: String(reference.dropFirst(1)))
|
self.reference = Reference(tag: String(reference.dropFirst(1)))
|
||||||
} else {
|
} else {
|
||||||
throw RuntimeError("failed to parse remote name: unknown reference format")
|
throw RuntimeError.FailedToParseRemoteName("unknown reference format")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.reference = Reference(tag: "latest")
|
self.reference = Reference(tag: "latest")
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
import System
|
||||||
|
|
||||||
|
class PIDLock {
|
||||||
|
let url: URL
|
||||||
|
let fd: Int32
|
||||||
|
|
||||||
|
init(lockURL: URL) throws {
|
||||||
|
url = lockURL
|
||||||
|
fd = open(lockURL.path, O_RDWR)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
close(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trylock() throws -> Bool {
|
||||||
|
let (locked, _) = try lockWrapper(F_SETLK, F_WRLCK, "failed to lock \(url)")
|
||||||
|
return locked
|
||||||
|
}
|
||||||
|
|
||||||
|
func lock() throws {
|
||||||
|
_ = try lockWrapper(F_SETLKW, F_WRLCK, "failed to lock \(url)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func unlock() throws {
|
||||||
|
_ = try lockWrapper(F_SETLK, F_UNLCK, "failed to unlock \(url)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pid() throws -> pid_t {
|
||||||
|
let (_, result) = try lockWrapper(F_GETLK, F_RDLCK, "failed to get lock \(url) status")
|
||||||
|
|
||||||
|
return result.l_pid
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockWrapper(_ operation: Int32, _ type: Int32, _ message: String) throws -> (Bool, flock) {
|
||||||
|
var result = flock(l_start: 0, l_len: 0, l_pid: 0, l_type: Int16(type), l_whence: Int16(SEEK_SET))
|
||||||
|
|
||||||
|
let ret = fcntl(fd, operation, &result)
|
||||||
|
if ret != 0 {
|
||||||
|
if operation == F_SETLK && errno == EAGAIN {
|
||||||
|
return (false, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError.PIDLockFailed("\(message): \(details)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct PassphraseGenerator: Sequence {
|
struct PassphraseGenerator: Sequence {
|
||||||
func makeIterator() -> PassphraseIterator {
|
func makeIterator() -> PassphraseIterator {
|
||||||
PassphraseIterator()
|
PassphraseIterator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PassphraseIterator: IteratorProtocol {
|
struct PassphraseIterator: IteratorProtocol {
|
||||||
mutating func next() -> String? {
|
mutating func next() -> String? {
|
||||||
passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
|
passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ let passphrases = [
|
|||||||
"act",
|
"act",
|
||||||
"action",
|
"action",
|
||||||
"actor",
|
"actor",
|
||||||
" actress",
|
"actress",
|
||||||
"actual",
|
"actual",
|
||||||
"adapt",
|
"adapt",
|
||||||
"add",
|
"add",
|
||||||
@@ -829,7 +829,7 @@ let passphrases = [
|
|||||||
"grow",
|
"grow",
|
||||||
"grunt",
|
"grunt",
|
||||||
"guard",
|
"guard",
|
||||||
" guess",
|
"guess",
|
||||||
"guide",
|
"guide",
|
||||||
"guilt",
|
"guilt",
|
||||||
"guitar",
|
"guitar",
|
||||||
@@ -1476,7 +1476,7 @@ let passphrases = [
|
|||||||
"retire",
|
"retire",
|
||||||
"retreat",
|
"retreat",
|
||||||
"return",
|
"return",
|
||||||
" reunion",
|
"reunion",
|
||||||
"reveal",
|
"reveal",
|
||||||
"review",
|
"review",
|
||||||
"reward",
|
"reward",
|
||||||
@@ -1764,7 +1764,7 @@ let passphrases = [
|
|||||||
"swim",
|
"swim",
|
||||||
"swing",
|
"swing",
|
||||||
"switch",
|
"switch",
|
||||||
" sword",
|
"sword",
|
||||||
"symbol",
|
"symbol",
|
||||||
"symptom",
|
"symptom",
|
||||||
"syrup",
|
"syrup",
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum Architecture: String, Codable {
|
enum Architecture: String, Codable {
|
||||||
case arm64
|
case arm64
|
||||||
case amd64
|
case amd64
|
||||||
}
|
}
|
||||||
|
|
||||||
func CurrentArchitecture() -> Architecture {
|
func CurrentArchitecture() -> Architecture {
|
||||||
#if arch(arm64)
|
#if arch(arm64)
|
||||||
return .arm64
|
return .arm64
|
||||||
#elseif arch(x86_64)
|
#elseif arch(x86_64)
|
||||||
return .amd64
|
return .amd64
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,97 @@
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
struct Darwin: Platform {
|
struct Darwin: Platform {
|
||||||
var ecid: VZMacMachineIdentifier
|
var ecid: VZMacMachineIdentifier
|
||||||
var hardwareModel: VZMacHardwareModel
|
var hardwareModel: VZMacHardwareModel
|
||||||
|
|
||||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||||
self.ecid = ecid
|
self.ecid = ecid
|
||||||
self.hardwareModel = hardwareModel
|
self.hardwareModel = hardwareModel
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
||||||
|
guard let data = Data.init(base64Encoded: encodedECID) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||||
|
in: container,
|
||||||
|
debugDescription: "failed to initialize Data using the provided value")
|
||||||
|
}
|
||||||
|
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||||
|
in: container,
|
||||||
|
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
||||||
|
}
|
||||||
|
self.ecid = ecid
|
||||||
|
|
||||||
|
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
||||||
|
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||||
|
}
|
||||||
|
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||||
|
}
|
||||||
|
self.hardwareModel = hardwareModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||||
|
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func os() -> OS {
|
||||||
|
.darwin
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||||
|
VZMacOSBootLoader()
|
||||||
|
}
|
||||||
|
|
||||||
|
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||||
|
let result = VZMacPlatformConfiguration()
|
||||||
|
|
||||||
|
result.machineIdentifier = ecid
|
||||||
|
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
||||||
|
result.hardwareModel = hardwareModel
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||||
|
let result = VZMacGraphicsDeviceConfiguration()
|
||||||
|
|
||||||
|
if let hostMainScreen = NSScreen.main {
|
||||||
|
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
||||||
|
result.displays = [
|
||||||
|
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
result.displays = [
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
VZMacGraphicsDisplayConfiguration(
|
||||||
|
widthInPixels: vmConfig.display.width,
|
||||||
|
heightInPixels: vmConfig.display.height,
|
||||||
|
// A reasonable guess according to Apple's documentation[1]
|
||||||
|
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||||
|
pixelsPerInch: 72
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
return result
|
||||||
guard let data = Data.init(base64Encoded: encodedECID) else {
|
}
|
||||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
|
||||||
in: container,
|
|
||||||
debugDescription: "failed to initialize Data using the provided value")
|
|
||||||
}
|
|
||||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
|
||||||
in: container,
|
|
||||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
|
||||||
}
|
|
||||||
self.ecid = ecid
|
|
||||||
|
|
||||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
if #available(macOS 13, *) {
|
||||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
// Trackpad is only supported starting with macOS Ventura
|
||||||
}
|
// macOS Monterey will continue using a USB device == .darwin
|
||||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
} else {
|
||||||
}
|
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
self.hardwareModel = hardwareModel
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
|
||||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
func os() -> OS {
|
|
||||||
.darwin
|
|
||||||
}
|
|
||||||
|
|
||||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
|
||||||
VZMacOSBootLoader()
|
|
||||||
}
|
|
||||||
|
|
||||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
|
||||||
let result = VZMacPlatformConfiguration()
|
|
||||||
|
|
||||||
result.machineIdentifier = ecid
|
|
||||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
|
||||||
result.hardwareModel = hardwareModel
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
|
||||||
let result = VZMacGraphicsDeviceConfiguration()
|
|
||||||
|
|
||||||
if let hostMainScreen = NSScreen.main {
|
|
||||||
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
|
||||||
result.displays = [
|
|
||||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
|
||||||
]
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
result.displays = [
|
|
||||||
VZMacGraphicsDisplayConfiguration(
|
|
||||||
widthInPixels: vmConfig.display.width,
|
|
||||||
heightInPixels: vmConfig.display.height,
|
|
||||||
// A reasonable guess according to Apple's documentation[1]
|
|
||||||
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
|
||||||
pixelsPerInch: 72
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
|
||||||
if #available(macOS 13, *) {
|
|
||||||
// Trackpad is only supported starting with macOS Ventura
|
|
||||||
// macOS Monterey will continue using a USB device == .darwin
|
|
||||||
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
|
||||||
} else {
|
|
||||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,36 +2,36 @@ import Virtualization
|
|||||||
|
|
||||||
@available(macOS 13, *)
|
@available(macOS 13, *)
|
||||||
struct Linux: Platform {
|
struct Linux: Platform {
|
||||||
func os() -> OS {
|
func os() -> OS {
|
||||||
.linux
|
.linux
|
||||||
}
|
}
|
||||||
|
|
||||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||||
let result = VZEFIBootLoader()
|
let result = VZEFIBootLoader()
|
||||||
|
|
||||||
result.variableStore = VZEFIVariableStore(url: nvramURL)
|
result.variableStore = VZEFIVariableStore(url: nvramURL)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||||
VZGenericPlatformConfiguration()
|
VZGenericPlatformConfiguration()
|
||||||
}
|
}
|
||||||
|
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||||
let result = VZVirtioGraphicsDeviceConfiguration()
|
let result = VZVirtioGraphicsDeviceConfiguration()
|
||||||
|
|
||||||
result.scanouts = [
|
result.scanouts = [
|
||||||
VZVirtioGraphicsScanoutConfiguration(
|
VZVirtioGraphicsScanoutConfiguration(
|
||||||
widthInPixels: vmConfig.display.width,
|
widthInPixels: vmConfig.display.width,
|
||||||
heightInPixels: vmConfig.display.height
|
heightInPixels: vmConfig.display.height
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
enum OS: String, Codable {
|
enum OS: String, Codable {
|
||||||
case darwin
|
case darwin
|
||||||
case linux
|
case linux
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
protocol Platform: Codable {
|
protocol Platform: Codable {
|
||||||
func os() -> OS
|
func os() -> OS
|
||||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||||
func platform(nvramURL: URL) -> VZPlatformConfiguration
|
func platform(nvramURL: URL) -> VZPlatformConfiguration
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ protocol PrunableStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protocol Prunable {
|
protocol Prunable {
|
||||||
|
var url: URL { get }
|
||||||
func delete() throws
|
func delete() throws
|
||||||
func accessDate() throws -> Date
|
func accessDate() throws -> Date
|
||||||
func sizeBytes() throws -> Int
|
func sizeBytes() throws -> Int
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Sentry
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct Root: AsyncParsableCommand {
|
struct Root: AsyncParsableCommand {
|
||||||
@@ -11,16 +13,51 @@ struct Root: AsyncParsableCommand {
|
|||||||
Clone.self,
|
Clone.self,
|
||||||
Run.self,
|
Run.self,
|
||||||
Set.self,
|
Set.self,
|
||||||
|
Get.self,
|
||||||
List.self,
|
List.self,
|
||||||
Login.self,
|
Login.self,
|
||||||
IP.self,
|
IP.self,
|
||||||
Pull.self,
|
Pull.self,
|
||||||
Push.self,
|
Push.self,
|
||||||
|
Import.self,
|
||||||
|
Export.self,
|
||||||
Prune.self,
|
Prune.self,
|
||||||
|
Rename.self,
|
||||||
|
Stop.self,
|
||||||
Delete.self,
|
Delete.self,
|
||||||
|
ReportInstallation.self,
|
||||||
])
|
])
|
||||||
|
|
||||||
public static func main() async throws {
|
public static func main() async throws {
|
||||||
|
// Initialize Sentry
|
||||||
|
if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
|
||||||
|
SentrySDK.start { options in
|
||||||
|
options.dsn = dsn
|
||||||
|
options.releaseName = CI.release
|
||||||
|
options.tracesSampleRate = Float(
|
||||||
|
ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
|
||||||
|
) as NSNumber?
|
||||||
|
|
||||||
|
// By default only 5XX are captured
|
||||||
|
// Let's capture everything but 401 (unauthorized)
|
||||||
|
options.enableCaptureFailedRequests = true
|
||||||
|
options.failedRequestStatusCodes = [
|
||||||
|
HttpStatusCodeRange(min: 400, max: 400),
|
||||||
|
HttpStatusCodeRange(min: 402, max: 599)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
||||||
|
|
||||||
|
// Enrich future events with Cirrus CI-specific tags
|
||||||
|
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
||||||
|
SentrySDK.configureScope { scope in
|
||||||
|
for (key, value) in tags.split(separator: ",").compactMap({ parseCirrusSentryTag($0) }) {
|
||||||
|
scope.setTag(value: value, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure the default SIGINT handled is disabled,
|
// Ensure the default SIGINT handled is disabled,
|
||||||
// otherwise there's a race between two handlers
|
// otherwise there's a race between two handlers
|
||||||
signal(SIGINT, SIG_IGN);
|
signal(SIGINT, SIG_IGN);
|
||||||
@@ -32,17 +69,48 @@ struct Root: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
sigintSrc.activate()
|
sigintSrc.activate()
|
||||||
|
|
||||||
|
// Set line-buffered output for stdout
|
||||||
|
setlinebuf(stdout)
|
||||||
|
|
||||||
// Parse and run command
|
// Parse and run command
|
||||||
do {
|
do {
|
||||||
var command = try parseAsRoot()
|
var command = try parseAsRoot()
|
||||||
|
|
||||||
|
// Run garbage-collection before each command (shouldn't take too long)
|
||||||
|
do {
|
||||||
|
try Config().gc()
|
||||||
|
} catch {
|
||||||
|
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
|
||||||
|
}
|
||||||
|
|
||||||
if var asyncCommand = command as? AsyncParsableCommand {
|
if var asyncCommand = command as? AsyncParsableCommand {
|
||||||
try await asyncCommand.run()
|
try await asyncCommand.run()
|
||||||
} else {
|
} else {
|
||||||
try command.run()
|
try command.run()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
// Capture the error into Sentry
|
||||||
|
SentrySDK.capture(error: error)
|
||||||
|
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||||
|
|
||||||
|
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
|
||||||
|
if let errorWithExitCode = error as? HasExitCode {
|
||||||
|
print(error)
|
||||||
|
|
||||||
|
Foundation.exit(errorWithExitCode.exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle any other exception, including ArgumentParser's ones
|
||||||
exit(withError: error)
|
exit(withError: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func parseCirrusSentryTag(_ tag: String.SubSequence) -> (String, String)? {
|
||||||
|
let splits = tag.split(separator: "=", maxSplits: 1)
|
||||||
|
if splits.count != 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return (String(splits[0]), String(splits[1]))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum SoftnetError: Error {
|
|
||||||
case InitializationFailed(why: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Softnet {
|
|
||||||
private let process = Process()
|
|
||||||
|
|
||||||
let vmFD: Int32
|
|
||||||
|
|
||||||
init(vmMACAddress: String) throws {
|
|
||||||
let binaryName = "softnet"
|
|
||||||
|
|
||||||
guard let executableURL = resolveBinaryPath(binaryName) else {
|
|
||||||
throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH")
|
|
||||||
}
|
|
||||||
|
|
||||||
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
|
||||||
|
|
||||||
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
|
||||||
if ret != 0 {
|
|
||||||
throw SoftnetError.InitializationFailed(why: "socketpair() failed with exit code \(ret)")
|
|
||||||
}
|
|
||||||
|
|
||||||
vmFD = fds[0]
|
|
||||||
let softnetFD = fds[1]
|
|
||||||
|
|
||||||
try setSocketBuffers(vmFD, 1 * 1024 * 1024);
|
|
||||||
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
|
||||||
|
|
||||||
process.executableURL = executableURL
|
|
||||||
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress]
|
|
||||||
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func run() throws {
|
|
||||||
try process.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() throws {
|
|
||||||
process.interrupt()
|
|
||||||
process.waitUntilExit()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
|
|
||||||
var option_value = sizeBytes
|
|
||||||
let option_len = socklen_t(MemoryLayout<Int>.size)
|
|
||||||
|
|
||||||
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &option_value, option_len)
|
|
||||||
if ret != 0 {
|
|
||||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)")
|
|
||||||
}
|
|
||||||
|
|
||||||
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &option_value, option_len)
|
|
||||||
if ret != 0 {
|
|
||||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ extension URL {
|
|||||||
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
||||||
let ret = utimes(path, times)
|
let ret = utimes(path, times)
|
||||||
if ret != 0 {
|
if ret != 0 {
|
||||||
throw RuntimeError("utimes(2) failed: \(ret.explanation())")
|
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(ret.explanation())")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension URL: Prunable {
|
extension URL: Prunable {
|
||||||
|
var url: URL {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
func delete() throws {
|
func delete() throws {
|
||||||
try FileManager.default.removeItem(at: self)
|
try FileManager.default.removeItem(at: self)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func resolveBinaryPath(_ name: String) -> URL? {
|
|||||||
|
|
||||||
for pathComponent in path.split(separator: ":") {
|
for pathComponent in path.split(separator: ":") {
|
||||||
let url = URL(fileURLWithPath: String(pathComponent))
|
let url = URL(fileURLWithPath: String(pathComponent))
|
||||||
.appendingPathComponent(name, isDirectory: false)
|
.appendingPathComponent(name, isDirectory: false)
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: url.path) {
|
if FileManager.default.fileExists(atPath: url.path) {
|
||||||
return url
|
return url
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
import AsyncAlgorithms
|
||||||
|
|
||||||
struct UnsupportedRestoreImageError: Error {
|
struct UnsupportedRestoreImageError: Error {
|
||||||
}
|
}
|
||||||
@@ -13,8 +14,8 @@ struct DownloadFailed: Error {
|
|||||||
struct UnsupportedOSError: Error, CustomStringConvertible {
|
struct UnsupportedOSError: Error, CustomStringConvertible {
|
||||||
let description: String
|
let description: String
|
||||||
|
|
||||||
init(_ what: String, _ plural: String) {
|
init(_ what: String, _ plural: String, _ requires: String = "running macOS 13.0 (Ventura) or newer") {
|
||||||
description = "error: \(what) \(plural) only supported on hosts running macOS 13.0 (Ventura) or newer"
|
description = "error: \(what) \(plural) only supported on hosts \(requires)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,16 +31,16 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
// VM's config
|
// VM's config
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
// VM's config
|
// VM's config
|
||||||
var config: VMConfig
|
var config: VMConfig
|
||||||
|
|
||||||
var softnet: Softnet? = nil
|
var network: Network
|
||||||
|
|
||||||
init(vmDir: VMDirectory,
|
init(vmDir: VMDirectory,
|
||||||
withSoftnet: Bool = false,
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||||
directoryShares: [DirectoryShare] = []
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = []
|
||||||
) throws {
|
) throws {
|
||||||
name = vmDir.name
|
name = vmDir.name
|
||||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
@@ -49,75 +50,100 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the virtual machine and its configuration
|
// Initialize the virtual machine and its configuration
|
||||||
if withSoftnet {
|
self.network = network
|
||||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
|
||||||
}
|
|
||||||
|
|
||||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||||
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments,
|
network: network, additionalDiskAttachments: additionalDiskAttachments,
|
||||||
directoryShares: directoryShares)
|
directorySharingDevices: directorySharingDevices
|
||||||
|
)
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
virtualMachine.delegate = self
|
virtualMachine.delegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
static func retrieveLatestIPSW() async throws -> URL {
|
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
|
||||||
|
// Check if we already have this IPSW in cache
|
||||||
|
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
|
||||||
|
|
||||||
|
if let hash = response.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
|
||||||
|
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: ipswLocation.path) {
|
||||||
|
defaultLogger.appendNewLine("Using cached *.ipsw file...")
|
||||||
|
try ipswLocation.updateAccessDate()
|
||||||
|
|
||||||
|
return ipswLocation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the IPSW
|
||||||
|
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
|
||||||
|
|
||||||
|
let progress = Progress(totalUnitCount: response.expectedContentLength)
|
||||||
|
ProgressObserver(progress).log(defaultLogger)
|
||||||
|
|
||||||
|
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw")
|
||||||
|
FileManager.default.createFile(atPath: temporaryLocation.path, contents: nil)
|
||||||
|
let lock = try FileLock(lockURL: temporaryLocation)
|
||||||
|
try lock.lock()
|
||||||
|
|
||||||
|
let fileHandle = try FileHandle(forWritingTo: temporaryLocation)
|
||||||
|
let digest = Digest()
|
||||||
|
|
||||||
|
for try await chunk in channel {
|
||||||
|
let chunkAsData = Data(chunk)
|
||||||
|
fileHandle.write(chunkAsData)
|
||||||
|
digest.update(chunkAsData)
|
||||||
|
progress.completedUnitCount += Int64(chunk.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
try fileHandle.close()
|
||||||
|
|
||||||
|
let finalLocation = try IPSWCache().locationFor(fileName: digest.finalize() + ".ipsw")
|
||||||
|
|
||||||
|
return try FileManager.default.replaceItemAt(finalLocation, withItemAt: temporaryLocation)!
|
||||||
|
}
|
||||||
|
|
||||||
|
static func latestIPSWURL() async throws -> URL {
|
||||||
defaultLogger.appendNewLine("Looking up the latest supported IPSW...")
|
defaultLogger.appendNewLine("Looking up the latest supported IPSW...")
|
||||||
|
|
||||||
let image = try await withCheckedThrowingContinuation { continuation in
|
let image = try await withCheckedThrowingContinuation { continuation in
|
||||||
VZMacOSRestoreImage.fetchLatestSupported() { result in
|
VZMacOSRestoreImage.fetchLatestSupported() { result in
|
||||||
continuation.resume(with: result)
|
continuation.resume(with: result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let expectedIPSWLocation = try IPSWCache().locationFor(image: image)
|
return image.url
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: expectedIPSWLocation.path) {
|
|
||||||
defaultLogger.appendNewLine("Using cached *.ipsw file...")
|
|
||||||
try expectedIPSWLocation.updateAccessDate()
|
|
||||||
return expectedIPSWLocation
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultLogger.appendNewLine("Fetching \(expectedIPSWLocation.lastPathComponent)...")
|
|
||||||
|
|
||||||
let data: Data = try await withCheckedThrowingContinuation { continuation in
|
|
||||||
let downloadedTask = URLSession.shared.dataTask(with: image.url) { data, response, error in
|
|
||||||
if error != nil {
|
|
||||||
continuation.resume(throwing: error!)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (data == nil) {
|
|
||||||
continuation.resume(throwing: DownloadFailed())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
continuation.resume(returning: data!)
|
|
||||||
}
|
|
||||||
ProgressObserver(downloadedTask.progress).log(defaultLogger)
|
|
||||||
downloadedTask.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
try data.write(to: expectedIPSWLocation, options: [.atomic])
|
|
||||||
return expectedIPSWLocation
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var inFinalState: Bool {
|
var inFinalState: Bool {
|
||||||
get {
|
get {
|
||||||
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
||||||
virtualMachine.state == VZVirtualMachine.State.paused ||
|
virtualMachine.state == VZVirtualMachine.State.paused ||
|
||||||
virtualMachine.state == VZVirtualMachine.State.error
|
virtualMachine.state == VZVirtualMachine.State.error
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
vmDir: VMDirectory,
|
vmDir: VMDirectory,
|
||||||
ipswURL: URL?,
|
ipswURL: URL,
|
||||||
diskSizeGB: UInt16,
|
diskSizeGB: UInt16,
|
||||||
withSoftnet: Bool = false,
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
|
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||||
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = []
|
||||||
) async throws {
|
) async throws {
|
||||||
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
|
var ipswURL = ipswURL
|
||||||
|
|
||||||
|
if !ipswURL.isFileURL {
|
||||||
|
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
|
||||||
|
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
|
||||||
|
// cannot deal with paths that contain symlinks, so expand them here first.
|
||||||
|
ipswURL.resolveSymlinksInPath()
|
||||||
|
|
||||||
// Load the restore image and try to get the requirements
|
// Load the restore image and try to get the requirements
|
||||||
// that match both the image and our platform
|
// that match both the image and our platform
|
||||||
@@ -149,14 +175,12 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
try config.save(toURL: vmDir.configURL)
|
try config.save(toURL: vmDir.configURL)
|
||||||
|
|
||||||
// Initialize the virtual machine and its configuration
|
// Initialize the virtual machine and its configuration
|
||||||
if withSoftnet {
|
self.network = network
|
||||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
|
||||||
}
|
|
||||||
|
|
||||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||||
vmConfig: config, softnet: softnet,
|
vmConfig: config, network: network,
|
||||||
additionalDiskAttachments: additionalDiskAttachments,
|
additionalDiskAttachments: additionalDiskAttachments,
|
||||||
directoryShares: [])
|
directorySharingDevices: directorySharingDevices
|
||||||
|
)
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
@@ -164,7 +188,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
// Run automated installation
|
// Run automated installation
|
||||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async { [ipswURL] in
|
||||||
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: ipswURL)
|
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: ipswURL)
|
||||||
|
|
||||||
defaultLogger.appendNewLine("Installing OS...")
|
defaultLogger.appendNewLine("Installing OS...")
|
||||||
@@ -193,11 +217,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run(_ recovery: Bool) async throws {
|
func run(_ recovery: Bool) async throws {
|
||||||
if let softnet = softnet {
|
try network.run(sema)
|
||||||
try softnet.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.sync {
|
let startTask = DispatchQueue.main.sync {
|
||||||
Task {
|
Task {
|
||||||
if #available(macOS 13, *) {
|
if #available(macOS 13, *) {
|
||||||
// new API introduced in Ventura
|
// new API introduced in Ventura
|
||||||
@@ -211,7 +233,12 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await withTaskCancellationHandler(operation: {
|
try await withTaskCancellationHandler(operation: {
|
||||||
|
// Await on VZVirtualMachine.start() result
|
||||||
|
_ = try await startTask.value
|
||||||
|
|
||||||
|
// Wait for the VM to finish running
|
||||||
|
// or for the exit condition
|
||||||
sema.wait()
|
sema.wait()
|
||||||
}, onCancel: {
|
}, onCancel: {
|
||||||
sema.signal()
|
sema.signal()
|
||||||
@@ -225,18 +252,16 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let softnet = softnet {
|
try await network.stop()
|
||||||
try softnet.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func craftConfiguration(
|
static func craftConfiguration(
|
||||||
diskURL: URL,
|
diskURL: URL,
|
||||||
nvramURL: URL,
|
nvramURL: URL,
|
||||||
vmConfig: VMConfig,
|
vmConfig: VMConfig,
|
||||||
softnet: Softnet? = nil,
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
||||||
directoryShares: [DirectoryShare]
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration]
|
||||||
) throws -> VZVirtualMachineConfiguration {
|
) throws -> VZVirtualMachineConfiguration {
|
||||||
let configuration = VZVirtualMachineConfiguration()
|
let configuration = VZVirtualMachineConfiguration()
|
||||||
|
|
||||||
@@ -268,13 +293,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
// Networking
|
// Networking
|
||||||
let vio = VZVirtioNetworkDeviceConfiguration()
|
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||||
|
vio.attachment = network.attachment()
|
||||||
if let softnet = softnet {
|
|
||||||
let fh = FileHandle.init(fileDescriptor: softnet.vmFD)
|
|
||||||
vio.attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
|
||||||
} else {
|
|
||||||
vio.attachment = VZNATNetworkDeviceAttachment()
|
|
||||||
}
|
|
||||||
vio.macAddress = vmConfig.macAddress
|
vio.macAddress = vmConfig.macAddress
|
||||||
configuration.networkDevices = [vio]
|
configuration.networkDevices = [vio]
|
||||||
|
|
||||||
@@ -286,19 +305,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
// Entropy
|
// Entropy
|
||||||
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||||
|
|
||||||
// Directory share
|
// Directory sharing devices
|
||||||
if #available(macOS 13, *) {
|
configuration.directorySharingDevices = directorySharingDevices
|
||||||
var directories: [String : VZSharedDirectory] = Dictionary()
|
|
||||||
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
|
|
||||||
|
|
||||||
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
|
||||||
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
|
|
||||||
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
|
|
||||||
|
|
||||||
configuration.directorySharingDevices = [sharingDevice]
|
|
||||||
} else if !directoryShares.isEmpty {
|
|
||||||
throw UnsupportedOSError("directory sharing", "is")
|
|
||||||
}
|
|
||||||
|
|
||||||
try configuration.validate()
|
try configuration.validate()
|
||||||
|
|
||||||
@@ -311,7 +319,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
|
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
|
||||||
print("guest has stopped the virtual machine due to error")
|
print("guest has stopped the virtual machine due to error: \(error)")
|
||||||
sema.signal()
|
sema.signal()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,9 +328,3 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
sema.signal()
|
sema.signal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DirectoryShare {
|
|
||||||
let name: String
|
|
||||||
let path: URL
|
|
||||||
let readOnly: Bool
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ struct VMDisplayConfig: Codable {
|
|||||||
var height: Int = 768
|
var height: Int = 768
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension VMDisplayConfig: CustomStringConvertible {
|
||||||
|
var description: String {
|
||||||
|
"\(width)x\(height)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct VMConfig: Codable {
|
struct VMConfig: Codable {
|
||||||
var version: Int = 1
|
var version: Int = 1
|
||||||
var os: OS
|
var os: OS
|
||||||
@@ -48,10 +54,10 @@ struct VMConfig: Codable {
|
|||||||
var display: VMDisplayConfig = VMDisplayConfig()
|
var display: VMDisplayConfig = VMDisplayConfig()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
platform: Platform,
|
platform: Platform,
|
||||||
cpuCountMin: Int,
|
cpuCountMin: Int,
|
||||||
memorySizeMin: UInt64,
|
memorySizeMin: UInt64,
|
||||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
||||||
) {
|
) {
|
||||||
self.os = platform.os()
|
self.os = platform.os()
|
||||||
self.arch = CurrentArchitecture()
|
self.arch = CurrentArchitecture()
|
||||||
@@ -105,12 +111,12 @@ struct VMConfig: Codable {
|
|||||||
let encodedMacAddress = try container.decode(String.self, forKey: .macAddress)
|
let encodedMacAddress = try container.decode(String.self, forKey: .macAddress)
|
||||||
guard let macAddress = VZMACAddress.init(string: encodedMacAddress) else {
|
guard let macAddress = VZMACAddress.init(string: encodedMacAddress) else {
|
||||||
throw DecodingError.dataCorruptedError(
|
throw DecodingError.dataCorruptedError(
|
||||||
forKey: .hardwareModel,
|
forKey: .hardwareModel,
|
||||||
in: container,
|
in: container,
|
||||||
debugDescription: "failed to initialize VZMacAddress using the provided value")
|
debugDescription: "failed to initialize VZMacAddress using the provided value")
|
||||||
}
|
}
|
||||||
self.macAddress = macAddress
|
self.macAddress = macAddress
|
||||||
|
|
||||||
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
|
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +138,7 @@ struct VMConfig: Codable {
|
|||||||
mutating func setCPU(cpuCount: Int) throws {
|
mutating func setCPU(cpuCount: Int) throws {
|
||||||
if cpuCount < cpuCountMin {
|
if cpuCount < cpuCountMin {
|
||||||
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
||||||
+ " at minimum (requested \(cpuCount))")
|
+ " at minimum (requested \(cpuCount))")
|
||||||
}
|
}
|
||||||
|
|
||||||
self.cpuCount = cpuCount
|
self.cpuCount = cpuCount
|
||||||
@@ -141,7 +147,7 @@ struct VMConfig: Codable {
|
|||||||
mutating func setMemory(memorySize: UInt64) throws {
|
mutating func setMemory(memorySize: UInt64) throws {
|
||||||
if memorySize < memorySizeMin {
|
if memorySize < memorySizeMin {
|
||||||
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
||||||
+ " of memory at minimum (requested \(memorySizeMin))")
|
+ " of memory at minimum (requested \(memorySizeMin))")
|
||||||
}
|
}
|
||||||
|
|
||||||
self.memorySize = memorySize
|
self.memorySize = memorySize
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import System
|
||||||
|
import AppleArchive
|
||||||
|
|
||||||
|
fileprivate let permissions = FilePermissions(rawValue: 0o644)
|
||||||
|
|
||||||
|
// Compresses VMDirectory using Apple's proprietary archive format[1] and LZFSE compression,
|
||||||
|
// which is recommended on Apple platforms[2].
|
||||||
|
//
|
||||||
|
// [1]: https://developer.apple.com/documentation/accelerate/compressing_file_system_directories
|
||||||
|
// [2]: https://developer.apple.com/documentation/compression/algorithm/lzfse
|
||||||
|
extension VMDirectory {
|
||||||
|
func exportToArchive(path: String) throws {
|
||||||
|
guard let fileStream = ArchiveByteStream.fileStream(
|
||||||
|
path: FilePath(path),
|
||||||
|
mode: .writeOnly,
|
||||||
|
options: [.create, .truncate],
|
||||||
|
permissions: permissions
|
||||||
|
) else {
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError.ExportFailed("ArchiveByteStream.fileStream() failed: \(details)")
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
try? fileStream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let compressionStream = ArchiveByteStream.compressionStream(
|
||||||
|
using: .lzfse,
|
||||||
|
writingTo: fileStream
|
||||||
|
) else {
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError.ExportFailed("ArchiveByteStream.compressionStream() failed: \(details)")
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
try? compressionStream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let encodeStream = ArchiveStream.encodeStream(writingTo: compressionStream) else {
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError.ExportFailed("ArchiveStream.encodeStream() failed: \(details)")
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
try? encodeStream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let keySet = ArchiveHeader.FieldKeySet("TYP,PAT,LNK,DEV,DAT,UID,GID,MOD,FLG,MTM,BTM,CTM") else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try encodeStream.writeDirectoryContents(archiveFrom: FilePath(baseURL.path), keySet: keySet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func importFromArchive(path: String) throws {
|
||||||
|
guard let fileStream = ArchiveByteStream.fileStream(path: FilePath(path), mode: .readOnly, options: [],
|
||||||
|
permissions: permissions) else {
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError.ImportFailed("ArchiveByteStream.fileStream() failed: \(details)")
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
try? fileStream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let decompressionStream = ArchiveByteStream.decompressionStream(readingFrom: fileStream) else {
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError.ImportFailed("ArchiveByteStream.decompressionStream() failed: \(details)")
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
try? decompressionStream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let decodeStream = ArchiveStream.decodeStream(readingFrom: decompressionStream) else {
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError.ImportFailed("ArchiveStream.decodeStream() failed: \(details)")
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
try? decodeStream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let extractStream = ArchiveStream.extractStream(extractingTo: FilePath(baseURL.path)) else {
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError.ImportFailed("ArchiveStream.extractStream() failed: \(details)")
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
try? extractStream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = try ArchiveStream.process(readingFrom: decodeStream, writingTo: extractStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Compression
|
import Compression
|
||||||
|
import Sentry
|
||||||
|
|
||||||
enum OCIError: Error {
|
enum OCIError: Error {
|
||||||
case ShouldBeExactlyOneLayer
|
case ShouldBeExactlyOneLayer
|
||||||
@@ -35,8 +36,8 @@ extension VMDirectory {
|
|||||||
throw OCIError.FailedToCreateVmFile
|
throw OCIError.FailedToCreateVmFile
|
||||||
}
|
}
|
||||||
let configFile = try FileHandle(forWritingTo: configURL)
|
let configFile = try FileHandle(forWritingTo: configURL)
|
||||||
try await registry.pullBlob(configLayers.first!.digest) { buffer in
|
try await registry.pullBlob(configLayers.first!.digest) { data in
|
||||||
configFile.write(Data(buffer: buffer))
|
configFile.write(data)
|
||||||
}
|
}
|
||||||
try configFile.close()
|
try configFile.close()
|
||||||
|
|
||||||
@@ -59,25 +60,25 @@ extension VMDirectory {
|
|||||||
|
|
||||||
// Progress
|
// Progress
|
||||||
let diskCompressedSize: Int64 = Int64(diskLayers.map {
|
let diskCompressedSize: Int64 = Int64(diskLayers.map {
|
||||||
$0.size
|
$0.size
|
||||||
}
|
}
|
||||||
.reduce(0) {
|
.reduce(0) {
|
||||||
$0 + $1
|
$0 + $1
|
||||||
})
|
})
|
||||||
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
||||||
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
||||||
let progress = Progress(totalUnitCount: diskCompressedSize)
|
let progress = Progress(totalUnitCount: diskCompressedSize)
|
||||||
ProgressObserver(progress).log(defaultLogger)
|
ProgressObserver(progress).log(defaultLogger)
|
||||||
|
|
||||||
for diskLayer in diskLayers {
|
for diskLayer in diskLayers {
|
||||||
try await registry.pullBlob(diskLayer.digest) { buffer in
|
try await registry.pullBlob(diskLayer.digest) { data in
|
||||||
let data = Data(buffer: buffer)
|
|
||||||
try filter.write(data)
|
try filter.write(data)
|
||||||
progress.completedUnitCount += Int64(data.count)
|
progress.completedUnitCount += Int64(data.count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try filter.finalize()
|
try filter.finalize()
|
||||||
try disk.close()
|
try disk.close()
|
||||||
|
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||||
|
|
||||||
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
||||||
defaultLogger.appendNewLine("pulling NVRAM...")
|
defaultLogger.appendNewLine("pulling NVRAM...")
|
||||||
@@ -92,8 +93,8 @@ extension VMDirectory {
|
|||||||
throw OCIError.FailedToCreateVmFile
|
throw OCIError.FailedToCreateVmFile
|
||||||
}
|
}
|
||||||
let nvram = try FileHandle(forWritingTo: nvramURL)
|
let nvram = try FileHandle(forWritingTo: nvramURL)
|
||||||
try await registry.pullBlob(nvramLayers.first!.digest) { buffer in
|
try await registry.pullBlob(nvramLayers.first!.digest) { data in
|
||||||
nvram.write(Data(buffer: buffer))
|
nvram.write(data)
|
||||||
}
|
}
|
||||||
try nvram.close()
|
try nvram.close()
|
||||||
}
|
}
|
||||||
@@ -145,9 +146,9 @@ extension VMDirectory {
|
|||||||
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
||||||
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
|
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
|
||||||
let manifest = OCIManifest(
|
let manifest = OCIManifest(
|
||||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||||
layers: layers,
|
layers: layers,
|
||||||
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
|
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manifest
|
// Manifest
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
struct UninitializedVMDirectoryError: Error {
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AlreadyInitializedVMDirectoryError: Error {
|
|
||||||
}
|
|
||||||
|
|
||||||
struct VMDirectory: Prunable {
|
struct VMDirectory: Prunable {
|
||||||
var baseURL: URL
|
var baseURL: URL
|
||||||
|
|
||||||
@@ -28,8 +22,12 @@ struct VMDirectory: Prunable {
|
|||||||
baseURL.lastPathComponent
|
baseURL.lastPathComponent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var url: URL {
|
||||||
|
baseURL
|
||||||
|
}
|
||||||
|
|
||||||
static func temporary() throws -> VMDirectory {
|
static func temporary() throws -> VMDirectory {
|
||||||
let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString)
|
||||||
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
|
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
|
||||||
|
|
||||||
return VMDirectory(baseURL: tmpDir)
|
return VMDirectory(baseURL: tmpDir)
|
||||||
@@ -43,7 +41,7 @@ struct VMDirectory: Prunable {
|
|||||||
|
|
||||||
func initialize(overwrite: Bool = false) throws {
|
func initialize(overwrite: Bool = false) throws {
|
||||||
if !overwrite && initialized {
|
if !overwrite && initialized {
|
||||||
throw AlreadyInitializedVMDirectoryError()
|
throw RuntimeError.VMDirectoryAlreadyInitialized("VM directory is already initialized, preventing overwrite")
|
||||||
}
|
}
|
||||||
|
|
||||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
|
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
|
||||||
@@ -54,8 +52,13 @@ struct VMDirectory: Prunable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
|
if !FileManager.default.fileExists(atPath: baseURL.path) {
|
||||||
|
throw RuntimeError.VMDoesNotExist(name: baseURL.lastPathComponent)
|
||||||
|
}
|
||||||
|
|
||||||
if !initialized {
|
if !initialized {
|
||||||
throw UninitializedVMDirectoryError()
|
throw RuntimeError.VMMissingFiles("VM is missing some of its files (\(configURL.lastPathComponent),"
|
||||||
|
+ " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,11 +68,21 @@ struct VMDirectory: Prunable {
|
|||||||
try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
|
try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
|
||||||
|
|
||||||
// Re-generate MAC address
|
// Re-generate MAC address
|
||||||
var newVMConfig = try VMConfig(fromURL: to.configURL)
|
|
||||||
if generateMAC {
|
if generateMAC {
|
||||||
newVMConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
try to.regenerateMACAddress()
|
||||||
}
|
}
|
||||||
try newVMConfig.save(toURL: to.configURL)
|
}
|
||||||
|
|
||||||
|
func macAddress() throws -> String {
|
||||||
|
try VMConfig(fromURL: configURL).macAddress.string
|
||||||
|
}
|
||||||
|
|
||||||
|
func regenerateMACAddress() throws {
|
||||||
|
var vmConfig = try VMConfig(fromURL: configURL)
|
||||||
|
|
||||||
|
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||||
|
|
||||||
|
try vmConfig.save(toURL: configURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resizeDisk(_ sizeGB: UInt16) throws {
|
func resizeDisk(_ sizeGB: UInt16) throws {
|
||||||
@@ -94,6 +107,10 @@ struct VMDirectory: Prunable {
|
|||||||
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sizeGB() throws -> Int {
|
||||||
|
try sizeBytes() / 1000 / 1000 / 1000
|
||||||
|
}
|
||||||
|
|
||||||
func markExplicitlyPulled() {
|
func markExplicitlyPulled() {
|
||||||
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class VMStorageHelper {
|
|||||||
return try closure()
|
return try closure()
|
||||||
} catch {
|
} catch {
|
||||||
if error.isFileNotFound() {
|
if error.isFileNotFound() {
|
||||||
throw RuntimeError("source VM \"\(name)\" not found, is it listed in \"tart list\"?")
|
throw RuntimeError.VMDoesNotExist(name: name)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
@@ -40,14 +40,82 @@ extension Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RuntimeError: Error, CustomStringConvertible {
|
enum RuntimeError : Error {
|
||||||
let message: String
|
case VMDoesNotExist(name: String)
|
||||||
|
case VMMissingFiles(_ message: String)
|
||||||
|
case VMNotRunning(_ message: String)
|
||||||
|
case VMAlreadyRunning(_ message: String)
|
||||||
|
case NoIPAddressFound(_ message: String)
|
||||||
|
case DiskAlreadyInUse(_ message: String)
|
||||||
|
case FailedToUpdateAccessDate(_ message: String)
|
||||||
|
case PIDLockFailed(_ message: String)
|
||||||
|
case FailedToParseRemoteName(_ message: String)
|
||||||
|
case VMTerminationFailed(_ message: String)
|
||||||
|
case InvalidCredentials(_ message: String)
|
||||||
|
case VMDirectoryAlreadyInitialized(_ message: String)
|
||||||
|
case ExportFailed(_ message: String)
|
||||||
|
case ImportFailed(_ message: String)
|
||||||
|
}
|
||||||
|
|
||||||
init(_ message: String) {
|
protocol HasExitCode {
|
||||||
self.message = message
|
var exitCode: Int32 { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
extension RuntimeError : CustomStringConvertible {
|
||||||
message
|
public var description: String {
|
||||||
|
switch self {
|
||||||
|
case .VMDoesNotExist(let name):
|
||||||
|
return "the specified VM \"\(name)\" does not exist"
|
||||||
|
case .VMMissingFiles(let message):
|
||||||
|
return message
|
||||||
|
case .VMNotRunning(let message):
|
||||||
|
return message
|
||||||
|
case .VMAlreadyRunning(let message):
|
||||||
|
return message
|
||||||
|
case .NoIPAddressFound(let message):
|
||||||
|
return message
|
||||||
|
case .DiskAlreadyInUse(let message):
|
||||||
|
return message
|
||||||
|
case .FailedToUpdateAccessDate(let message):
|
||||||
|
return message
|
||||||
|
case .PIDLockFailed(let message):
|
||||||
|
return message
|
||||||
|
case .FailedToParseRemoteName(let cause):
|
||||||
|
return "failed to parse remote name: \(cause)"
|
||||||
|
case .VMTerminationFailed(let message):
|
||||||
|
return message
|
||||||
|
case .InvalidCredentials(let message):
|
||||||
|
return message
|
||||||
|
case .VMDirectoryAlreadyInitialized(let message):
|
||||||
|
return message
|
||||||
|
case .ExportFailed(let message):
|
||||||
|
return "VM export failed: \(message)"
|
||||||
|
case .ImportFailed(let message):
|
||||||
|
return "VM import failed: \(message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RuntimeError : HasExitCode {
|
||||||
|
var exitCode: Int32 {
|
||||||
|
switch self {
|
||||||
|
case .VMNotRunning:
|
||||||
|
return 2
|
||||||
|
case .VMAlreadyRunning:
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customize error description for Sentry[1]
|
||||||
|
//
|
||||||
|
// [1]: https://docs.sentry.io/platforms/apple/guides/ios/usage/#customizing-error-descriptions
|
||||||
|
extension RuntimeError : CustomNSError {
|
||||||
|
var errorUserInfo: [String : Any] {
|
||||||
|
[
|
||||||
|
NSDebugDescriptionErrorKey: description,
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ class VMStorageLocal {
|
|||||||
_ = try FileManager.default.replaceItemAt(vmURL(name), withItemAt: from.baseURL)
|
_ = try FileManager.default.replaceItemAt(vmURL(name), withItemAt: from.baseURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rename(_ name: String, _ newName: String) throws {
|
||||||
|
_ = try FileManager.default.replaceItemAt(vmURL(newName), withItemAt: vmURL(name))
|
||||||
|
}
|
||||||
|
|
||||||
func delete(_ name: String) throws {
|
func delete(_ name: String) throws {
|
||||||
try FileManager.default.removeItem(at: vmURL(name))
|
try FileManager.default.removeItem(at: vmURL(name))
|
||||||
}
|
}
|
||||||
@@ -58,4 +62,8 @@ class VMStorageLocal {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
||||||
|
try list().contains { try $1.macAddress() == macAddress }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Sentry
|
||||||
|
|
||||||
class VMStorageOCI: PrunableStorage {
|
class VMStorageOCI: PrunableStorage {
|
||||||
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||||
@@ -39,7 +40,7 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
// Pre-create intermediate directories (e.g. creates ~/.tart/cache/OCIs/github.com/org/repo/
|
// Pre-create intermediate directories (e.g. creates ~/.tart/cache/OCIs/github.com/org/repo/
|
||||||
// for github.com/org/repo:latest)
|
// for github.com/org/repo:latest)
|
||||||
try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(),
|
try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
|
|
||||||
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
|
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,7 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
var refCounts = Dictionary<URL, UInt>()
|
var refCounts = Dictionary<URL, UInt>()
|
||||||
|
|
||||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||||
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
|
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
var result: [(String, VMDirectory, Bool)] = Array()
|
var result: [(String, VMDirectory, Bool)] = Array()
|
||||||
|
|
||||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||||
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
|
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
||||||
|
|
||||||
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||||
reference: Reference(digest: Digest.hash(manifestData)))
|
reference: Reference(digest: Digest.hash(manifestData)))
|
||||||
|
|
||||||
// Ensure that host directory for given RemoteName exists in OCI storage
|
// Ensure that host directory for given RemoteName exists in OCI storage
|
||||||
let hostDirectoryURL = hostDirectoryURL(digestName)
|
let hostDirectoryURL = hostDirectoryURL(digestName)
|
||||||
@@ -148,16 +149,43 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !exists(digestName) {
|
if !exists(digestName) {
|
||||||
|
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
||||||
let tmpVMDir = try VMDirectory.temporary()
|
let tmpVMDir = try VMDirectory.temporary()
|
||||||
|
|
||||||
|
// Lock the temporary VM directory to prevent it's garbage collection
|
||||||
|
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||||
|
try tmpVMDirLock.lock()
|
||||||
|
|
||||||
// Try to reclaim some cache space if we know the VM size in advance
|
// Try to reclaim some cache space if we know the VM size in advance
|
||||||
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
||||||
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
|
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
|
||||||
|
|
||||||
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
|
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey])
|
||||||
let availableCapacityBytes = UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
|
let capacityImportant = attrs.volumeAvailableCapacityForImportantUsage!
|
||||||
|
let capacityAvailable = attrs.volumeAvailableCapacity!
|
||||||
|
let availableCapacityBytes = max(UInt64(capacityImportant), UInt64(capacityAvailable))
|
||||||
|
|
||||||
|
if capacityImportant == 0 || capacityAvailable == 0 {
|
||||||
|
SentrySDK.capture(message: "Zero capacity") { scope in
|
||||||
|
scope.setLevel(.warning)
|
||||||
|
|
||||||
|
scope.setContext(value: [
|
||||||
|
"volumeAvailableCapacityForImportantUsageKey": capacityImportant,
|
||||||
|
"volumeAvailableCapacityKey": capacityAvailable,
|
||||||
|
], key: "Attributes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// There is a suspicious that occasionally capacity is returned as zero which can't be true.
|
||||||
|
// Let's validate to avoid unnecessary pruning.
|
||||||
|
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
|
||||||
|
let transaction = SentrySDK.startTransaction(name: "Automatically Pruning Cache", operation: "prune", bindToScope: true)
|
||||||
|
transaction.setData(value: name, key: "name")
|
||||||
|
transaction.setData(value: uncompressedDiskSize, key: "uncompressedDiskSize")
|
||||||
|
transaction.setData(value: availableCapacityBytes, key: "availableCapacity")
|
||||||
|
transaction.setData(value: requiredCapacityBytes, key: "requiredCapacity")
|
||||||
|
defer { transaction.finish() }
|
||||||
|
|
||||||
if availableCapacityBytes < requiredCapacityBytes {
|
|
||||||
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,7 +193,9 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
try await withTaskCancellationHandler(operation: {
|
try await withTaskCancellationHandler(operation: {
|
||||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
||||||
try move(digestName, from: tmpVMDir)
|
try move(digestName, from: tmpVMDir)
|
||||||
|
transaction.finish()
|
||||||
}, onCancel: {
|
}, onCancel: {
|
||||||
|
transaction.finish(status: SentrySpanStatus.cancelled)
|
||||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -174,7 +204,7 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
|
|
||||||
if name != digestName {
|
if name != digestName {
|
||||||
// Create new or overwrite the old symbolic link
|
// Create new or overwrite the old symbolic link
|
||||||
try link(from: digestName, to: name)
|
try link(from: name, to: digestName)
|
||||||
} else {
|
} else {
|
||||||
// Ensure that images pulled by content digest
|
// Ensure that images pulled by content digest
|
||||||
// are excluded from garbage collection
|
// are excluded from garbage collection
|
||||||
@@ -183,11 +213,11 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func link(from: RemoteName, to: RemoteName) throws {
|
func link(from: RemoteName, to: RemoteName) throws {
|
||||||
if FileManager.default.fileExists(atPath: vmURL(to).path) {
|
if FileManager.default.fileExists(atPath: vmURL(from).path) {
|
||||||
try FileManager.default.removeItem(at: vmURL(to))
|
try FileManager.default.removeItem(at: vmURL(from))
|
||||||
}
|
}
|
||||||
|
|
||||||
try FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
|
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
||||||
|
|
||||||
try gc()
|
try gc()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,36 +3,36 @@ import Dynamic
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
class FullFledgedVNC: VNC {
|
class FullFledgedVNC: VNC {
|
||||||
let password: String
|
let password: String
|
||||||
private let vnc: Dynamic
|
private let vnc: Dynamic
|
||||||
|
|
||||||
init(virtualMachine: VZVirtualMachine) {
|
init(virtualMachine: VZVirtualMachine) {
|
||||||
password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
|
password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
|
||||||
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
|
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
|
||||||
vnc = Dynamic._VZVNCServer(port: 0, queue: DispatchQueue.global(),
|
vnc = Dynamic._VZVNCServer(port: 0, queue: DispatchQueue.global(),
|
||||||
securityConfiguration: securityConfiguration)
|
securityConfiguration: securityConfiguration)
|
||||||
vnc.virtualMachine = virtualMachine
|
vnc.virtualMachine = virtualMachine
|
||||||
vnc.start()
|
vnc.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForURL() async throws -> URL {
|
func waitForURL() async throws -> URL {
|
||||||
while true {
|
while true {
|
||||||
// Port is 0 shortly after start(),
|
// Port is 0 shortly after start(),
|
||||||
// but will be initialized later
|
// but will be initialized later
|
||||||
if let port = vnc.port.asUInt16, port != 0 {
|
if let port = vnc.port.asUInt16, port != 0 {
|
||||||
return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
|
return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
|
||||||
}
|
|
||||||
|
|
||||||
// Wait 50 ms.
|
|
||||||
try await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func stop() throws {
|
// Wait 50 ms.
|
||||||
vnc.stop()
|
try await Task.sleep(nanoseconds: 50_000_000)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
func stop() throws {
|
||||||
try? stop()
|
vnc.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
try? stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,33 +2,33 @@ import XCTest
|
|||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
final class FileLockTests: XCTestCase {
|
final class FileLockTests: XCTestCase {
|
||||||
func testSimple() throws {
|
func testSimple() throws {
|
||||||
// Create a temporary file that will be used as a lock
|
// Create a temporary file that will be used as a lock
|
||||||
let url = temporaryFile()
|
let url = temporaryFile()
|
||||||
|
|
||||||
// Make sure this file can be locked and unlocked
|
// Make sure this file can be locked and unlocked
|
||||||
let lock = try FileLock(lockURL: url)
|
let lock = try FileLock(lockURL: url)
|
||||||
try lock.lock()
|
try lock.lock()
|
||||||
try lock.unlock()
|
try lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDoubleLockResultsInError() throws {
|
func testDoubleLockResultsInError() throws {
|
||||||
// Create a temporary file that will be used as a lock
|
// Create a temporary file that will be used as a lock
|
||||||
let url = temporaryFile()
|
let url = temporaryFile()
|
||||||
|
|
||||||
// Create two locks on a same file and ensure one of them fails
|
// Create two locks on a same file and ensure one of them fails
|
||||||
let firstLock = try FileLock(lockURL: url)
|
let firstLock = try FileLock(lockURL: url)
|
||||||
try firstLock.lock()
|
try firstLock.lock()
|
||||||
|
|
||||||
let secondLock = try! FileLock(lockURL: url)
|
let secondLock = try! FileLock(lockURL: url)
|
||||||
XCTAssertFalse(try secondLock.trylock())
|
XCTAssertFalse(try secondLock.trylock())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func temporaryFile() -> URL {
|
private func temporaryFile() -> URL {
|
||||||
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
|
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
|
||||||
|
|
||||||
FileManager.default.createFile(atPath: url.path, contents: nil)
|
FileManager.default.createFile(atPath: url.path, contents: nil)
|
||||||
|
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,31 +5,31 @@ import Network
|
|||||||
final class MACAddressResolverTests: XCTestCase {
|
final class MACAddressResolverTests: XCTestCase {
|
||||||
func testSingleEntry() throws {
|
func testSingleEntry() throws {
|
||||||
let leases = try Leases("""
|
let leases = try Leases("""
|
||||||
{
|
{
|
||||||
ip_address=1.2.3.4
|
ip_address=1.2.3.4
|
||||||
hw_address=1,00:11:22:33:44:55
|
hw_address=1,00:11:22:33:44:55
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMultipleEntries() throws {
|
func testMultipleEntries() throws {
|
||||||
let leases = try Leases("""
|
let leases = try Leases("""
|
||||||
{
|
{
|
||||||
ip_address=1.2.3.4
|
ip_address=1.2.3.4
|
||||||
hw_address=1,00:11:22:33:44:55
|
hw_address=1,00:11:22:33:44:55
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
ip_address=5.6.7.8
|
ip_address=5.6.7.8
|
||||||
hw_address=1,AA:BB:CC:DD:EE:FF
|
hw_address=1,AA:BB:CC:DD:EE:FF
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||||
XCTAssertEqual(IPv4Address("5.6.7.8"),
|
XCTAssertEqual(IPv4Address("5.6.7.8"),
|
||||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,88 +2,88 @@ import XCTest
|
|||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
final class RegistryTests: XCTestCase {
|
final class RegistryTests: XCTestCase {
|
||||||
var registryRunner: RegistryRunner?
|
var registryRunner: RegistryRunner?
|
||||||
|
|
||||||
override func setUp() async throws {
|
override func setUp() async throws {
|
||||||
try await super.setUp()
|
try await super.setUp()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
registryRunner = try await RegistryRunner()
|
registryRunner = try await RegistryRunner()
|
||||||
} catch {
|
} catch {
|
||||||
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
try await super.tearDown()
|
||||||
|
|
||||||
|
registryRunner = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var registry: Registry {
|
||||||
|
registryRunner!.registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPushPullBlobSmall() async throws {
|
||||||
|
// Generate a simple blob
|
||||||
|
let pushedBlob = Data("The quick brown fox jumps over the lazy dog".utf8)
|
||||||
|
|
||||||
|
// Push it
|
||||||
|
let pushedBlobDigest = try await registry.pushBlob(fromData: pushedBlob)
|
||||||
|
XCTAssertEqual("sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", pushedBlobDigest)
|
||||||
|
|
||||||
|
// Pull it
|
||||||
|
var pulledBlob = Data()
|
||||||
|
try await registry.pullBlob(pushedBlobDigest) { data in
|
||||||
|
pulledBlob.append(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() async throws {
|
// Ensure that both blobs are identical
|
||||||
try await super.tearDown()
|
XCTAssertEqual(pushedBlob, pulledBlob)
|
||||||
|
}
|
||||||
|
|
||||||
registryRunner = nil
|
func testPushPullBlobHugeInChunks() async throws {
|
||||||
|
// Generate a large enough blob
|
||||||
|
let fh = FileHandle(forReadingAtPath: "/dev/urandom")!
|
||||||
|
let largeBlobToPush = try fh.read(upToCount: 768 * 1024 * 1024)!
|
||||||
|
|
||||||
|
// Push it
|
||||||
|
let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush, chunkSizeMb: 10)
|
||||||
|
|
||||||
|
// Pull it
|
||||||
|
var pulledLargeBlob = Data()
|
||||||
|
try await registry.pullBlob(largeBlobDigest) { data in
|
||||||
|
pulledLargeBlob.append(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
var registry: Registry {
|
// Ensure that both blobs are identical
|
||||||
registryRunner!.registry
|
XCTAssertEqual(largeBlobToPush, pulledLargeBlob)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPushPullBlobSmall() async throws {
|
func testPushPullManifest() async throws {
|
||||||
// Generate a simple blob
|
// Craft a basic config
|
||||||
let pushedBlob = Data("The quick brown fox jumps over the lazy dog".utf8)
|
let configData = try OCIConfig().toJSON()
|
||||||
|
let configDigest = try await registry.pushBlob(fromData: configData)
|
||||||
|
|
||||||
// Push it
|
// Craft a basic layer
|
||||||
let pushedBlobDigest = try await registry.pushBlob(fromData: pushedBlob)
|
let layerData = Data("doesn't matter".utf8)
|
||||||
XCTAssertEqual("sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", pushedBlobDigest)
|
let layerDigest = try await registry.pushBlob(fromData: layerData)
|
||||||
|
|
||||||
// Pull it
|
// Craft a basic manifest and push it
|
||||||
var pulledBlob = Data()
|
let manifest = OCIManifest(
|
||||||
try await registry.pullBlob(pushedBlobDigest) { buffer in
|
config: OCIManifestConfig(size: configData.count, digest: configDigest),
|
||||||
pulledBlob.append(Data(buffer: buffer))
|
layers: [
|
||||||
}
|
OCIManifestLayer(mediaType: "application/octet-stream", size: layerData.count, digest: layerDigest)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
let pushedManifestDigest = try await registry.pushManifest(reference: "latest", manifest: manifest)
|
||||||
|
|
||||||
// Ensure that both blobs are identical
|
// Ensure that the manifest pulled by tag matches with the one pushed above
|
||||||
XCTAssertEqual(pushedBlob, pulledBlob)
|
let (pulledByTagManifest, _) = try await registry.pullManifest(reference: "latest")
|
||||||
}
|
XCTAssertEqual(manifest, pulledByTagManifest)
|
||||||
|
|
||||||
func testPushPullBlobHugeInChunks() async throws {
|
// Ensure that the manifest pulled by digest matches with the one pushed above
|
||||||
// Generate a large enough blob
|
let (pulledByDigestManifest, _) = try await registry.pullManifest(reference: "\(pushedManifestDigest)")
|
||||||
let fh = FileHandle(forReadingAtPath: "/dev/urandom")!
|
XCTAssertEqual(manifest, pulledByDigestManifest)
|
||||||
let largeBlobToPush = try fh.read(upToCount: 768 * 1024 * 1024)!
|
}
|
||||||
|
|
||||||
// Push it
|
|
||||||
let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush, chunkSizeMb: 10)
|
|
||||||
|
|
||||||
// Pull it
|
|
||||||
var pulledLargeBlob = Data()
|
|
||||||
try await registry.pullBlob(largeBlobDigest) { buffer in
|
|
||||||
pulledLargeBlob.append(Data(buffer: buffer))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure that both blobs are identical
|
|
||||||
XCTAssertEqual(largeBlobToPush, pulledLargeBlob)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPushPullManifest() async throws {
|
|
||||||
// Craft a basic config
|
|
||||||
let configData = try OCIConfig().toJSON()
|
|
||||||
let configDigest = try await registry.pushBlob(fromData: configData)
|
|
||||||
|
|
||||||
// Craft a basic layer
|
|
||||||
let layerData = Data("doesn't matter".utf8)
|
|
||||||
let layerDigest = try await registry.pushBlob(fromData: layerData)
|
|
||||||
|
|
||||||
// Craft a basic manifest and push it
|
|
||||||
let manifest = OCIManifest(
|
|
||||||
config: OCIManifestConfig(size: configData.count, digest: configDigest),
|
|
||||||
layers: [
|
|
||||||
OCIManifestLayer(mediaType: "application/octet-stream", size: layerData.count, digest: layerDigest)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
let pushedManifestDigest = try await registry.pushManifest(reference: "latest", manifest: manifest)
|
|
||||||
|
|
||||||
// Ensure that the manifest pulled by tag matches with the one pushed above
|
|
||||||
let (pulledByTagManifest, _) = try await registry.pullManifest(reference: "latest")
|
|
||||||
XCTAssertEqual(manifest, pulledByTagManifest)
|
|
||||||
|
|
||||||
// Ensure that the manifest pulled by digest matches with the one pushed above
|
|
||||||
let (pulledByDigestManifest, _) = try await registry.pullManifest(reference: "\(pushedManifestDigest)")
|
|
||||||
XCTAssertEqual(manifest, pulledByDigestManifest)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ final class RemoteNameTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest"))
|
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testComplexTag() throws {
|
func testComplexTag() throws {
|
||||||
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: Reference(tag: "1.2.3-RC-1"))
|
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: Reference(tag: "1.2.3-RC-1"))
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ final class RemoteNameTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(expectedRemoteName,
|
XCTAssertEqual(expectedRemoteName,
|
||||||
try RemoteName("ghcr.io/a/b@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
|
try RemoteName("ghcr.io/a/b@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testASCIIOnly() throws {
|
func testASCIIOnly() throws {
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import XCTest
|
|||||||
final class URLAbsolutizationTets: XCTestCase {
|
final class URLAbsolutizationTets: XCTestCase {
|
||||||
func testNeedsAbsolutization() throws {
|
func testNeedsAbsolutization() throws {
|
||||||
let url = URL(string: "/v2/some/path?some=query")!
|
let url = URL(string: "/v2/some/path?some=query")!
|
||||||
.absolutize(URL(string: "https://example.com/v2/")!)
|
.absolutize(URL(string: "https://example.com/v2/")!)
|
||||||
|
|
||||||
XCTAssertEqual(url.absoluteString, "https://example.com/v2/some/path?some=query")
|
XCTAssertEqual(url.absoluteString, "https://example.com/v2/some/path?some=query")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDoesntNeedAbsolutization() throws {
|
func testDoesntNeedAbsolutization() throws {
|
||||||
let url = URL(string: "https://example.org/v2/some/path?some=query")!
|
let url = URL(string: "https://example.org/v2/some/path?some=query")!
|
||||||
.absolutize(URL(string: "https://example.com/v2/")!)
|
.absolutize(URL(string: "https://example.com/v2/")!)
|
||||||
|
|
||||||
XCTAssertEqual(url.absoluteString, "https://example.org/v2/some/path?some=query")
|
XCTAssertEqual(url.absoluteString, "https://example.org/v2/some/path?some=query")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,53 +2,53 @@ import Foundation
|
|||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
enum RegistryRunnerError: Error {
|
enum RegistryRunnerError: Error {
|
||||||
case DockerFailed(exitCode: Int32)
|
case DockerFailed(exitCode: Int32)
|
||||||
}
|
}
|
||||||
|
|
||||||
class RegistryRunner {
|
class RegistryRunner {
|
||||||
let containerID: String
|
let containerID: String
|
||||||
let registry: Registry
|
let registry: Registry
|
||||||
|
|
||||||
static func dockerCmd(_ arguments: String...) throws -> String {
|
static func dockerCmd(_ arguments: String...) throws -> String {
|
||||||
let stdoutPipe = Pipe()
|
let stdoutPipe = Pipe()
|
||||||
|
|
||||||
let proc = Process()
|
let proc = Process()
|
||||||
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
|
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
|
||||||
proc.arguments = arguments
|
proc.arguments = arguments
|
||||||
proc.standardOutput = stdoutPipe
|
proc.standardOutput = stdoutPipe
|
||||||
try proc.run()
|
try proc.run()
|
||||||
|
|
||||||
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
|
||||||
proc.waitUntilExit()
|
proc.waitUntilExit()
|
||||||
|
|
||||||
if proc.terminationStatus != 0 {
|
if proc.terminationStatus != 0 {
|
||||||
throw RegistryRunnerError.DockerFailed(exitCode: proc.terminationStatus)
|
throw RegistryRunnerError.DockerFailed(exitCode: proc.terminationStatus)
|
||||||
}
|
|
||||||
|
|
||||||
return String(data: stdoutData, encoding: .utf8) ?? ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init() async throws {
|
return String(data: stdoutData, encoding: .utf8) ?? ""
|
||||||
// Start container
|
}
|
||||||
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "5000", "registry:2")
|
|
||||||
.trimmingCharacters(in: CharacterSet.newlines)
|
|
||||||
containerID = container
|
|
||||||
|
|
||||||
// Get forwarded port
|
init() async throws {
|
||||||
let port = try Self.dockerCmd("inspect", containerID, "--format", "{{(index (index .NetworkSettings.Ports \"5000/tcp\") 0).HostPort}}")
|
// Start container
|
||||||
.trimmingCharacters(in: CharacterSet.newlines)
|
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "5000", "registry:2")
|
||||||
|
.trimmingCharacters(in: CharacterSet.newlines)
|
||||||
|
containerID = container
|
||||||
|
|
||||||
registry = try Registry(urlComponents: URLComponents(string: "http://127.0.0.1:\(port)/v2/")!,
|
// Get forwarded port
|
||||||
namespace: "vm-image")
|
let port = try Self.dockerCmd("inspect", containerID, "--format", "{{(index (index .NetworkSettings.Ports \"5000/tcp\") 0).HostPort}}")
|
||||||
|
.trimmingCharacters(in: CharacterSet.newlines)
|
||||||
|
|
||||||
// Wait for the Docker Registry to start
|
registry = try Registry(urlComponents: URLComponents(string: "http://127.0.0.1:\(port)/v2/")!,
|
||||||
while ((try? await registry.ping()) == nil) {
|
namespace: "vm-image")
|
||||||
try await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
}
|
// Wait for the Docker Registry to start
|
||||||
|
while ((try? await registry.ping()) == nil) {
|
||||||
|
try await Task.sleep(nanoseconds: 100_000_000)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
_ = try! Self.dockerCmd("kill", containerID)
|
_ = try! Self.dockerCmd("kill", containerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
tart.run
|
||||||
|
www.tart.run
|
||||||