Compare commits

...
56 Commits
Author SHA1 Message Date
Nikolay Edigaryev 1380ece108 Use separate exception codes for better Sentry grouping (#363) 2022-12-19 17:40:55 +00:00
Fedor Korotkov 9cdb7087f2 Fixed --help (#361)
* Fixed --help

Apparently `--help` works via exceptions 🤷‍♂️

Fixes #360

* lint issue
2022-12-17 20:57:17 +04:00
Nikolay Edigaryev c3e37854c3 .cirrus.yml: install Sentry CLI (#356) 2022-12-15 22:28:21 +00:00
Fedor Korotkov 828351a8fb Report pull metrics to Sentry (#354)
* Report GC events to Sentry

* Formatting

* Use transactions for measuring pull duration on cache miss

* Fixed linting

* Explicitly enable capturing of failed requests

* Allow customizing tracesSampleRate

* bindToScope

* Measure compressed disk size

* Don't capture GC events
2022-12-15 16:53:34 -05:00
Nikolay Edigaryev f9ac994e18 Fix inverted VMStorageOCI.link() logic (#355) 2022-12-15 21:18:33 +00:00
marc-48k fdeaf979c2 Get command (#353)
* (wip) First pass at Get command.

* Adds validation in case multiple options are supplied.
2022-12-15 22:52:22 +04:00
Nikolay Edigaryev d9f1c37cdd Sentry integration (#352)
* Ditch Foundation.exit()'s where feasible

* Sentry integration

* SwiftFormat

* Upload symbols and sources to Sentry

* Use Sentry Releases

* Do not use ExitCode exceptions

* Clarify why we need CustomNSError extension
2022-12-15 13:50:21 +00:00
Fedor Korotkov 81253417a4 Optimal buffer sizes for Softnet socket (#351)
* Optimal buffer sizes for Softnet socket

* Formatting
2022-12-14 23:18:39 +04:00
marc-48kandNikolay Edigaryev c9caeab098 Filter tart list by source (#349)
* Adds Option to filter VMs by 'source'

* Tidy up columns for table on some Terminals

* Don't show headers if there's an error.

* Fixes linting errors.

* Update Sources/tart/Commands/List.swift

- removing the short name for now, as we don't know if we may need -s in the future
- removing the capitalization for the word "source" and adding an example instead

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>

* source should be an Optional flag. Reverts header change.

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2022-12-14 13:08:35 -05:00
marc-48kandFedor Korotkov 6d7dc40c57 Doco fixups (#350)
* Fix-ups and SSH howto

* Minor corrections, additions and normalizations

* Fixes SSH command.

* Update README.md

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2022-12-14 21:49:37 +04:00
Nikolay Edigaryev 15b26f78ae Registry: include port when looking up credentials (#346) 2022-12-05 09:56:48 -05:00
Nikolay Edigaryev d9f4f9e954 Switch to github.com/nicklockwood/SwiftFormat version 0.50.6 (#345) 2022-12-05 11:11:57 +04:00
Nikolay Edigaryev 31635e59d7 Don't swallow VZVirtualMachine.start() exceptions (#343)
* Don't swallow VZVirtualMachine.start() exceptions

* Rename task to startTask for clarity

* No need to use "self"
2022-12-01 14:03:21 -05:00
Nikolay EdigaryevandFedor Korotkov e061d00afc Fix SwiftFormat's URL/glob mishandling (#342)
* Fix SwiftFormat's URL/glob mishandling

* Revert "Fix SwiftFormat's URL/glob mishandling"

This reverts commit 4d1a4c7fb3.

* Use a fixed SwiftFormat

* Another SwiftFormat fix

* Update .cirrus.yml

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2022-11-30 17:53:25 +00:00
Nikolay Edigaryev 4555dd5824 README.md: document --dir (#338) 2022-11-30 18:12:06 +04:00
Nikolay Edigaryev ad9c3c661e Reformat code idents and introduce the SwiftFormat linter (#339)
* Package.swift: add SwiftFormat

Can be invoked with "swift package plugin swiftformat".

* $ swift package plugin swiftformat

* .cirrus.yml: run SwiftFormat

* SwiftFormat: exclude Sources/tart/OCI/Reference/Generated
2022-11-29 15:56:13 +00:00
Nikolay Edigaryev c27d4a089c OCI: WWW-Authenticate's scheme should be treated case-insensitive (#336) 2022-11-25 16:59:38 +04:00
Nikolay Edigaryev 70f9fcc12e tart run: do not check --disk lock for read-only attachments (#334) 2022-11-22 22:37:48 -05:00
Nikolay Edigaryev 456ebc1c7b Rosetta support (#324) 2022-11-21 12:56:27 -05:00
Nikolay Edigaryev 14cbc727ad tart run: check if the disks are locked (#333) 2022-11-21 17:42:53 +04:00
Fedor Korotkov 03e6d9345d Document list of users (#332)
Fixes #208
2022-11-21 12:34:24 +04:00
Nikolay Edigaryev c09cbefdd0 VZVirtualMachineDelegate implementation: show the error details (#330) 2022-11-18 18:51:26 +04:00
Fedor Korotkov 3896728eb9 Added Code Owners (#328)
* Added Code Owners

* Rename gi to CODEOWNERS
2022-11-17 22:57:12 -05:00
sheldonneuberger-sc de993fbf6d use 64bit int for memory (#327) 2022-11-16 14:52:28 -05:00
Nikolay Edigaryev f08ddf3855 Fetch IPSWs via file (#322) 2022-11-15 10:01:52 +04:00
Nikolay Edigaryev 8524d93741 Integration tests (#313)
* Integration tests

* Set "HOMEBREW_NO_AUTO_UPDATE=1" for virtualenv installation step

* Use CIRRUS_WORKING_DIR as temporary directory if present
2022-11-14 13:24:51 -05:00
Nikolay Edigaryev 833c162187 tart stop: return a different exit code when VM is not running (#321)
See https://github.com/cirruslabs/tart/pull/316#issuecomment-1311556674.
2022-11-11 14:20:21 -05:00
Fedor Korotkov 31ba71dad7 Option to provide registry credentials via environment variables (#320)
Fixes #124
2022-11-11 18:32:23 +04:00
Nikolay Edigaryev 5e77968989 Introduce "tart stop" (#316) 2022-11-11 07:59:22 +04:00
Evan Burkey f37372da28 Implement Get command (#309) 2022-11-10 09:14:14 +04:00
Nikolay Edigaryev e600d2f036 Improve UninitializedVMDirectoryError() (#314) 2022-11-09 20:49:35 -05:00
Nikolay Edigaryev b21dbbe3a3 Fetcher: do not use cookies to avoid CSRF checks (#312)
* Fetcher: do not use cookies to avoid CSRF checks

* Explain why we disable cookies

* Rename getURLSession() → createURLSession() and move it below
2022-11-09 14:27:45 -05:00
Nikolay Edigaryev ee0fbdd83d OCI: pull blobs via file (#306)
* OCI: pull blobs via file

* Explain why we delete the downloaded file after opening a handle to it

* Further abstract away ways to fetch a URLRequest

* No need to cast HTTPURLResponse to HTTPURLResponse

* Fetcher: no need to be a delegate anymore

* Fetcher.fetch() can be made static
2022-11-09 19:36:41 +04:00
Fedor Korotkov 8961c5189a Fixed Tart logo on GitHubMobile App (#310)
We can't use the regular images since we use Git LFS. Forgot to fix the main image as part of #237
2022-11-09 12:19:15 +04:00
Nikolay EdigaryevandFedor Korotkov 555715588d README.md: more realistic Packer HCL example (#308)
* README.md: more realistic Packer HCL example

* Update README.md

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>

* Update README.md

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2022-11-08 04:59:35 -05:00
Andrea Cristalli 8d096966b4 Packer json configuration is deprecated (#307) 2022-11-08 13:42:22 +04:00
Nikolay Edigaryev fb954b7cc1 tart list: support -q (or --quiet) for automation purposes (#293) 2022-11-02 11:49:45 -04:00
Fedor KorotkovandNikolay Edigaryev 8cbcd2285b Document Cirrus Runners (#288)
* Document Cirrus Runners

Fixes #237

* Update README.md

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>

* Remove redundant mention

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2022-10-27 10:17:13 -04:00
Pete Goldsmith 3d0d889c99 Remove whitespace (#285) 2022-10-22 08:17:42 -04:00
Nikolay Edigaryev 0e77f14dd7 OCI: always read channel until end (#284) 2022-10-18 11:44:14 -04:00
Nikolay Edigaryev 39e1b84423 Use URLSession.dataTask() with delegate instead of URLSession.bytes() (#282)
* Use URLSession.dataTask() with delegate instead of URLSession.bytes()

* Use URLSession.shared instead of creating a new one each time
2022-10-18 08:54:28 -04:00
Fedor Korotkov af7530ee50 Relax status code acceptance (#281)
* Relax status code acceptance

Fixes #273

* added a comment
2022-10-17 21:42:25 +04:00
Nikolay Edigaryev 36bb68a72d tart run: deprecate --with-softnet and introduce --net-softnet (#274) 2022-10-14 18:38:39 +04:00
Nikolay Edigaryev afd707eedf OCI reference parser: allow dashes in host name (#275) 2022-10-14 16:36:25 +04:00
Nikolay Edigaryev c67efb88f3 Fix tart prune --cache-budget logic (#272) 2022-10-14 01:43:37 +04:00
Nikolay Edigaryev 667de2a199 Monitor Softnet process and throw if it terminates prematurely (#270) 2022-10-12 20:00:13 +04:00
Nikolay Edigaryev 44650e9713 Improve RemoteName parser (#225) (#269)
* Improve RemoteName parser

* Remove Parsing import

* Permit namespace components to contain separators, but no more than one

* Add testNoPathTraversal
2022-10-11 22:59:44 +04:00
Nikolay Edigaryev 62ee42de3b Plug URLSession.bytes() memory leak (#267) 2022-10-10 18:38:57 +04:00
Nikolay Edigaryev 89301d114e Log cache pruning to $TART_HOME/tart.log (#265)
* Log cache pruning to $TART_HOME/tart.log

* Log zero capacities
2022-10-07 23:01:42 +04:00
Nikolay Edigaryev dbb33d0651 Don't download IPSWs into memory (#262) 2022-10-06 20:33:34 +04:00
Nikolay Edigaryev 166e3e570f Ditch AsyncHTTPClient in favor of URLSession (#260) 2022-10-05 00:39:36 +04:00
Nikolay Edigaryev 7a2c20ba30 tart create: support fetching URLs specified in the --from-ipsw option (#256)
* tart create: support fetching URLs specified in the --from-ipsw option

* Use x-amz-meta-digest-sha256 header to cache IPSWs
2022-09-27 23:36:18 +04:00
Nikolay Edigaryev e90d53eceb tart delete: allow removing multiple VMs at once (#257) 2022-09-27 10:30:44 -04:00
Nikolay Edigaryev afbc2e0764 tart ip: keep waiting for the /var/db/dhcpd_leases file to appear (#254) 2022-09-22 10:24:14 -04:00
Fedor Korotkov 0229138bd5 Fixed wait delay for IP command (#250)
I initially wanted a one-second wait but made a human mistake.

Fixes #249
2022-09-19 21:44:14 +04:00
Nikolay EdigaryevandPete Goldsmith 4d08e6365e Set line-buffered output for stdout and introduce --graphics for tart run (#248)
* Set line-buffered output for stdout

* tart run: introduce --graphics

* Update Sources/tart/Commands/Run.swift

Co-authored-by: Pete Goldsmith <peter.n.goldsmith@gmail.com>

* Update Sources/tart/Commands/Run.swift

Co-authored-by: Pete Goldsmith <peter.n.goldsmith@gmail.com>

Co-authored-by: Pete Goldsmith <peter.n.goldsmith@gmail.com>
2022-09-19 21:43:37 +04:00
90 changed files with 3534 additions and 1270 deletions
+55 -4
View File
@@ -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,6 +50,10 @@ 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:
@@ -26,6 +61,9 @@ task:
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,23 @@ 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
+1
View File
@@ -0,0 +1 @@
* @edigaryev @fkorotkov
+5
View File
@@ -0,0 +1,5 @@
--disable all
--enable indent
--indent 2
--exclude Sources/tart/OCI/Reference/Generated
--swiftversion 5.7
+41 -68
View File
@@ -1,12 +1,12 @@
{ {
"pins" : [ "pins" : [
{ {
"identity" : "async-http-client", "identity" : "antlr4",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/async-http-client", "location" : "https://github.com/antlr/antlr4",
"state" : { "state" : {
"revision" : "df87a860fdc41a595d5ca67f74cde9adbccc099a", "branch" : "dev",
"version" : "1.11.4" "revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2"
} }
}, },
{ {
@@ -18,6 +18,24 @@
"revision" : "772883073d044bc754d401cabb6574624eb3778f" "revision" : "772883073d044bc754d401cabb6574624eb3778f"
} }
}, },
{
"identity" : "puppy",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sushichop/Puppy",
"state" : {
"revision" : "3e8d87f714f14244878752a6bb71ea465119f8a1",
"version" : "0.5.1"
}
},
{
"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",
@@ -36,6 +54,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",
@@ -46,12 +73,12 @@
} }
}, },
{ {
"identity" : "swift-case-paths", "identity" : "swift-collections",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-case-paths", "location" : "https://github.com/apple/swift-collections.git",
"state" : { "state" : {
"revision" : "ce9c0d897db8a840c39de64caaa9b60119cf4be8", "revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
"version" : "0.8.1" "version" : "1.0.3"
} }
}, },
{ {
@@ -59,53 +86,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git", "location" : "https://github.com/apple/swift-log.git",
"state" : { "state" : {
"revision" : "5d66f7ba25daf4f94100e7022febf3c75e37a6c7", "revision" : "6fe203dc33195667ce1759bf0182975e4653ba1c",
"version" : "1.4.2" "version" : "1.4.4"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "124119f0bb12384cef35aa041d7c3a686108722d",
"version" : "2.40.0"
}
},
{
"identity" : "swift-nio-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "8eea84ec6144167354387ef9244b0939f5852dc8",
"version" : "1.11.0"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "108ac15087ea9b79abb6f6742699cf31de0e8772",
"version" : "1.22.0"
}
},
{
"identity" : "swift-nio-ssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "1750873bce84b4129b5303655cce2c3d35b9ed3a",
"version" : "2.19.0"
}
},
{
"identity" : "swift-nio-transport-services",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-transport-services.git",
"state" : {
"revision" : "1a4692acb88156e3da1b0c6732a8a38b2a744166",
"version" : "1.12.0"
} }
}, },
{ {
@@ -117,15 +99,6 @@
"version" : "1.0.2" "version" : "1.0.2"
} }
}, },
{
"identity" : "swift-parsing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-parsing",
"state" : {
"revision" : "28d32e9ace1c4c43f5e5a177be837a202494c2d5",
"version" : "0.9.2"
}
},
{ {
"identity" : "swiftdate", "identity" : "swiftdate",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -136,12 +109,12 @@
} }
}, },
{ {
"identity" : "xctest-dynamic-overlay", "identity" : "swiftformat",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "location" : "https://github.com/nicklockwood/SwiftFormat",
"state" : { "state" : {
"revision" : "50a70a9d3583fe228ce672e8923010c8df2deddd", "revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
"version" : "0.2.1" "version" : "0.50.6"
} }
} }
], ],
+20 -6
View File
@@ -12,21 +12,35 @@ 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/pointfreeco/swift-parsing", from: "0.9.2"),
.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/malcommac/SwiftDate", from: "6.3.1") .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/sushichop/Puppy", from: "0.5.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"),
], ],
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: "Parsing", package: "swift-parsing"),
.product(name: "SwiftDate", package: "SwiftDate"), .product(name: "SwiftDate", package: "SwiftDate"),
.product(name: "Puppy", package: "Puppy"),
.product(name: "Antlr4Static", package: "Antlr4"),
.product(name: "Atomics", package: "swift-atomics"),
.product(name: "Sentry", package: "sentry-cocoa"),
], 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"])
] ]
) )
+147 -38
View File
@@ -1,6 +1,6 @@
![Tart – open source virtualization for your automation needs](Resources/TartSocial.png) <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/14966395?baseline=14966339).
@@ -22,20 +22,64 @@ 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://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 ```shell
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
``` ```
![tart VM view app](Resources/TartScreenshot.png) <img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartScreenshot.png"/>
## CI Integration ## CI Integration
Tart already powers several CI services mentioned above including our own [Cirrus CI](https://cirrus-ci.org/guide/macOS/) which offers unlimited concurrency with per-second billing.
For services that haven't leveraged Tart yet, we offer fully managed runners via a monthly subscription.
*Cirrus Runners* is the fastest way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
Please read down below about currently supported services.
### Managed runners for your CI-as-a-service
At the moment Cirrus Runners only supports GitHub Actions, but we are actively working on adding more options.
Please [email us](mailto:hello@cirruslabs.org) if you are interested in a particular one.
#### GitHub Actions
Configuring Cirrus Runners for GitHub Actions is as simple as installing [Cirrus Runners App](https://github.com/apps/cirrus-runners).
After successful installation and subscription configuration, use any of [Ventura images managed by us](https://github.com/cirruslabs/macos-image-templates) in `runs-on`:
```yaml
name: Test Suite
jobs:
test:
runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest
```
When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`.
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartGHARunners.png"/>
### Self-hosted CI
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI 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 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. one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
@@ -64,14 +108,14 @@ brew install cirruslabs/cli/cirrus
cirrus run cirrus run
``` ```
![Cirrus CLI Run](Resources/TartCirrusCLI.gif) <img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartCirrusCLI.gif"/>
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml` [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. 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). **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 #### 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. 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 For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
@@ -89,7 +133,7 @@ task:
Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host: Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
```bash ```shell
cirrus run --artifacts-dir artifacts cirrus run --artifacts-dir artifacts
``` ```
@@ -123,7 +167,9 @@ After the initial booting of the VM you'll need to manually go through the macOS
#### Creating a Linux VM image from scratch #### Creating a Linux VM image from scratch
```bash Linux VMs are supported on hosts running macOS 13.0 (Ventura) or newer.
```shell
# Create a bare VM # Create a bare VM
tart create --linux ubuntu tart create --linux ubuntu
@@ -152,32 +198,35 @@ Please refer to `tart set --help` for additional details.
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions. 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: Here is an example of a template to build `monterey-base` local image based of a remote image:
```json ```hcl
{ packer {
"builders": [ required_plugins {
{ tart = {
"name": "tart", version = ">= 0.5.3"
"type": "tart-cli", source = "github.com/cirruslabs/tart"
"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": [ source "tart-cli" "tart" {
"echo 'Disabling spotlight indexing...'", vm_base_name = "ghcr.io/cirruslabs/macos-ventura-base:latest"
"sudo mdutil -a -i off" vm_name = "my-custom-ventura"
], cpu_count = 4
"type": "shell" memory_gb = 8
}, disk_size_gb = 70
# more provisioners ssh_password = "admin"
] ssh_timeout = "120s"
ssh_username = "admin"
}
build {
sources = ["source.tart-cli.tart"]
provisioner "shell" {
inline = ["echo 'Disabling spotlight indexing...'", "sudo mdutil -a -i off"]
}
# more provisioners
} }
``` ```
@@ -197,6 +246,12 @@ tart login acme.io
Credentials are securely stored in Keychain. Credentials are securely stored in Keychain.
In addition, Tart supports [Docker credential helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers)
if defined in `~/.docker/config.json`.
Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override authorization
for all registries which might useful for integrating with your CI's secret management.
#### Pushing a Local Image #### Pushing a Local Image
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags: Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
@@ -221,6 +276,50 @@ 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. This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
### Mounting directories
To mount a directory, run the VM with the `--dir` argument:
```shell
tart run --dir=project:~/src/project vm
```
Here, the `project` specifies a mount name, whereas the `~/src/project` is a path to the host's directory to expose to the VM.
It is also possible to mount directories in read-only mode by adding a third parameter, `ro`:
```shell
tart run --dir=project:~/src/project:ro vm
```
To mount multiple directories, repeat the `--dir` argument for each directory:
```shell
tart run --dir=www1:~/project1/www --dir=www2:~/project2/www
```
Note that the first parameter in each `--dir` argument must be unique, otherwise only the last `--dir` argument using that name will be used.
Note: to use the directory mounting feature, the host needs to run macOS 13.0 (Ventura) or newer.
#### Accessing mounted directories in macOS guests
All shared directories are automatically mounted to `/Volumes/My Shared Files` directory.
The directory we've mounted above will be accessible from the `/Volumes/My Shared Files/project` path inside a guest VM.
Note: to use the directory mounting feature, the guest VM needs to run macOS 13.0 (Ventura) or newer.
#### Accessing mounted directories in Linux guests
To be able to access the shared directories from the Linux guest, you need to manually mount the virtual filesystem first:
```shell
mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
```
The directory we've mounted above will be accessible from the `/mnt/shared/project` path inside a guest VM.
## FAQ ## FAQ
<details> <details>
@@ -259,7 +358,7 @@ This invocation calls the `tart pull` implicitly (if the image is not being pres
<summary>VM location on disk</summary> <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/`. 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/`. Remote images are pulled into `~/.tart/cache/OCIs/`.
</details> </details>
<details> <details>
@@ -274,7 +373,7 @@ This invocation calls the `tart pull` implicitly (if the image is not being pres
To change the default network to `192.168.77.1`: To change the default network to `192.168.77.1`:
``` ```shell
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Address -string 192.168.77.1 sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Address -string 192.168.77.1
``` ```
@@ -282,7 +381,17 @@ This invocation calls the `tart pull` implicitly (if the image is not being pres
The default subnet mask `255.255.255.0` should suffice for most use-cases, however, you can also change it to `255.255.0.0`, for example: The default subnet mask `255.255.255.0` should suffice for most use-cases, however, you can also change it to `255.255.0.0`, for example:
``` ```shell
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0 sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
``` ```
</details> </details>
<details>
<summary>How to connect to a VM over SSH?</summary>
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
```shell
ssh admin@$(tart ip macos-monterey-base)
```
</details>
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:23728bb5438c88b3d0826170a5fa4b7aaad0fbd94a4769c8d492c9f75b57ba81
size 155885
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3a43f541b1ab0b57ae2060d371cba5dbb1f5c80b89c76434b7154d8144f66e61 oid sha256:7a4929ca4e02d4968904749028ada7072704d2a52cccb69335e1596452c822c7
size 205325 size 1359834
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4d1f58f6460217434ae133c6d6e444320eb705c5dcf9efebbb215b117ae452c1
size 589
+4
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:630c4975b9eead9e4f62e2a5375509dc0ee6cab2940ec3ffc2ae16b08d325964
size 6765
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e48fbe90f6cc84e100fc286a5349c681cacb22358811181046d35ea298309de
size 16379
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1c7f7f9bf0fc0c9807f3a415fe6bf773c8a416b351c5aa08de46149a13de7387
size 5297
+4
View File
@@ -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 {
+27 -35
View File
@@ -21,43 +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()
// 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: {
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)
} }
let sourceVM = try VMStorageHelper.open(sourceName)
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
try await withTaskCancellationHandler(operation: {
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)
})
} }
} }
+28 -30
View File
@@ -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,40 +25,38 @@ struct Create: AsyncParsableCommand {
} }
func run() async throws { func run() async throws {
do { let tmpVMDir = try VMDirectory.temporary()
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection // Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL) let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock() try tmpVMDirLock.lock()
try await withTaskCancellationHandler(operation: { try await withTaskCancellationHandler(operation: {
if let fromIPSW = fromIPSW { if let fromIPSW = fromIPSW {
if fromIPSW == "latest" { let ipswURL: URL
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
} else { if fromIPSW == "latest" {
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW), diskSizeGB: diskSize) 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)
}
} }
} }
+3 -9
View File
@@ -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)
} }
} }
} }
+52
View File
@@ -0,0 +1,52 @@
import ArgumentParser
import Foundation
struct Get: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
@Argument(help: "VM name.")
var name: String
@Flag(help: "Number of VM CPUs.")
var cpu: Bool = false
@Flag(help: "VM memory size in megabytes.")
var memory: Bool = false
@Flag(help: "Disk size in gigabytes.")
var diskSize: Bool = false
@Flag(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800.")
var display: Bool = false
func validate() throws {
if [cpu, memory, diskSize, display].filter({$0}).count > 1 {
throw ValidationError("Options --cpu, --memory, --disk-size and --display are mutually exclusive")
}
}
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
let diskSizeInGb = try vmDir.sizeBytes() / 1000 / 1000 / 1000
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
if cpu {
print(vmConfig.cpuCount)
} else if memory {
print(memorySizeInMb)
} else if diskSize {
print(diskSizeInGb)
} else if display {
print("\(vmConfig.display.width)x\(vmConfig.display.height)")
} else {
print(
"CPU\tMemory\tDisk\tDisplay\n" +
"\(vmConfig.cpuCount)\t" +
"\(memorySizeInMb) MB\t" +
"\(diskSizeInGb) GB\t" +
"\(vmConfig.display)"
)
}
}
}
+33 -25
View File
@@ -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
+28 -8
View File
@@ -5,24 +5,44 @@ import SwiftUI
struct List: AsyncParsableCommand { struct List: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "List created VMs") static var configuration = CommandConfiguration(abstract: "List created VMs")
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
var quiet: Bool = false
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
var source: String?
func validate() throws {
guard let source = source else {
return
}
if !["local", "oci"].contains(source) {
throw ValidationError("'\(source)' is not a valid <source>")
}
}
func run() async throws { func run() async throws {
do { if !quiet {
print("Source\tName") print("Source\tName")
}
if source == nil || source == "local" {
displayTable("local", try VMStorageLocal().list()) displayTable("local", try VMStorageLocal().list())
}
if source == nil || source == "oci" {
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) }) displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
} }
} }
private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) { private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) { for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) {
print("\(source)\t\(name)") if quiet {
print(name)
} else {
let source = source.padding(toLength: "Source".count, withPad: " ", startingAt: 0)
print("\(source)\t\(name)")
}
} }
} }
} }
+21 -31
View File
@@ -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)
} }
} }
+39 -37
View File
@@ -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,11 @@ struct Prune: AsyncParsableCommand {
cacheReclaimedBytes += try prunable.sizeBytes() cacheReclaimedBytes += try prunable.sizeBytes()
try prunable.delete() try prunable.delete()
puppy.info("deleting \(prunable.url)...")
} }
puppy.info("reclaimed \(cacheReclaimedBytes) bytes")
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
} }
} }
+12 -20
View File
@@ -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)
} }
} }
+37 -45
View File
@@ -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)
} }
} }
} }
+21 -29
View File
@@ -2,39 +2,31 @@ import ArgumentParser
import Foundation import Foundation
struct Rename: AsyncParsableCommand { struct Rename: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Rename a VM") static var configuration = CommandConfiguration(abstract: "Rename a VM")
@Argument(help: "VM name") @Argument(help: "VM name")
var name: String var name: String
@Argument(help: "new VM name") @Argument(help: "new VM name")
var newName: String var newName: String
func validate() throws { func validate() throws {
if newName.contains("/") { if newName.contains("/") {
throw ValidationError("<new-name> should be a local name") throw ValidationError("<new-name> should be a local name")
} }
}
func run() async throws {
let localStorage = VMStorageLocal()
if !localStorage.exists(name) {
throw ValidationError("failed to rename a non-existent VM: \(name)")
} }
func run() async throws { if localStorage.exists(newName) {
do { throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
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)
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
} }
try localStorage.rename(name, newName)
}
} }
+161 -45
View File
@@ -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,33 +38,52 @@ 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 @Flag(help: ArgumentHelp(visibility: .private))
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: """
Requires host to be macOS 13.0 (Ventura) or newer. Requires host to be macOS 13.0 (Ventura) or newer.
All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS, All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS,
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point". while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
For macOS guests, they must be running macOS 13.0 (Ventura) or newer. For macOS guests, they must be running macOS 13.0 (Ventura) or newer.
""", valueName: "name:path[:ro]")) """, valueName: "name:path[:ro]"))
var dir: [String] = [] var dir: [String] = []
@Option(help: ArgumentHelp(""" @Option(help: ArgumentHelp("""
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\") Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
""", discussion: """ """, discussion: """
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces. Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
""", valueName: "interface name")) """, valueName: "interface name"))
var netBridged: String? 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")
@@ -69,16 +92,41 @@ struct Run: AsyncParsableCommand {
if withSoftnet && netBridged != nil { if withSoftnet && netBridged != nil {
throw ValidationError("--with-softnet and --net-bridged are mutually exclusive") throw ValidationError("--with-softnet and --net-bridged 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,
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(), network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
additionalDiskAttachments: additionalDiskAttachments(), additionalDiskAttachments: additionalDiskAttachments,
directoryShares: directoryShares() directorySharingDevices: directoryShares() + rosettaDirectoryShare()
) )
let vncImpl: VNC? = try { let vncImpl: VNC? = try {
@@ -92,6 +140,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 {
@@ -113,12 +178,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)
} }
} }
@@ -129,7 +194,8 @@ 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()
@@ -137,7 +203,7 @@ struct Run: AsyncParsableCommand {
} }
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? { func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
if withSoftnet { if withSoftnet || netSoftnet {
let config = try VMConfig.init(fromURL: vmDir.configURL) let config = try VMConfig.init(fromURL: vmDir.configURL)
return try Softnet(vmMACAddress: config.macAddress.string) return try Softnet(vmMACAddress: config.macAddress.string)
@@ -199,8 +265,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 == ":" }
@@ -221,10 +301,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: NSString(string: path).expandingTildeInPath), 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() {
@@ -245,16 +361,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() }
} }
} }
} }
+20 -28
View File
@@ -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)
} }
} }
} }
+60
View File
@@ -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 -3
View File
@@ -12,8 +12,8 @@ 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
@@ -26,7 +26,7 @@ struct Config {
func gc() throws { func gc() throws {
for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir, for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
includingPropertiesForKeys: [], options: []) { includingPropertiesForKeys: [], options: []) {
let lock = try FileLock(lockURL: entry) let lock = try FileLock(lockURL: entry)
if try !lock.trylock() { if try !lock.trylock() {
continue continue
@@ -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 {
+67
View File
@@ -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)
}
+37 -37
View File
@@ -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
}
} }
+3 -3
View File
@@ -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")}
} }
} }
+96 -89
View File
@@ -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])
}
} }
+11 -4
View File
@@ -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 {
+2 -2
View File
@@ -2,6 +2,6 @@ import Virtualization
protocol Network { protocol Network {
func attachment() -> VZNetworkDeviceAttachment func attachment() -> VZNetworkDeviceAttachment
func run() throws func run(_ sema: DispatchSemaphore) throws
func stop() throws func stop() async throws
} }
+13 -13
View File
@@ -2,21 +2,21 @@ import Foundation
import Virtualization import Virtualization
class NetworkBridged: Network { class NetworkBridged: Network {
let interface: VZBridgedNetworkInterface let interface: VZBridgedNetworkInterface
init(interface: VZBridgedNetworkInterface) { init(interface: VZBridgedNetworkInterface) {
self.interface = interface self.interface = interface
} }
func attachment() -> VZNetworkDeviceAttachment { func attachment() -> VZNetworkDeviceAttachment {
VZBridgedNetworkDeviceAttachment(interface: interface) VZBridgedNetworkDeviceAttachment(interface: interface)
} }
func run() throws { func run(_ sema: DispatchSemaphore) throws {
// no-op, only used for Softnet // no-op, only used for Softnet
} }
func stop() throws { func stop() async throws {
// no-op, only used for Softnet // no-op, only used for Softnet
} }
} }
+9 -9
View File
@@ -2,15 +2,15 @@ import Foundation
import Virtualization import Virtualization
class NetworkShared: Network { class NetworkShared: Network {
func attachment() -> VZNetworkDeviceAttachment { func attachment() -> VZNetworkDeviceAttachment {
VZNATNetworkDeviceAttachment() VZNATNetworkDeviceAttachment()
} }
func run() throws { func run(_ sema: DispatchSemaphore) throws {
// no-op, only used for Softnet // no-op, only used for Softnet
} }
func stop() throws { func stop() async throws {
// no-op, only used for Softnet // no-op, only used for Softnet
} }
} }
+35 -7
View File
@@ -1,12 +1,16 @@
import Foundation import Foundation
import Virtualization import Virtualization
import Atomics
enum SoftnetError: Error { enum SoftnetError: Error {
case InitializationFailed(why: String) case InitializationFailed(why: String)
case RuntimeFailed(why: String)
} }
class Softnet: Network { class Softnet: Network {
private let process = Process() private let process = Process()
private var monitorTask: Task<Void, Error>? = nil
private let monitorTaskFinished = ManagedAtomic<Bool>(false)
let vmFD: Int32 let vmFD: Int32
@@ -35,25 +39,49 @@ class Softnet: Network {
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false) process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
} }
func run() throws { func run(_ sema: DispatchSemaphore) throws {
try process.run() 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() throws { func stop() async throws {
process.interrupt() if monitorTaskFinished.load(ordering: .sequentiallyConsistent) {
process.waitUntilExit() // 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 { private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
var option_value = sizeBytes
let option_len = socklen_t(MemoryLayout<Int>.size) let option_len = socklen_t(MemoryLayout<Int>.size)
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &option_value, option_len) // 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 { if ret != 0 {
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)") throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)")
} }
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &option_value, option_len) var sendBufferSize = sizeBytes
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sendBufferSize, option_len)
if ret != 0 { if ret != 0 {
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)") throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
} }
+3 -3
View File
@@ -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()
} }
} }
@@ -0,0 +1,37 @@
token literal names:
null
':'
'/'
'.'
'-'
'@'
'_'
null
null
token symbolic names:
null
null
null
null
null
null
null
DIGIT
LETTER
rule names:
root
host
port
host_component
namespace
namespace_component
reference
tag
separator
name
atn:
[4, 1, 8, 95, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 1, 0, 1, 0, 1, 0, 3, 0, 24, 8, 0, 1, 0, 1, 0, 1, 0, 3, 0, 29, 8, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 5, 1, 36, 8, 1, 10, 1, 12, 1, 39, 9, 1, 1, 2, 4, 2, 42, 8, 2, 11, 2, 12, 2, 43, 1, 3, 1, 3, 1, 3, 5, 3, 49, 8, 3, 10, 3, 12, 3, 52, 9, 3, 1, 4, 1, 4, 1, 4, 5, 4, 57, 8, 4, 10, 4, 12, 4, 60, 9, 4, 1, 5, 1, 5, 3, 5, 64, 8, 5, 4, 5, 66, 8, 5, 11, 5, 12, 5, 67, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 3, 6, 77, 8, 6, 1, 7, 1, 7, 1, 7, 1, 7, 5, 7, 83, 8, 7, 10, 7, 12, 7, 86, 9, 7, 1, 8, 1, 8, 1, 9, 4, 9, 91, 8, 9, 11, 9, 12, 9, 92, 1, 9, 0, 0, 10, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 0, 2, 2, 0, 3, 4, 6, 6, 1, 0, 7, 8, 95, 0, 20, 1, 0, 0, 0, 2, 32, 1, 0, 0, 0, 4, 41, 1, 0, 0, 0, 6, 45, 1, 0, 0, 0, 8, 53, 1, 0, 0, 0, 10, 65, 1, 0, 0, 0, 12, 76, 1, 0, 0, 0, 14, 78, 1, 0, 0, 0, 16, 87, 1, 0, 0, 0, 18, 90, 1, 0, 0, 0, 20, 23, 3, 2, 1, 0, 21, 22, 5, 1, 0, 0, 22, 24, 3, 4, 2, 0, 23, 21, 1, 0, 0, 0, 23, 24, 1, 0, 0, 0, 24, 25, 1, 0, 0, 0, 25, 26, 5, 2, 0, 0, 26, 28, 3, 8, 4, 0, 27, 29, 3, 12, 6, 0, 28, 27, 1, 0, 0, 0, 28, 29, 1, 0, 0, 0, 29, 30, 1, 0, 0, 0, 30, 31, 5, 0, 0, 1, 31, 1, 1, 0, 0, 0, 32, 37, 3, 6, 3, 0, 33, 34, 5, 3, 0, 0, 34, 36, 3, 6, 3, 0, 35, 33, 1, 0, 0, 0, 36, 39, 1, 0, 0, 0, 37, 35, 1, 0, 0, 0, 37, 38, 1, 0, 0, 0, 38, 3, 1, 0, 0, 0, 39, 37, 1, 0, 0, 0, 40, 42, 5, 7, 0, 0, 41, 40, 1, 0, 0, 0, 42, 43, 1, 0, 0, 0, 43, 41, 1, 0, 0, 0, 43, 44, 1, 0, 0, 0, 44, 5, 1, 0, 0, 0, 45, 50, 3, 18, 9, 0, 46, 47, 5, 4, 0, 0, 47, 49, 3, 18, 9, 0, 48, 46, 1, 0, 0, 0, 49, 52, 1, 0, 0, 0, 50, 48, 1, 0, 0, 0, 50, 51, 1, 0, 0, 0, 51, 7, 1, 0, 0, 0, 52, 50, 1, 0, 0, 0, 53, 58, 3, 10, 5, 0, 54, 55, 5, 2, 0, 0, 55, 57, 3, 10, 5, 0, 56, 54, 1, 0, 0, 0, 57, 60, 1, 0, 0, 0, 58, 56, 1, 0, 0, 0, 58, 59, 1, 0, 0, 0, 59, 9, 1, 0, 0, 0, 60, 58, 1, 0, 0, 0, 61, 63, 3, 18, 9, 0, 62, 64, 3, 16, 8, 0, 63, 62, 1, 0, 0, 0, 63, 64, 1, 0, 0, 0, 64, 66, 1, 0, 0, 0, 65, 61, 1, 0, 0, 0, 66, 67, 1, 0, 0, 0, 67, 65, 1, 0, 0, 0, 67, 68, 1, 0, 0, 0, 68, 11, 1, 0, 0, 0, 69, 70, 5, 1, 0, 0, 70, 77, 3, 14, 7, 0, 71, 72, 5, 5, 0, 0, 72, 73, 3, 18, 9, 0, 73, 74, 5, 1, 0, 0, 74, 75, 3, 18, 9, 0, 75, 77, 1, 0, 0, 0, 76, 69, 1, 0, 0, 0, 76, 71, 1, 0, 0, 0, 77, 13, 1, 0, 0, 0, 78, 84, 3, 18, 9, 0, 79, 80, 3, 16, 8, 0, 80, 81, 3, 18, 9, 0, 81, 83, 1, 0, 0, 0, 82, 79, 1, 0, 0, 0, 83, 86, 1, 0, 0, 0, 84, 82, 1, 0, 0, 0, 84, 85, 1, 0, 0, 0, 85, 15, 1, 0, 0, 0, 86, 84, 1, 0, 0, 0, 87, 88, 7, 0, 0, 0, 88, 17, 1, 0, 0, 0, 89, 91, 7, 1, 0, 0, 90, 89, 1, 0, 0, 0, 91, 92, 1, 0, 0, 0, 92, 90, 1, 0, 0, 0, 92, 93, 1, 0, 0, 0, 93, 19, 1, 0, 0, 0, 11, 23, 28, 37, 43, 50, 58, 63, 67, 76, 84, 92]
@@ -0,0 +1,14 @@
T__0=1
T__1=2
T__2=3
T__3=4
T__4=5
T__5=6
DIGIT=7
LETTER=8
':'=1
'/'=2
'.'=3
'-'=4
'@'=5
'_'=6
@@ -0,0 +1,167 @@
// Generated from java-escape by ANTLR 4.11.1
import Antlr4
/**
* This class provides an empty implementation of {@link ReferenceListener},
* which can be extended to create a listener which only needs to handle a subset
* of the available methods.
*/
open class ReferenceBaseListener: ReferenceListener {
public init() { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterRoot(_ ctx: ReferenceParser.RootContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitRoot(_ ctx: ReferenceParser.RootContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterHost(_ ctx: ReferenceParser.HostContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitHost(_ ctx: ReferenceParser.HostContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterPort(_ ctx: ReferenceParser.PortContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitPort(_ ctx: ReferenceParser.PortContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterHost_component(_ ctx: ReferenceParser.Host_componentContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitHost_component(_ ctx: ReferenceParser.Host_componentContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterNamespace(_ ctx: ReferenceParser.NamespaceContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitNamespace(_ ctx: ReferenceParser.NamespaceContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterReference(_ ctx: ReferenceParser.ReferenceContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitReference(_ ctx: ReferenceParser.ReferenceContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterTag(_ ctx: ReferenceParser.TagContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitTag(_ ctx: ReferenceParser.TagContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterSeparator(_ ctx: ReferenceParser.SeparatorContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitSeparator(_ ctx: ReferenceParser.SeparatorContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterName(_ ctx: ReferenceParser.NameContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitName(_ ctx: ReferenceParser.NameContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterEveryRule(_ ctx: ParserRuleContext) throws { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitEveryRule(_ ctx: ParserRuleContext) throws { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func visitTerminal(_ node: TerminalNode) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func visitErrorNode(_ node: ErrorNode) { }
}
@@ -0,0 +1,41 @@
token literal names:
null
':'
'/'
'.'
'-'
'@'
'_'
null
null
token symbolic names:
null
null
null
null
null
null
null
DIGIT
LETTER
rule names:
T__0
T__1
T__2
T__3
T__4
T__5
DIGIT
LETTER
channel names:
DEFAULT_TOKEN_CHANNEL
HIDDEN
mode names:
DEFAULT_MODE
atn:
[4, 0, 8, 33, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 6, 1, 6, 1, 7, 1, 7, 0, 0, 8, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 1, 0, 2, 1, 0, 48, 57, 2, 0, 65, 90, 97, 122, 32, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0, 1, 17, 1, 0, 0, 0, 3, 19, 1, 0, 0, 0, 5, 21, 1, 0, 0, 0, 7, 23, 1, 0, 0, 0, 9, 25, 1, 0, 0, 0, 11, 27, 1, 0, 0, 0, 13, 29, 1, 0, 0, 0, 15, 31, 1, 0, 0, 0, 17, 18, 5, 58, 0, 0, 18, 2, 1, 0, 0, 0, 19, 20, 5, 47, 0, 0, 20, 4, 1, 0, 0, 0, 21, 22, 5, 46, 0, 0, 22, 6, 1, 0, 0, 0, 23, 24, 5, 45, 0, 0, 24, 8, 1, 0, 0, 0, 25, 26, 5, 64, 0, 0, 26, 10, 1, 0, 0, 0, 27, 28, 5, 95, 0, 0, 28, 12, 1, 0, 0, 0, 29, 30, 7, 0, 0, 0, 30, 14, 1, 0, 0, 0, 31, 32, 7, 1, 0, 0, 32, 16, 1, 0, 0, 0, 1, 0, 0]
@@ -0,0 +1,90 @@
// Generated from java-escape by ANTLR 4.11.1
import Antlr4
open class ReferenceLexer: Lexer {
internal static var _decisionToDFA: [DFA] = {
var decisionToDFA = [DFA]()
let length = ReferenceLexer._ATN.getNumberOfDecisions()
for i in 0..<length {
decisionToDFA.append(DFA(ReferenceLexer._ATN.getDecisionState(i)!, i))
}
return decisionToDFA
}()
internal static let _sharedContextCache = PredictionContextCache()
public
static let T__0=1, T__1=2, T__2=3, T__3=4, T__4=5, T__5=6, DIGIT=7, LETTER=8
public
static let channelNames: [String] = [
"DEFAULT_TOKEN_CHANNEL", "HIDDEN"
]
public
static let modeNames: [String] = [
"DEFAULT_MODE"
]
public
static let ruleNames: [String] = [
"T__0", "T__1", "T__2", "T__3", "T__4", "T__5", "DIGIT", "LETTER"
]
private static let _LITERAL_NAMES: [String?] = [
nil, "':'", "'/'", "'.'", "'-'", "'@'", "'_'"
]
private static let _SYMBOLIC_NAMES: [String?] = [
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
]
public
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
override open
func getVocabulary() -> Vocabulary {
return ReferenceLexer.VOCABULARY
}
public
required init(_ input: CharStream) {
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
super.init(input)
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
}
override open
func getGrammarFileName() -> String { return "Reference.g4" }
override open
func getRuleNames() -> [String] { return ReferenceLexer.ruleNames }
override open
func getSerializedATN() -> [Int] { return ReferenceLexer._serializedATN }
override open
func getChannelNames() -> [String] { return ReferenceLexer.channelNames }
override open
func getModeNames() -> [String] { return ReferenceLexer.modeNames }
override open
func getATN() -> ATN { return ReferenceLexer._ATN }
static let _serializedATN:[Int] = [
4,0,8,33,6,-1,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,
2,7,7,7,1,0,1,0,1,1,1,1,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,6,1,6,1,7,1,
7,0,0,8,1,1,3,2,5,3,7,4,9,5,11,6,13,7,15,8,1,0,2,1,0,48,57,2,0,65,90,97,
122,32,0,1,1,0,0,0,0,3,1,0,0,0,0,5,1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,
1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,1,17,1,0,0,0,3,19,1,0,0,0,5,21,1,0,0,
0,7,23,1,0,0,0,9,25,1,0,0,0,11,27,1,0,0,0,13,29,1,0,0,0,15,31,1,0,0,0,
17,18,5,58,0,0,18,2,1,0,0,0,19,20,5,47,0,0,20,4,1,0,0,0,21,22,5,46,0,0,
22,6,1,0,0,0,23,24,5,45,0,0,24,8,1,0,0,0,25,26,5,64,0,0,26,10,1,0,0,0,
27,28,5,95,0,0,28,12,1,0,0,0,29,30,7,0,0,0,30,14,1,0,0,0,31,32,7,1,0,0,
32,16,1,0,0,0,1,0,0
]
public
static let _ATN: ATN = try! ATNDeserializer().deserialize(_serializedATN)
}
@@ -0,0 +1,14 @@
T__0=1
T__1=2
T__2=3
T__3=4
T__4=5
T__5=6
DIGIT=7
LETTER=8
':'=1
'/'=2
'.'=3
'-'=4
'@'=5
'_'=6
@@ -0,0 +1,129 @@
// Generated from java-escape by ANTLR 4.11.1
import Antlr4
/**
* This interface defines a complete listener for a parse tree produced by
* {@link ReferenceParser}.
*/
public protocol ReferenceListener: ParseTreeListener {
/**
* Enter a parse tree produced by {@link ReferenceParser#root}.
- Parameters:
- ctx: the parse tree
*/
func enterRoot(_ ctx: ReferenceParser.RootContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#root}.
- Parameters:
- ctx: the parse tree
*/
func exitRoot(_ ctx: ReferenceParser.RootContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#host}.
- Parameters:
- ctx: the parse tree
*/
func enterHost(_ ctx: ReferenceParser.HostContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#host}.
- Parameters:
- ctx: the parse tree
*/
func exitHost(_ ctx: ReferenceParser.HostContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#port}.
- Parameters:
- ctx: the parse tree
*/
func enterPort(_ ctx: ReferenceParser.PortContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#port}.
- Parameters:
- ctx: the parse tree
*/
func exitPort(_ ctx: ReferenceParser.PortContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#host_component}.
- Parameters:
- ctx: the parse tree
*/
func enterHost_component(_ ctx: ReferenceParser.Host_componentContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#host_component}.
- Parameters:
- ctx: the parse tree
*/
func exitHost_component(_ ctx: ReferenceParser.Host_componentContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#namespace}.
- Parameters:
- ctx: the parse tree
*/
func enterNamespace(_ ctx: ReferenceParser.NamespaceContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#namespace}.
- Parameters:
- ctx: the parse tree
*/
func exitNamespace(_ ctx: ReferenceParser.NamespaceContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#namespace_component}.
- Parameters:
- ctx: the parse tree
*/
func enterNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#namespace_component}.
- Parameters:
- ctx: the parse tree
*/
func exitNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#reference}.
- Parameters:
- ctx: the parse tree
*/
func enterReference(_ ctx: ReferenceParser.ReferenceContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#reference}.
- Parameters:
- ctx: the parse tree
*/
func exitReference(_ ctx: ReferenceParser.ReferenceContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#tag}.
- Parameters:
- ctx: the parse tree
*/
func enterTag(_ ctx: ReferenceParser.TagContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#tag}.
- Parameters:
- ctx: the parse tree
*/
func exitTag(_ ctx: ReferenceParser.TagContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#separator}.
- Parameters:
- ctx: the parse tree
*/
func enterSeparator(_ ctx: ReferenceParser.SeparatorContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#separator}.
- Parameters:
- ctx: the parse tree
*/
func exitSeparator(_ ctx: ReferenceParser.SeparatorContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#name}.
- Parameters:
- ctx: the parse tree
*/
func enterName(_ ctx: ReferenceParser.NameContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#name}.
- Parameters:
- ctx: the parse tree
*/
func exitName(_ ctx: ReferenceParser.NameContext)
}
@@ -0,0 +1,799 @@
// Generated from java-escape by ANTLR 4.11.1
import Antlr4
open class ReferenceParser: Parser {
internal static var _decisionToDFA: [DFA] = {
var decisionToDFA = [DFA]()
let length = ReferenceParser._ATN.getNumberOfDecisions()
for i in 0..<length {
decisionToDFA.append(DFA(ReferenceParser._ATN.getDecisionState(i)!, i))
}
return decisionToDFA
}()
internal static let _sharedContextCache = PredictionContextCache()
public
enum Tokens: Int {
case EOF = -1, T__0 = 1, T__1 = 2, T__2 = 3, T__3 = 4, T__4 = 5, T__5 = 6,
DIGIT = 7, LETTER = 8
}
public
static let RULE_root = 0, RULE_host = 1, RULE_port = 2, RULE_host_component = 3,
RULE_namespace = 4, RULE_namespace_component = 5, RULE_reference = 6,
RULE_tag = 7, RULE_separator = 8, RULE_name = 9
public
static let ruleNames: [String] = [
"root", "host", "port", "host_component", "namespace", "namespace_component",
"reference", "tag", "separator", "name"
]
private static let _LITERAL_NAMES: [String?] = [
nil, "':'", "'/'", "'.'", "'-'", "'@'", "'_'"
]
private static let _SYMBOLIC_NAMES: [String?] = [
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
]
public
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
override open
func getGrammarFileName() -> String { return "java-escape" }
override open
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
override open
func getSerializedATN() -> [Int] { return ReferenceParser._serializedATN }
override open
func getATN() -> ATN { return ReferenceParser._ATN }
override open
func getVocabulary() -> Vocabulary {
return ReferenceParser.VOCABULARY
}
override public
init(_ input:TokenStream) throws {
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
try super.init(input)
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
}
public class RootContext: ParserRuleContext {
open
func host() -> HostContext? {
return getRuleContext(HostContext.self, 0)
}
open
func namespace() -> NamespaceContext? {
return getRuleContext(NamespaceContext.self, 0)
}
open
func EOF() -> TerminalNode? {
return getToken(ReferenceParser.Tokens.EOF.rawValue, 0)
}
open
func port() -> PortContext? {
return getRuleContext(PortContext.self, 0)
}
open
func reference() -> ReferenceContext? {
return getRuleContext(ReferenceContext.self, 0)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_root
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterRoot(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitRoot(self)
}
}
}
@discardableResult
open func root() throws -> RootContext {
var _localctx: RootContext
_localctx = RootContext(_ctx, getState())
try enterRule(_localctx, 0, ReferenceParser.RULE_root)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(20)
try host()
setState(23)
try _errHandler.sync(self)
_la = try _input.LA(1)
if (_la == ReferenceParser.Tokens.T__0.rawValue) {
setState(21)
try match(ReferenceParser.Tokens.T__0.rawValue)
setState(22)
try port()
}
setState(25)
try match(ReferenceParser.Tokens.T__1.rawValue)
setState(26)
try namespace()
setState(28)
try _errHandler.sync(self)
_la = try _input.LA(1)
if (_la == ReferenceParser.Tokens.T__0.rawValue || _la == ReferenceParser.Tokens.T__4.rawValue) {
setState(27)
try reference()
}
setState(30)
try match(ReferenceParser.Tokens.EOF.rawValue)
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class HostContext: ParserRuleContext {
open
func host_component() -> [Host_componentContext] {
return getRuleContexts(Host_componentContext.self)
}
open
func host_component(_ i: Int) -> Host_componentContext? {
return getRuleContext(Host_componentContext.self, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_host
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterHost(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitHost(self)
}
}
}
@discardableResult
open func host() throws -> HostContext {
var _localctx: HostContext
_localctx = HostContext(_ctx, getState())
try enterRule(_localctx, 2, ReferenceParser.RULE_host)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(32)
try host_component()
setState(37)
try _errHandler.sync(self)
_la = try _input.LA(1)
while (_la == ReferenceParser.Tokens.T__2.rawValue) {
setState(33)
try match(ReferenceParser.Tokens.T__2.rawValue)
setState(34)
try host_component()
setState(39)
try _errHandler.sync(self)
_la = try _input.LA(1)
}
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class PortContext: ParserRuleContext {
open
func DIGIT() -> [TerminalNode] {
return getTokens(ReferenceParser.Tokens.DIGIT.rawValue)
}
open
func DIGIT(_ i:Int) -> TerminalNode? {
return getToken(ReferenceParser.Tokens.DIGIT.rawValue, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_port
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterPort(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitPort(self)
}
}
}
@discardableResult
open func port() throws -> PortContext {
var _localctx: PortContext
_localctx = PortContext(_ctx, getState())
try enterRule(_localctx, 4, ReferenceParser.RULE_port)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(41)
try _errHandler.sync(self)
_la = try _input.LA(1)
repeat {
setState(40)
try match(ReferenceParser.Tokens.DIGIT.rawValue)
setState(43);
try _errHandler.sync(self)
_la = try _input.LA(1)
} while (_la == ReferenceParser.Tokens.DIGIT.rawValue)
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class Host_componentContext: ParserRuleContext {
open
func name() -> [NameContext] {
return getRuleContexts(NameContext.self)
}
open
func name(_ i: Int) -> NameContext? {
return getRuleContext(NameContext.self, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_host_component
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterHost_component(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitHost_component(self)
}
}
}
@discardableResult
open func host_component() throws -> Host_componentContext {
var _localctx: Host_componentContext
_localctx = Host_componentContext(_ctx, getState())
try enterRule(_localctx, 6, ReferenceParser.RULE_host_component)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(45)
try name()
setState(50)
try _errHandler.sync(self)
_la = try _input.LA(1)
while (_la == ReferenceParser.Tokens.T__3.rawValue) {
setState(46)
try match(ReferenceParser.Tokens.T__3.rawValue)
setState(47)
try name()
setState(52)
try _errHandler.sync(self)
_la = try _input.LA(1)
}
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class NamespaceContext: ParserRuleContext {
open
func namespace_component() -> [Namespace_componentContext] {
return getRuleContexts(Namespace_componentContext.self)
}
open
func namespace_component(_ i: Int) -> Namespace_componentContext? {
return getRuleContext(Namespace_componentContext.self, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_namespace
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterNamespace(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitNamespace(self)
}
}
}
@discardableResult
open func namespace() throws -> NamespaceContext {
var _localctx: NamespaceContext
_localctx = NamespaceContext(_ctx, getState())
try enterRule(_localctx, 8, ReferenceParser.RULE_namespace)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(53)
try namespace_component()
setState(58)
try _errHandler.sync(self)
_la = try _input.LA(1)
while (_la == ReferenceParser.Tokens.T__1.rawValue) {
setState(54)
try match(ReferenceParser.Tokens.T__1.rawValue)
setState(55)
try namespace_component()
setState(60)
try _errHandler.sync(self)
_la = try _input.LA(1)
}
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class Namespace_componentContext: ParserRuleContext {
open
func name() -> [NameContext] {
return getRuleContexts(NameContext.self)
}
open
func name(_ i: Int) -> NameContext? {
return getRuleContext(NameContext.self, i)
}
open
func separator() -> [SeparatorContext] {
return getRuleContexts(SeparatorContext.self)
}
open
func separator(_ i: Int) -> SeparatorContext? {
return getRuleContext(SeparatorContext.self, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_namespace_component
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterNamespace_component(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitNamespace_component(self)
}
}
}
@discardableResult
open func namespace_component() throws -> Namespace_componentContext {
var _localctx: Namespace_componentContext
_localctx = Namespace_componentContext(_ctx, getState())
try enterRule(_localctx, 10, ReferenceParser.RULE_namespace_component)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(65)
try _errHandler.sync(self)
_la = try _input.LA(1)
repeat {
setState(61)
try name()
setState(63)
try _errHandler.sync(self)
_la = try _input.LA(1)
if ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
setState(62)
try separator()
}
setState(67);
try _errHandler.sync(self)
_la = try _input.LA(1)
} while (_la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue)
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class ReferenceContext: ParserRuleContext {
open
func tag() -> TagContext? {
return getRuleContext(TagContext.self, 0)
}
open
func name() -> [NameContext] {
return getRuleContexts(NameContext.self)
}
open
func name(_ i: Int) -> NameContext? {
return getRuleContext(NameContext.self, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_reference
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterReference(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitReference(self)
}
}
}
@discardableResult
open func reference() throws -> ReferenceContext {
var _localctx: ReferenceContext
_localctx = ReferenceContext(_ctx, getState())
try enterRule(_localctx, 12, ReferenceParser.RULE_reference)
defer {
try! exitRule()
}
do {
setState(76)
try _errHandler.sync(self)
switch (ReferenceParser.Tokens(rawValue: try _input.LA(1))!) {
case .T__0:
try enterOuterAlt(_localctx, 1)
setState(69)
try match(ReferenceParser.Tokens.T__0.rawValue)
setState(70)
try tag()
break
case .T__4:
try enterOuterAlt(_localctx, 2)
setState(71)
try match(ReferenceParser.Tokens.T__4.rawValue)
setState(72)
try name()
setState(73)
try match(ReferenceParser.Tokens.T__0.rawValue)
setState(74)
try name()
break
default:
throw ANTLRException.recognition(e: NoViableAltException(self))
}
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class TagContext: ParserRuleContext {
open
func name() -> [NameContext] {
return getRuleContexts(NameContext.self)
}
open
func name(_ i: Int) -> NameContext? {
return getRuleContext(NameContext.self, i)
}
open
func separator() -> [SeparatorContext] {
return getRuleContexts(SeparatorContext.self)
}
open
func separator(_ i: Int) -> SeparatorContext? {
return getRuleContext(SeparatorContext.self, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_tag
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterTag(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitTag(self)
}
}
}
@discardableResult
open func tag() throws -> TagContext {
var _localctx: TagContext
_localctx = TagContext(_ctx, getState())
try enterRule(_localctx, 14, ReferenceParser.RULE_tag)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(78)
try name()
setState(84)
try _errHandler.sync(self)
_la = try _input.LA(1)
while ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
setState(79)
try separator()
setState(80)
try name()
setState(86)
try _errHandler.sync(self)
_la = try _input.LA(1)
}
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class SeparatorContext: ParserRuleContext {
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_separator
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterSeparator(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitSeparator(self)
}
}
}
@discardableResult
open func separator() throws -> SeparatorContext {
var _localctx: SeparatorContext
_localctx = SeparatorContext(_ctx, getState())
try enterRule(_localctx, 16, ReferenceParser.RULE_separator)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(87)
_la = try _input.LA(1)
if (!((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
try _errHandler.recoverInline(self)
}
else {
_errHandler.reportMatch(self)
try consume()
}
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class NameContext: ParserRuleContext {
open
func LETTER() -> [TerminalNode] {
return getTokens(ReferenceParser.Tokens.LETTER.rawValue)
}
open
func LETTER(_ i:Int) -> TerminalNode? {
return getToken(ReferenceParser.Tokens.LETTER.rawValue, i)
}
open
func DIGIT() -> [TerminalNode] {
return getTokens(ReferenceParser.Tokens.DIGIT.rawValue)
}
open
func DIGIT(_ i:Int) -> TerminalNode? {
return getToken(ReferenceParser.Tokens.DIGIT.rawValue, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_name
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterName(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitName(self)
}
}
}
@discardableResult
open func name() throws -> NameContext {
var _localctx: NameContext
_localctx = NameContext(_ctx, getState())
try enterRule(_localctx, 18, ReferenceParser.RULE_name)
var _la: Int = 0
defer {
try! exitRule()
}
do {
var _alt:Int
try enterOuterAlt(_localctx, 1)
setState(90);
try _errHandler.sync(self)
_alt = 1;
repeat {
switch (_alt) {
case 1:
setState(89)
_la = try _input.LA(1)
if (!(_la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue)) {
try _errHandler.recoverInline(self)
}
else {
_errHandler.reportMatch(self)
try consume()
}
break
default:
throw ANTLRException.recognition(e: NoViableAltException(self))
}
setState(92);
try _errHandler.sync(self)
_alt = try getInterpreter().adaptivePredict(_input,10,_ctx)
} while (_alt != 2 && _alt != ATN.INVALID_ALT_NUMBER)
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
static let _serializedATN:[Int] = [
4,1,8,95,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,
7,2,8,7,8,2,9,7,9,1,0,1,0,1,0,3,0,24,8,0,1,0,1,0,1,0,3,0,29,8,0,1,0,1,
0,1,1,1,1,1,1,5,1,36,8,1,10,1,12,1,39,9,1,1,2,4,2,42,8,2,11,2,12,2,43,
1,3,1,3,1,3,5,3,49,8,3,10,3,12,3,52,9,3,1,4,1,4,1,4,5,4,57,8,4,10,4,12,
4,60,9,4,1,5,1,5,3,5,64,8,5,4,5,66,8,5,11,5,12,5,67,1,6,1,6,1,6,1,6,1,
6,1,6,1,6,3,6,77,8,6,1,7,1,7,1,7,1,7,5,7,83,8,7,10,7,12,7,86,9,7,1,8,1,
8,1,9,4,9,91,8,9,11,9,12,9,92,1,9,0,0,10,0,2,4,6,8,10,12,14,16,18,0,2,
2,0,3,4,6,6,1,0,7,8,95,0,20,1,0,0,0,2,32,1,0,0,0,4,41,1,0,0,0,6,45,1,0,
0,0,8,53,1,0,0,0,10,65,1,0,0,0,12,76,1,0,0,0,14,78,1,0,0,0,16,87,1,0,0,
0,18,90,1,0,0,0,20,23,3,2,1,0,21,22,5,1,0,0,22,24,3,4,2,0,23,21,1,0,0,
0,23,24,1,0,0,0,24,25,1,0,0,0,25,26,5,2,0,0,26,28,3,8,4,0,27,29,3,12,6,
0,28,27,1,0,0,0,28,29,1,0,0,0,29,30,1,0,0,0,30,31,5,0,0,1,31,1,1,0,0,0,
32,37,3,6,3,0,33,34,5,3,0,0,34,36,3,6,3,0,35,33,1,0,0,0,36,39,1,0,0,0,
37,35,1,0,0,0,37,38,1,0,0,0,38,3,1,0,0,0,39,37,1,0,0,0,40,42,5,7,0,0,41,
40,1,0,0,0,42,43,1,0,0,0,43,41,1,0,0,0,43,44,1,0,0,0,44,5,1,0,0,0,45,50,
3,18,9,0,46,47,5,4,0,0,47,49,3,18,9,0,48,46,1,0,0,0,49,52,1,0,0,0,50,48,
1,0,0,0,50,51,1,0,0,0,51,7,1,0,0,0,52,50,1,0,0,0,53,58,3,10,5,0,54,55,
5,2,0,0,55,57,3,10,5,0,56,54,1,0,0,0,57,60,1,0,0,0,58,56,1,0,0,0,58,59,
1,0,0,0,59,9,1,0,0,0,60,58,1,0,0,0,61,63,3,18,9,0,62,64,3,16,8,0,63,62,
1,0,0,0,63,64,1,0,0,0,64,66,1,0,0,0,65,61,1,0,0,0,66,67,1,0,0,0,67,65,
1,0,0,0,67,68,1,0,0,0,68,11,1,0,0,0,69,70,5,1,0,0,70,77,3,14,7,0,71,72,
5,5,0,0,72,73,3,18,9,0,73,74,5,1,0,0,74,75,3,18,9,0,75,77,1,0,0,0,76,69,
1,0,0,0,76,71,1,0,0,0,77,13,1,0,0,0,78,84,3,18,9,0,79,80,3,16,8,0,80,81,
3,18,9,0,81,83,1,0,0,0,82,79,1,0,0,0,83,86,1,0,0,0,84,82,1,0,0,0,84,85,
1,0,0,0,85,15,1,0,0,0,86,84,1,0,0,0,87,88,7,0,0,0,88,17,1,0,0,0,89,91,
7,1,0,0,90,89,1,0,0,0,91,92,1,0,0,0,92,90,1,0,0,0,92,93,1,0,0,0,93,19,
1,0,0,0,11,23,28,37,43,50,58,63,67,76,84,92
]
public
static let _ATN = try! ATNDeserializer().deserialize(_serializedATN)
}
+5
View File
@@ -0,0 +1,5 @@
all: clean
antlr -o Generated -Dlanguage=Swift Reference.g4
clean:
rm -rf Generated
+14
View File
@@ -0,0 +1,14 @@
grammar Reference;
root: host (':' port)? '/' namespace reference? EOF;
host: host_component ('.' host_component)*;
port: DIGIT+;
host_component: name ('-' name)*;
namespace: namespace_component ('/' namespace_component)*;
namespace_component: (name separator?)+;
reference: (':' tag) | ('@' name ':' name);
tag: name (separator name)*;
separator: '.' | '-' | '_';
name: (LETTER | DIGIT)+;
DIGIT: [0-9];
LETTER: [A-Za-z];
+120 -106
View File
@@ -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)
} }
} }
+64 -42
View File
@@ -1,5 +1,5 @@
import Foundation import Foundation
import Parsing import Antlr4
struct Reference: Comparable, Hashable, CustomStringConvertible { struct Reference: Comparable, Hashable, CustomStringConvertible {
enum ReferenceType: Comparable { enum ReferenceType: Comparable {
@@ -46,6 +46,39 @@ struct Reference: Comparable, Hashable, CustomStringConvertible {
} }
} }
class ReferenceCollector: ReferenceBaseListener {
var host: String? = nil
var port: String? = nil
var namespace: String? = nil
var reference: String? = nil
override func exitHost(_ ctx: ReferenceParser.HostContext) {
host = ctx.getText()
}
override func exitPort(_ ctx: ReferenceParser.PortContext) {
port = ctx.getText()
}
override func exitNamespace(_ ctx: ReferenceParser.NamespaceContext) {
namespace = ctx.getText()
}
override func exitReference(_ ctx: ReferenceParser.ReferenceContext) {
reference = ctx.getText()
}
}
class ErrorCollector: BaseErrorListener {
var error: String? = nil
override func syntaxError<T>(_ recognizer: Recognizer<T>, _ offendingSymbol: AnyObject?, _ line: Int, _ charPositionInLine: Int, _ msg: String, _ e: AnyObject?) {
if error == nil {
error = "\(msg) (character \(charPositionInLine + 1))"
}
}
}
struct RemoteName: Comparable, Hashable, CustomStringConvertible { struct RemoteName: Comparable, Hashable, CustomStringConvertible {
var host: String var host: String
var namespace: String var namespace: String
@@ -58,51 +91,40 @@ struct RemoteName: Comparable, Hashable, CustomStringConvertible {
} }
init(_ name: String) throws { init(_ name: String) throws {
let csNormal = [ let errorCollector = ErrorCollector()
UInt8(ascii: "a")...UInt8(ascii: "z"), let inputStream = ANTLRInputStream(Array(name.unicodeScalars), name.count)
UInt8(ascii: "A")...UInt8(ascii: "Z"), let lexer = ReferenceLexer(inputStream)
UInt8(ascii: "0")...UInt8(ascii: "9"), lexer.removeErrorListeners()
].asCharacterSet().union(CharacterSet(charactersIn: "_-.")) lexer.addErrorListener(errorCollector)
let csHex = [ let tokenStream = CommonTokenStream(lexer)
UInt8(ascii: "a")...UInt8(ascii: "f"), let parser = try ReferenceParser(tokenStream)
UInt8(ascii: "0")...UInt8(ascii: "9"), parser.removeErrorListeners()
].asCharacterSet() parser.addErrorListener(errorCollector)
let parser = Parse { let referenceCollector = ReferenceCollector()
Consumed { try ParseTreeWalker().walk(referenceCollector, try parser.root())
csNormal
Optionally { if let error = errorCollector.error {
":" throw RuntimeError.FailedToParseRemoteName("\(error)")
Digits()
}
}
"/"
csNormal.union(CharacterSet(charactersIn: "/"))
Optionally {
OneOf {
Parse {
":"
csNormal.map {
Reference(tag: String($0))
}
}
Parse {
"@sha256:"
csHex.map {
Reference(digest: "sha256:" + String($0))
}
}
}
}
End()
} }
let result = try parser.parse(name) host = referenceCollector.host!
if let port = referenceCollector.port {
host = String(result.0) host += ":" + port
namespace = String(result.1) }
reference = result.2 ?? Reference(tag: "latest") namespace = referenceCollector.namespace!
if let reference = referenceCollector.reference {
if reference.starts(with: "@sha256:") {
self.reference = Reference(digest: String(reference.dropFirst(1)))
} else if reference.starts(with: ":") {
self.reference = Reference(tag: String(reference.dropFirst(1)))
} else {
throw RuntimeError.FailedToParseRemoteName("unknown reference format")
}
} else {
self.reference = Reference(tag: "latest")
}
} }
static func <(lhs: RemoteName, rhs: RemoteName) -> Bool { static func <(lhs: RemoteName, rhs: RemoteName) -> Bool {
+52
View File
@@ -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)))]
} }
} }
+4 -4
View File
@@ -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",
+7 -7
View File
@@ -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
} }
+87 -87
View File
@@ -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()]
}
} }
}
} }
+24 -24
View File
@@ -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()]
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import Virtualization import Virtualization
enum OS: String, Codable { enum OS: String, Codable {
case darwin case darwin
case linux case linux
} }
+5 -5
View File
@@ -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]
} }
+1
View File
@@ -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
+74 -1
View File
@@ -1,5 +1,15 @@
import ArgumentParser import ArgumentParser
import Foundation import Foundation
import Puppy
import Sentry
var puppy = Puppy.default
class LogFormatter: LogFormattable {
func formatMessage(_ level: LogLevel, message: String, tag: String, function: String, file: String, line: UInt, swiftLogInfo: [String: String], label: String, date: Date, threadID: UInt64) -> String {
"\(date) \(level) \(message)"
}
}
@main @main
struct Root: AsyncParsableCommand { struct Root: AsyncParsableCommand {
@@ -11,6 +21,7 @@ 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,
@@ -18,10 +29,40 @@ struct Root: AsyncParsableCommand {
Push.self, Push.self,
Prune.self, Prune.self,
Rename.self, Rename.self,
Stop.self,
Delete.self, Delete.self,
]) ])
public static func main() async throws { public static func main() async throws {
// Initialize Sentry
if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
SentrySDK.start { options in
options.dsn = dsn
options.releaseName = CI.release
options.tracesSampleRate = Float(
ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
) as NSNumber?
// By default only 5XX are captured
// Let's capture everything but 401 (unauthorized)
options.enableCaptureFailedRequests = true
options.failedRequestStatusCodes = [
HttpStatusCodeRange(min: 400, max: 400),
HttpStatusCodeRange(min: 402, max: 599)
]
}
}
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
// Enrich future events with Cirrus CI-specific tags
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
SentrySDK.configureScope { scope in
for (key, value) in tags.split(separator: ",").compactMap({ parseCirrusSentryTag($0) }) {
scope.setTag(value: value, key: key)
}
}
}
// 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);
@@ -33,6 +74,15 @@ struct Root: AsyncParsableCommand {
} }
sigintSrc.activate() sigintSrc.activate()
// Set line-buffered output for stdout
setlinebuf(stdout)
// Initialize file logger
let logFileURL = try Config().tartHomeDir.appendingPathComponent("tart.log")
let fileLogger = try FileLogger("org.cirruslabs.tart", fileURL: logFileURL)
fileLogger.format = LogFormatter()
puppy.add(fileLogger)
// Parse and run command // Parse and run command
do { do {
var command = try parseAsRoot() var command = try parseAsRoot()
@@ -46,7 +96,30 @@ struct Root: AsyncParsableCommand {
try command.run() try command.run()
} }
} catch { } catch {
exit(withError: error) if exitCode(for: error).rawValue == 0 {
exit(withError: error)
}
// Capture the error into Sentry
SentrySDK.capture(error: error)
SentrySDK.flush(timeout: 2.seconds.timeInterval)
print(error)
if let errorWithExitCode = error as? HasExitCode {
Foundation.exit(errorWithExitCode.exitCode)
}
Foundation.exit(1)
} }
} }
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 -1
View File
@@ -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())")
} }
} }
} }
+4
View File
@@ -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)
} }
+1 -1
View File
@@ -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
+88 -70
View File
@@ -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,7 +31,7 @@ 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
@@ -39,7 +40,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
init(vmDir: VMDirectory, init(vmDir: VMDirectory,
network: Network = NetworkShared(), 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)
@@ -51,70 +52,98 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Initialize the virtual machine and its configuration // Initialize the virtual machine and its configuration
self.network = network self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
nvramURL: vmDir.nvramURL, vmConfig: config, nvramURL: vmDir.nvramURL, vmConfig: config,
network: network, 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,
network: Network = NetworkShared(), 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
@@ -148,9 +177,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Initialize the virtual machine and its configuration // Initialize the virtual machine and its configuration
self.network = network self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
vmConfig: config, network: network, vmConfig: config, network: network,
additionalDiskAttachments: additionalDiskAttachments, additionalDiskAttachments: additionalDiskAttachments,
directoryShares: []) directorySharingDevices: directorySharingDevices
)
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
super.init() super.init()
@@ -158,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...")
@@ -187,9 +217,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
func run(_ recovery: Bool) async throws { func run(_ recovery: Bool) async throws {
try network.run() try network.run(sema)
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
@@ -203,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()
@@ -217,7 +252,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
} }
try network.stop() try await network.stop()
} }
static func craftConfiguration( static func craftConfiguration(
@@ -226,7 +261,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
vmConfig: VMConfig, vmConfig: VMConfig,
network: Network = NetworkShared(), network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment], additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
directoryShares: [DirectoryShare] directorySharingDevices: [VZDirectorySharingDeviceConfiguration]
) throws -> VZVirtualMachineConfiguration { ) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration() let configuration = VZVirtualMachineConfiguration()
@@ -270,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()
@@ -295,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()
} }
@@ -304,9 +328,3 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
sema.signal() sema.signal()
} }
} }
struct DirectoryShare {
let name: String
let path: URL
let readOnly: Bool
}
+16 -10
View File
@@ -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
+15 -14
View File
@@ -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
+11 -8
View File
@@ -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,6 +22,10 @@ struct VMDirectory: Prunable {
baseURL.lastPathComponent baseURL.lastPathComponent
} }
var url: URL {
baseURL
}
static func temporary() throws -> VMDirectory { static func temporary() throws -> VMDirectory {
let tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString) let tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false) try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
@@ -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))")
} }
} }
+70 -8
View File
@@ -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,76 @@ 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)
}
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
}
}
}
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,
]
} }
} }
+24 -9
View File
@@ -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,6 +149,7 @@ 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 // Lock the temporary VM directory to prevent it's garbage collection
@@ -159,11 +161,22 @@ class VMStorageOCI: PrunableStorage {
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024) let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey]) let attrs = try Config().tartCacheDir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey])
let availableCapacityBytes = max(UInt64(attrs.volumeAvailableCapacityForImportantUsage!), UInt64(attrs.volumeAvailableCapacity!)) let capacityImportant = attrs.volumeAvailableCapacityForImportantUsage!
let capacityAvailable = attrs.volumeAvailableCapacity!
let availableCapacityBytes = max(UInt64(capacityImportant), UInt64(capacityAvailable))
if capacityImportant == 0 || capacityAvailable == 0 {
puppy.warning("important capacity \(capacityImportant) bytes, "
+ "available capacity is \(capacityAvailable) bytes")
}
// There is a suspicious that occasionally capacity is returned as zero which can't be true. // There is a suspicious that occasionally capacity is returned as zero which can't be true.
// Let's validate to avoid unnecessary pruning. // Let's validate to avoid unnecessary pruning.
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes { if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
puppy.info("pruning cache to accommodate \(name) with a disk of size \(uncompressedDiskSize) bytes ("
+ "available capacity is \(availableCapacityBytes) bytes, required capacity "
+ "is \(requiredCapacityBytes) bytes)")
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes) try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
} }
} }
@@ -171,7 +184,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 {
@@ -180,7 +195,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
@@ -189,11 +204,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()
} }
+26 -26
View File
@@ -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()
}
} }
+22 -22
View File
@@ -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
} }
} }
+17 -17
View File
@@ -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")!))
} }
} }
+72 -72
View File
@@ -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)
}
} }
+6 -2
View File
@@ -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 {
@@ -44,4 +44,8 @@ final class RemoteNameTests: XCTestCase {
// Port must be specified when ":" is used // Port must be specified when ":" is used
XCTAssertEqual(try? RemoteName("127.0.0.1:/a/b").host, nil) XCTAssertEqual(try? RemoteName("127.0.0.1:/a/b").host, nil)
} }
func testNoPathTraversal() throws {
XCTAssertEqual(try? RemoteName("ghcr.io/a/../b/c:latest"), nil)
}
} }
+2 -2
View File
@@ -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")
} }
+34 -34
View File
@@ -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)
} }
} }
+16
View File
@@ -0,0 +1,16 @@
import pytest
from tart import Tart
from docker_registry import DockerRegistry
@pytest.fixture(scope="class")
def tart():
with Tart() as tart:
yield tart
@pytest.fixture(scope="class")
def docker_registry():
with DockerRegistry() as docker_registry:
yield docker_registry
+20
View File
@@ -0,0 +1,20 @@
import requests
from testcontainers.core.waiting_utils import wait_container_is_ready
from testcontainers.general import DockerContainer
class DockerRegistry(DockerContainer):
_default_exposed_port = 5000
def __init__(self):
super().__init__("registry:2")
self.with_exposed_ports(self._default_exposed_port)
@wait_container_is_ready(requests.exceptions.ConnectionError)
def remote_name(self, for_vm: str):
exposed_port = self.get_exposed_port(self._default_exposed_port)
requests.get(f"http://127.0.0.1:{exposed_port}/v2/")
return f"127.0.0.1:{exposed_port}/tart/{for_vm}:latest"
+5
View File
@@ -0,0 +1,5 @@
pytest
testcontainers
requests
bitmath
pytest-dependency
+33
View File
@@ -0,0 +1,33 @@
import tempfile
import os
import subprocess
class Tart:
def __init__(self):
self.tmp_dir = tempfile.TemporaryDirectory(dir=os.environ.get("CIRRUS_WORKING_DIR"))
# Link to the users IPSW cache to make things faster
src = os.path.join(os.path.expanduser("~"), ".tart", "cache", "IPSWs")
dst = os.path.join(self.tmp_dir.name, "cache", "IPSWs")
os.makedirs(os.path.join(self.tmp_dir.name, "cache"))
os.symlink(src, dst)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.tmp_dir.cleanup()
def home(self) -> str:
return self.tmp_dir.name
def run(self, args):
env = os.environ.copy()
env.update({"TART_HOME": self.tmp_dir.name})
completed_process = subprocess.run(["tart"] + args, env=env, capture_output=True)
completed_process.check_returncode()
return completed_process.stdout.decode("utf-8"), completed_process.stderr.decode("utf-8")
+10
View File
@@ -0,0 +1,10 @@
def test_clone(tart):
# Create a Linux VM (because we can create it really fast)
tart.run(["create", "--linux", "debian"])
# Clone the VM
tart.run(["clone", "debian", "ubuntu"])
# Ensure that we have now 2 VMs
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == "debian\nubuntu\n"
+16
View File
@@ -0,0 +1,16 @@
def test_create_macos(tart):
# Create a macOS VM
tart.run(["create", "--from-ipsw", "latest", "macos-vm"])
# Ensure that the VM was created
stdout, _ = tart.run(["list", "--quiet"])
assert stdout == "macos-vm\n"
def test_create_linux(tart):
# Create a Linux VM
tart.run(["create", "--linux", "linux-vm"])
# Ensure that the VM was created
stdout, _ = tart.run(["list", "--quiet"])
assert stdout == "linux-vm\n"
+14
View File
@@ -0,0 +1,14 @@
def test_delete(tart):
# Create a Linux VM (because we can create it really fast)
tart.run(["create", "--linux", "debian"])
# Ensure that the VM exists
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == "debian\n"
# Delete the VM
tart.run(["delete", "debian"])
# Ensure that the VM was removed
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == ""
+55
View File
@@ -0,0 +1,55 @@
import os
import tempfile
import timeit
import uuid
import bitmath
import pytest
amount_to_transfer = bitmath.GB(1)
minimal_speed_per_second = bitmath.Mb(100)
class TestOCI:
@pytest.mark.dependency()
def test_push_speed(self, tart, vm_with_random_disk, docker_registry):
start = timeit.default_timer()
tart.run(["push", "--insecure", vm_with_random_disk, docker_registry.remote_name(vm_with_random_disk)])
stop = timeit.default_timer()
actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start)
assert actual_speed_per_second > minimal_speed_per_second
@pytest.mark.dependency(depends=["TestOCI::test_push_speed"])
def test_pull_speed(self, tart, vm_with_random_disk, docker_registry):
start = timeit.default_timer()
tart.run(["pull", "--insecure", docker_registry.remote_name(vm_with_random_disk)])
stop = timeit.default_timer()
actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start)
assert actual_speed_per_second > minimal_speed_per_second
@staticmethod
def _calculate_speed_per_second(amount_transferred, time_taken):
return (amount_transferred / time_taken).best_prefix(bitmath.SI)
@pytest.fixture(scope="class")
def vm_with_random_disk(tart):
vm_name = str(uuid.uuid4())
# Create a VM (Linux for speed's sake)
tart.run(["create", "--linux", vm_name])
# Populate VM's disk with "amount_to_transfer" of random bytes
# to effectively disable Tart's OCI blob compression
disk_path = os.path.join(tart.home(), "vms", vm_name, "disk.img")
with tempfile.NamedTemporaryFile(delete=False) as tf:
tf.write(os.urandom(amount_to_transfer.bytes))
tf.close()
os.rename(tf.name, disk_path)
yield vm_name
tart.run(["delete", vm_name])
+10
View File
@@ -0,0 +1,10 @@
def test_rename(tart):
# Create a Linux VM (because we can create it really fast)
tart.run(["create", "--linux", "debian"])
# Rename that VM
tart.run(["rename", "debian", "ubuntu"])
# Ensure that the VM is now named "ubuntu"
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == "ubuntu\n"