Compare commits

...
120 Commits
Author SHA1 Message Date
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
Nikolay Edigaryev 8cd68ea8ef Revert com.apple.vm.networking entitlement (#247) 2022-09-14 11:01:16 -04:00
Fedor Korotkov f9001304c8 Fixed packaging 2022-09-14 10:06:22 -04:00
Nikolay Edigaryev 4e20ea8f72 tart run: introduce --net-bridged (#245)
* tart run: introduce --net-bridged

* tart.entitlements: add com.apple.vm.networking
2022-09-14 17:53:04 +04:00
Nikolay EdigaryevandPete Goldsmith 678ce0a55a Introduce "tart rename" command to rename VMs (#246)
* Introduce "tart rename" command to rename VMs

* Remove unused SystemConfiguration import

* Update Sources/tart/Commands/Rename.swift

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

Co-authored-by: Pete Goldsmith <peter.n.goldsmith@gmail.com>
2022-09-14 17:44:26 +04:00
Fedor Korotkov 8273ae66e1 Update Developer Certificates (#242)
* Update Developer Certificates

Now the signature will state `Cirrus Labs, Inc.` and not `Fedor Korotkov`.

* Updated identity
2022-09-13 09:36:35 -04:00
Fedor Korotkov 4a9316a377 [skip ci] Add sponsorship option (#241) 2022-09-13 16:30:26 +04:00
Fedor Korotkov 6321d547cf Improve available capacity checking (#240)
* Improve available capacity checking

* Fixed expression
2022-09-12 21:55:25 +04:00
Nikolay Edigaryev f241e21614 Self-hosted temporary directory (#238) 2022-09-12 20:56:52 +04:00
Nikolay Edigaryev 0eca923604 Document NAT subnet change procedure (#236) 2022-09-08 16:26:40 +04:00
Pete Goldsmith 87f29cc11f Clarify requirements for dir argument (#231) 2022-09-07 09:02:46 -04:00
Pete Goldsmith 90d1393137 Handle tilde in path for directory share (#233)
* Expand Tilde in path

* Expand tilde in disk paths
2022-09-07 08:15:29 -04:00
Nikolay Edigaryev 625d431d10 Revert "Improve RemoteName parser (#225)" (#230)
This reverts commit ae7018c31f.
2022-09-06 22:13:30 +04:00
Nikolay Edigaryev 4648e1aea1 tart clone: clone VM and generate MAC under a file lock (#215)
* tart clone: clone VM and generate MAC under a file lock

* Lock concurrent "tart pull"'s for the same host

* Config: ensure Tart's home and cache directories always exist
2022-09-06 21:33:51 +04:00
Fedor Korotkov 4b62b73015 Document Adopters (#229)
* Document early adopters

Related to #208

* Use HTML
2022-09-06 21:25:22 +04:00
Nikolay Edigaryev ae7018c31f Improve RemoteName parser (#225)
* Improve RemoteName parser

* Remove Parsing import

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

* Add testNoPathTraversal
2022-09-06 17:28:49 +04:00
Pete Goldsmith e54c89da0c Clarify if guest or host is unsupported (#228) 2022-09-06 09:23:41 -04:00
Nikolay Edigaryev 9a0ec3e6b0 Fix cache reclaimed bytes calculation (#223) 2022-09-01 16:12:48 -04:00
Fedor Korotkov 400f85a493 Check Tart Home for auto-pruning (#220)
Otherwise it's weird we check $TMP but prune $TART_HOME
2022-09-01 15:44:19 -04:00
Fedor Korotkov 0105280b5d Support plaintext auths from Docker config (#219) 2022-09-01 23:10:52 +04:00
Fedor Korotkov c063c5bdc2 Include version in installer (#218) 2022-09-01 19:42:50 +04:00
Fedor Korotkov 8a2b0151e0 Create Apple Installer in dist folder (#217)
Seems goreleaser reset git state before submitting and therefore removes Tart.pkg
2022-09-01 18:04:43 +04:00
Fedor Korotkov 2eea51d6ec Create Apple Installer (#216)
The installer will install tart binary to `/usr/local/bin/tart`

Fixes #153
2022-08-31 18:36:28 -04:00
Fedor Korotkov 1890909d28 Sign release binaries (#207)
* Sign release binaries

Should fix #184

* Use VMs for building

* Test release

* Fixed password

* Revert testing
2022-08-30 16:26:06 -04:00
Nikolay Edigaryev 0cad2e454c Directory sharing support (#211) 2022-08-30 10:26:16 +04:00
Nikolay Edigaryev 17dcf942c1 Terminate VM on GUI window close (#210) 2022-08-30 02:41:58 +04:00
Nikolay Edigaryev 6296df7c0c Credential helpers: "credHelpers" map is optional in Docker's config (#209) 2022-08-29 16:38:51 -04:00
Fedor Korotkov c54b140750 Support Docker Helpers (#205)
Fixes #167
2022-08-29 20:26:53 +04:00
Fedor Korotkov 048a5506df Support trackpad on macOS and clipboard sharing on Linux (#202)
Fixes #66
Relates to #14 since fixes on Linux
2022-08-27 20:05:45 +04:00
Nikolay Edigaryev 553b36349b README.md: clarify "Pulling a Remote Image" section (#196) 2022-08-25 15:43:30 +04:00
Fedor Korotkov d0bf286aa1 Document Linux VM image creation (#193) 2022-08-24 23:21:50 +04:00
Nikolay Edigaryev 195a4b3a72 Make UnsupportedOSError more user-friendly (#192)
* Make UnsupportedOSError more user-friendly

* tart prune: allow only specifying a --gc flag
2022-08-24 15:03:54 -04:00
Nikolay Edigaryev 59393df2e1 Linux support (#190)
* Linux support

* Support macOS 12

* Improve readability a bit

* Remove useless variable
2022-08-23 22:38:50 +04:00
Nikolay Edigaryev 732c8244a4 Run garbage collection after each symlink and expose --gc in tart prune (#191) 2022-08-23 17:31:02 +04:00
Nikolay Edigaryev 9e69c8c161 testGetAndSetAccessTime: fix flakiness (#187) 2022-08-18 14:42:54 -04:00
Nikolay Edigaryev e4ac2275b9 tart ip: show a warning when DHCP lease and ARP cache entries mismatch (#186) 2022-08-18 22:25:02 +04:00
Nikolay Edigaryev 1a1f19e169 Implement garbage collection when deleting cached OCI VMs (#185)
* Properly calculate remotely-retrieved manifest digests

* Implement garbage collection when deleting cached OCI VMs
2022-08-18 17:13:06 +04:00
Nikolay Edigaryev fea916dacc OCI blob compression: fix Data memory leak when using InputFilter (#183)
* OCI blob compression: fix Data memory leak when using InputFilter

* Rename mappedDiskOffset to mappedDiskReadOffset

* Update progress.completedUnitCount differently
2022-08-17 12:57:39 -04:00
Fedor Korotkov b1be9730c7 Build and release from Ventrura (#182)
Might be related to #180
2022-08-17 17:42:24 +04:00
Nikolay EdigaryevandNikolay Edigaryev 14059a1d6f tart {pull,clone}: add missing --insecure support (#181)
Co-authored-by: Nikolay Edigaryev <edi@microsun.local>
2022-08-17 01:31:19 +04:00
Nikolay Edigaryev 440681320a Introduce "tart prune" command (#164) 2022-08-12 16:43:04 +03:00
Fedor KorotkovandNikolay Edigaryev a80954c888 Do not include Content-Range for monolithic uploads (#179)
* Do not include Content-Range for monolithic uploads

Some registries still assumes it's a chunked upload and verifies the "chunk" size which is too big.

* Update Sources/tart/OCI/Registry.swift

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

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2022-08-12 16:33:15 +03:00
Nikolay Edigaryev cc8201dee6 OCI: support insecure registries and custom ports (#174) 2022-08-12 16:24:14 +03:00
Fedor Korotkov 2cab49b3f1 Use Ventura APIs for recovery mode (#175) 2022-08-12 08:02:44 +03:00
Fedor Korotkov 131827802a Friendly error message on Intel (#177)
In case someone tries to install Tart. Fixes #176
2022-08-10 11:13:21 -04:00
Fedor Korotkov e2b7f12388 Support chunked uploads (#159)
* Support chunked uploads

* Rebase fixes

* Bump swift http client
2022-08-08 10:16:47 -04:00
Fedor Korotkov 617a5d02dc Fixed potential infinity auth (#173)
Follow up to an attempt in #169
2022-08-08 16:50:13 +03:00
Nikolay Edigaryev b83bce4544 tart login: read whole stdin contents instead of just a line (#170) 2022-08-02 16:52:35 +03:00
Nikolay Edigaryev 7d16516b6a OCI: gcr.io fixes (#169)
* Make sure we don't craft an URLComponents with an empty query items

* Prevent auth() infinite loop

* OCI: ignore invalid RFC 3339 formatted dates
2022-08-01 19:24:11 -04:00
Nikolay Edigaryev 56ddd8df75 OCI's HTTP client: prevent the usage of NIO Transport Services (#168) 2022-08-01 18:17:50 -04:00
Fedor Korotkov b1534a05d5 Configure audio devices to pass through the signal from/to the host (#163)
Fixes #161
2022-08-01 17:54:55 +03:00
Nikolay Edigaryev f54e7c2cab tart login: only store one credential per registry (#162) 2022-08-01 09:55:37 +03:00
Nikolay Edigaryev 85dfa961c9 tart run: introduce --disk option (#156)
* tart run: introduce --disk option

* Document how to create disks using Disk Utility
2022-07-26 09:25:23 -04:00
Nikolay Edigaryev 3a74fc35e6 Introduce TART_HOME (#157) 2022-07-25 17:01:46 +03:00
Nikolay Edigaryev 7bf7f890c4 tart run: return exit status 2 when VM is already running (#155) 2022-07-21 07:53:47 -04:00
Nikolay Edigaryev 48cd4b47e4 Move full-fledged VNC support to --experimental-vnc (#154) 2022-07-21 12:37:43 +03:00
Nikolay Edigaryev c1dee4f9b2 Credentials: update Keychain entry if it already exists (#149) 2022-07-13 12:35:44 -04:00
Fedor Korotkov 116dc01f55 Document how to retrieve artifacts (#146) 2022-07-08 18:45:14 +03:00
Nikolay Edigaryev 52abb7589c OCI: support Basic authentication scheme (#145)
* OCI: support Basic authentication scheme

* .isValid → .isValid()

* tart login: make --username optional
2022-07-08 16:36:00 +03:00
Nikolay Edigaryev 4386192161 tart login: introduce --username and --password-stdin flags (#143) 2022-07-05 16:32:20 +03:00
Nikolay Edigaryev 85429cea0a Retrieve IP from DHCPD leases file instead of ARP cache (#141)
* Retrieve IP from DHCPD leases file instead of ARP cache

* Reference PLCache_read() from the retrieveRawLeases() parsing function
2022-06-30 17:58:52 +03:00
Nikolay Edigaryev 384abcd0bd OCI: make sure annotations are sorted (#138) 2022-06-27 16:25:52 +03:00
Fedor Korotkov 92529afa23 Handle tart run --no-graphics --vnc properly (#137)
Let's start a VNC server but not force open Screen Sharing if `--no-graphics` is also passed.
2022-06-24 22:54:41 +03:00
Nikolay Edigaryev c25364b2d4 Homebrew: depend on Softnet package (#136) 2022-06-21 14:55:51 -04:00
Nikolay Edigaryev e12f95878e Softnet: an alternative to built-in NAT with better isolation (#48)
* Softnet: an alternative to built-in NAT with better isolation

* Softnet: increase socketpair(2) socket buffer sizes to 1 MiB

* Pass VM's FD and MAC address to the Softnet

* Softnet: implement graceful shutdown

* Bring back the dispatchMain() and task cancellation

* tart pull: check for cancellation when pulling response body

* Don't dispatchMain() in withTaskCancellationHandler()

* Move VNC URL opening logic into VNCWrapper.open()
2022-06-21 19:17:02 +03:00
Fedor Korotkov 7efef28250 Allow VNC for Recovery mode (#134) 2022-06-20 20:08:31 +03:00
Nikolay Edigaryev 1e90752ded Full-fledged VNC support (#126) 2022-06-20 18:53:17 +03:00
Fedor Korotkov 2020324ef5 Fixed --no-graphics (#132)
Followup to #129 which broke CLI integration because `--no-graphics` option didn't wait for anything.
2022-06-20 10:28:10 -04:00
Nikolay Edigaryev b581db5be4 OCI: make annotations optional (#130)
* OCI: make annotations optional

* Don't initialize annotations by default
2022-06-17 10:53:52 -04:00
Fedor Korotkov 8ed2ce159f Install Go 2022-06-17 10:33:32 -04:00
Fedor Korotkov 89217c23a2 Fixes for Ventura (#129)
* Fixes for Ventura

Still a noob in SwiftUI and Swift concurrency, but it seems on Ventura a task group is not actually running on main or something. Either way I think this change simplifies things but launching a VM in a task and then just continuing with either VNC or built-in graphics.

* Check VM's state
2022-06-17 10:04:57 -04:00
Fedor Korotkov 0d633de04a Build Tart inside a Tart VM (#127) 2022-06-17 11:18:26 +03:00
Nikolay Edigaryev f59ef2722d OCI: store uncompressed disk size in manifest (#128) 2022-06-17 11:14:28 +03:00
Fedor Korotkov 7759c72a76 Don't use dispatchMain (#123) 2022-06-14 11:31:31 -04:00
Raymond 4cc8a9925a accept larger disk size (#122) 2022-06-10 09:05:57 -04:00
Nikolay Edigaryev b16bbf587a Registry: log upload location when encountering blob pushing error (#121) 2022-06-08 17:23:16 +03:00
Fedor Korotkov 022317bc17 Build Debug Binary (#117)
* Build Debug Binary

Xcode 14 Beta breaks something when building with a production configuration and Tart can't run a VM.

* Fixed path
2022-06-07 18:14:52 +03:00
Fedor Korotkov 87733290e7 Smaller icon (#116)
Initially it wasn't fully following the [guidelines](https://developer.apple.com/design/human-interface-guidelines/macos/icons-and-images/app-icon/).

Fixes #112
2022-06-06 18:38:30 +03:00
Fedor Korotkov c14b46adc3 Make sure VM directory exists before moving (#111) 2022-06-03 23:22:11 +03:00
Nikolay Edigaryev 63329ef363 Atomic tart {create,clone,pull} operations using rename(2) (#109)
* tart clone: always re-generate MAC-address

This is not really an issue for non-enterprise users[1].

[1]: https://github.com/cirruslabs/tart/issues/20#issuecomment-1136944455

* Atomic tart {create,clone,pull} operations using rename(2)

* Print a nicer error message when attempting to double-run a VM

* tart clone: bring back the old MAC-address generation logic

* Ensure VMDirectory.temporary() will be deleted on failure
2022-06-01 11:02:31 -04:00
Nikolay EdigaryevandFedor Korotkov 5446164a36 tart pull: introduce --populate-cache flag (#103)
* tart pull: introduce --populate-cache flag

* VMStorageOCI: introduce cache() method

* Review comments (#107)

* Rename SetCommand back to Set

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2022-05-28 23:20:24 -04:00
Nikolay Edigaryev f3068b9055 tart login: verify credentials (#102)
* tart login: verify credentials

* Make DictionaryCredentialsProvider fileprivate to Login.swift
2022-05-26 09:31:46 -04:00
Fedor Korotkov afa6b7b46c Auto-detect screen DPI (#106)
* Auto-detect screen DPI

Fixes #104

* Added a comment
2022-05-26 12:23:13 +03:00
Fedor Korotkov d277fb2941 Added helpfull messages to run command (#100) 2022-05-23 11:29:35 -04:00
Fedor Korotkov 088cdc51a3 Option to run with VNC (#97)
* Option to run with VNC

So copy/paste is working. For example, `tart run --vnc latest`.

Related to 14

* Add VNC task first

* Indicate the wait
2022-05-23 16:49:15 +03:00
Fedor Korotkov afb23a3e3a Init HTTP client only if Registry is used (#99) 2022-05-21 00:26:43 +03:00
102 changed files with 6733 additions and 772 deletions
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -e
export VERSION="${CIRRUS_TAG:-0}"
mkdir -p .ci/pkg/
cp .build/arm64-apple-macosx/debug/tart .ci/pkg/
pkgbuild --root .ci/pkg --version $VERSION --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Cirrus Labs, Inc. (9M2P8L4D89)" "./dist/Tart-$VERSION.pkg"
xcrun notarytool submit "./dist/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
xcrun stapler staple "./dist/Tart-$VERSION.pkg"
+48 -6
View File
@@ -1,14 +1,33 @@
persistent_worker:
labels:
name: Mac-Mini-M1
task:
name: Test
test_script: swift test
name: Test on Ventura
alias: test
persistent_worker:
labels:
name: scaleway-m1
test_script:
- 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: Build
alias: build
only_if: $CIRRUS_TAG == ''
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
build_script: swift build --product tart
sign_script: codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
binary_artifacts:
@@ -17,7 +36,30 @@ task:
task:
name: Release
only_if: $CIRRUS_TAG != ''
depends_on:
- test
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
setup_script:
- cd $HOME
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
- security create-keychain -p password101 build.keychain
- security default-keychain -s build.keychain
- security unlock-keychain -p password101 build.keychain
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
install_script:
- brew install go goreleaser/tap/goreleaser-pro
- brew install mitchellh/gon/gon
info_script:
- security find-identity -v
- xcodebuild -version
- swift -version
release_script: goreleaser
+1
View File
@@ -0,0 +1 @@
* @edigaryev @fkorotkov
+1
View File
@@ -0,0 +1 @@
github: [cirruslabs]
+17 -4
View File
@@ -7,13 +7,16 @@ builds:
goarch:
- arm64
prebuilt:
path: .build/{{ .Arch }}-apple-macosx/release/tart
path: .build/arm64-apple-macosx/debug/tart
hooks:
post:
- gon gon.hcl
- .ci/create-pkg.sh
before:
hooks:
- .ci/set-version.sh
- swift build -c release --product tart
- codesign --sign - --entitlements Resources/tart.entitlements --force .build/arm64-apple-macosx/release/tart
- swift build -c debug --product tart
archives:
- id: binary
@@ -24,6 +27,8 @@ archives:
release:
prerelease: auto
extra_files:
- glob: ./dist/Tart-{{ .Tag }}.pkg
brews:
- name: tart
@@ -32,9 +37,17 @@ brews:
tap:
owner: cirruslabs
name: homebrew-cli
caveats: See the Github repository for more information
caveats: See the GitHub repository for more information
homepage: https://github.com/cirruslabs/tart
description: Run macOS VMs on Apple Silicon
skip_upload: auto
dependencies:
- "cirruslabs/cli/softnet"
custom_block: |
depends_on :macos => :monterey
on_macos do
unless Hardware::CPU.arm?
odie "Tart only works on Apple Silicon!"
end
end
+54 -63
View File
@@ -1,12 +1,12 @@
{
"pins" : [
{
"identity" : "async-http-client",
"identity" : "antlr4",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/async-http-client",
"location" : "https://github.com/antlr/antlr4",
"state" : {
"revision" : "24425989dadab6d6e4167174791a23d4e2a6d0c3",
"version" : "1.10.0"
"branch" : "dev",
"revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2"
}
},
{
@@ -18,6 +18,24 @@
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
}
},
{
"identity" : "puppy",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sushichop/Puppy",
"state" : {
"revision" : "3e8d87f714f14244878752a6bb71ea465119f8a1",
"version" : "0.5.1"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-algorithms",
"state" : {
"revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
"version" : "1.0.0"
}
},
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
@@ -28,12 +46,30 @@
}
},
{
"identity" : "swift-case-paths",
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-case-paths",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
"revision" : "ce9c0d897db8a840c39de64caaa9b60119cf4be8",
"version" : "0.8.1"
"branch" : "main",
"revision" : "f05e450f0b909c0e80670a47516c4b9700b9e5da"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
"version" : "1.0.2"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
"version" : "1.0.3"
}
},
{
@@ -41,71 +77,26 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "5d66f7ba25daf4f94100e7022febf3c75e37a6c7",
"version" : "1.4.2"
"revision" : "6fe203dc33195667ce1759bf0182975e4653ba1c",
"version" : "1.4.4"
}
},
{
"identity" : "swift-nio",
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"location" : "https://github.com/apple/swift-numerics",
"state" : {
"revision" : "124119f0bb12384cef35aa041d7c3a686108722d",
"version" : "2.40.0"
"revision" : "0a5bc04095a675662cf24757cc0640aa2204253b",
"version" : "1.0.2"
}
},
{
"identity" : "swift-nio-extras",
"identity" : "swiftdate",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"location" : "https://github.com/malcommac/SwiftDate",
"state" : {
"revision" : "8eea84ec6144167354387ef9244b0939f5852dc8",
"version" : "1.11.0"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "72bcaf607b40d7c51044f65b0f5ed8581a911832",
"version" : "1.21.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"
}
},
{
"identity" : "swift-parsing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-parsing",
"state" : {
"revision" : "28d32e9ace1c4c43f5e5a177be837a202494c2d5",
"version" : "0.9.2"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "50a70a9d3583fe228ce672e8923010c8df2deddd",
"version" : "0.2.1"
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
"version" : "6.3.1"
}
}
],
+20 -6
View File
@@ -1,4 +1,4 @@
// swift-tools-version:5.6
// swift-tools-version:5.7
import PackageDescription
let package = Package(
@@ -12,17 +12,31 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"),
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
.package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.9.2"),
.package(url: "https://github.com/swift-server/async-http-client", from: "1.10.0"),
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
.package(url: "https://github.com/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")),
],
targets: [
.executableTarget(name: "tart", dependencies: [
.product(name: "Algorithms", package: "swift-algorithms"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "AsyncHTTPClient", package: "async-http-client"),
.product(name: "Dynamic", package: "Dynamic"),
.product(name: "Parsing", package: "swift-parsing"),
.product(name: "SwiftDate", package: "SwiftDate"),
.product(name: "Puppy", package: "Puppy"),
.product(name: "Antlr4Static", package: "Antlr4"),
.product(name: "Atomics", package: "swift-atomics"),
], 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"])
]
)
+166 -34
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 virtual machines on Apple Silicon.
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
Built by CI engineers for your automation needs. Here are some highlights of Tart:
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/14966395?baseline=14966339).
@@ -8,18 +8,62 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
* Use Tart Packer Plugin to automate VM creation.
* Built-in CI integration.
*Tart* is already adopted by several automation services:
<p align="center">
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
</a>
<a href="https://codemagic.io/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Codemagic.png" height="65"/>
</a>
<a href="https://testingbot.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
</a>
</p>
## Usage
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
```shell
brew install cirruslabs/cli/tart
tart clone ghcr.io/cirruslabs/macos-monterey-base:latest monterey-base
tart run monterey-base
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-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
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
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.
@@ -48,17 +92,47 @@ brew install cirruslabs/cli/cirrus
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`
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
#### Retrieving artifacts from within Tart VMs
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tasks/#artifacts-instruction):
```yaml
task:
name: Build
macos_instance:
image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
build_script: swift build --product tart
binary_artifacts:
path: .build/debug/tart
```
Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
```bash
cirrus run --artifacts-dir artifacts
```
Note that all retrieved artifacts will be prefixed with the associated task name and `artifacts` instruction name.
For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/binary/.build/debug/tart`.
## Virtual Machine Management
### Creating from scratch
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regarding of the underlying OS a particular VM image has.
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
#### Creating a macOS VM image from scratch
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
use `latest` instead of a path to `*.ipsw` to download the latest available version:
@@ -75,6 +149,27 @@ After the initial booting of the VM you'll need to manually go through the macOS
4. Disable Screen Saver.
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
#### Creating a Linux VM image from scratch
```bash
# Create a bare VM
tart create --linux ubuntu
# Install Ubuntu
tart run --disk focal-desktop-arm64.iso ubuntu
# Run VM
tart run ubuntu
```
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
```shell
sudo apt update
sudo apt install -y openssh-server
sudo ufw allow ssh
```
### Configuring a VM
By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command.
@@ -85,32 +180,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.
Here is an example of a template to build `monterey-base` local image based of a remote image:
```json
{
"builders": [
{
"name": "tart",
"type": "tart-cli",
"vm_base_name": "tartvm/vanilla:latest",
"vm_name": "monterey-base",
"cpu_count": 4,
"memory_gb": 8,
"disk_size_gb": 32,
"ssh_username": "admin",
"ssh_password": "admin",
"ssh_timeout": "120s"
```hcl
packer {
required_plugins {
tart = {
version = ">= 0.5.3"
source = "github.com/cirruslabs/tart"
}
],
"provisioners": [
{
"inline": [
"echo 'Disabling spotlight indexing...'",
"sudo mdutil -a -i off"
],
"type": "shell"
},
# more provisioners
]
}
}
source "tart-cli" "tart" {
vm_base_name = "ghcr.io/cirruslabs/macos-ventura-base:latest"
vm_name = "my-custom-ventura"
cpu_count = 4
memory_gb = 8
disk_size_gb = 70
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
}
```
@@ -130,6 +228,12 @@ tart login acme.io
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
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
@@ -140,10 +244,20 @@ tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:
#### Pulling a Remote Image
You can either pull an image:
```shell
tart pull acme.io/remoteorg/name:latest my-local-vm-name
tart pull acme.io/remoteorg/name:latest
```
...or instantiate a VM from a remote image:
```shell
tart clone acme.io/remoteorg/name:latest my-local-vm-name
```
This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
## FAQ
<details>
@@ -161,8 +275,8 @@ tart pull acme.io/remoteorg/name:latest my-local-vm-name
<details>
<summary>Why Tart is free and open sourced?</summary>
Tart is a relatively small project, and it didn't feel right to try to monetize it.
Apple did all the heavy lifting with their `Virtualization.Framework`.
Apple did all the heavy lifting with their `Virtualization.Framework` and it just felt right to develop Tart in the open.
Please consider [becoming a sponsor](https://github.com/sponsors/cirruslabs) if you find Tart saving a substantial amount of money on licensing and engineering hours for your company.
</details>
<details>
@@ -191,3 +305,21 @@ tart pull acme.io/remoteorg/name:latest my-local-vm-name
Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
doesn't support nested virtualization.
</details>
<details>
<summary>Changing the default NAT subnet</summary>
To change the default network to `192.168.77.1`:
```
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Address -string 192.168.77.1
```
Note that even through a network would normally be specified as `192.168.77.0`, the [vmnet framework](https://developer.apple.com/documentation/vmnet) seems to treat this as a starting address too and refuses to pick up such network-like values.
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:
```
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
```
</details>
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8dd6af1a08bbcdc4faf0ff53601b38136c90231e11bd81bc8cd477d6f1c7d3f2
size 209404
oid sha256:1fe96aed7a965b075300f092a3ca76e09053eb7cf2f3125c3a819098a8bc4b31
size 123360
+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
oid sha256:3a43f541b1ab0b57ae2060d371cba5dbb1f5c80b89c76434b7154d8144f66e61
size 205325
oid sha256:7a4929ca4e02d4968904749028ada7072704d2a52cccb69335e1596452c822c7
size 1359834
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dbcf6f65f26c0e17b2fe55a9712fc783315cd64905a0febce758b31d7e4e923b
size 6787
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4239996f30145992936cfc8faa0232fa8904aedf4de61e30ef4c4fc86cad587f
size 14588
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:227192fcd215c9cc05b1ebcd2616bd1f8c95be184726f64d91fc4507a88c66e1
size 18393
+1 -1
View File
@@ -5,4 +5,4 @@
<key>com.apple.security.virtualization</key>
<true/>
</dict>
</plist>
</plist>
-119
View File
@@ -1,119 +0,0 @@
import Foundation
import Network
import Virtualization
struct ARPCommandFailedError: Error, CustomStringConvertible {
var terminationReason: Process.TerminationReason
var terminationStatus: Int32
var description: String {
var reason: String
switch terminationReason {
case .exit:
reason = "exit code \(terminationStatus)"
case .uncaughtSignal:
reason = "uncaught signal"
default:
reason = "unknown reason"
}
return "arp command failed: \(reason)"
}
}
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
var explanation: String
var description: String {
"arp command yielded invalid output: \(explanation)"
}
}
struct ARPCacheInternalError: Error, CustomStringConvertible {
var explanation: String
var description: String {
"ARPCache internal error: \(explanation)"
}
}
struct ARPCache {
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
let process = Process.init()
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
process.arguments = ["-an"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.standardInput = FileHandle.nullDevice
try process.run()
process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
throw ARPCommandFailedError(
terminationReason: process.terminationReason,
terminationStatus: process.terminationStatus)
}
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
}
let lines = String(decoding: rawLines, 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 {
func getCaptureGroup(name: String, for string: String) throws -> String {
let nsRange = self.range(withName: name)
if nsRange.location == NSNotFound {
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
}
guard let range = Range.init(nsRange, in: string) else {
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
}
return String(string[range])
}
}
+46 -22
View File
@@ -11,24 +11,47 @@ struct Clone: AsyncParsableCommand {
@Argument(help: "new VM name")
var newName: String
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
func validate() throws {
if newName.contains("/") {
throw ValidationError("<new-name> should be a local name")
}
}
func run() async throws {
do {
if let remoteName = try? RemoteName(sourceName) {
if !VMStorageOCI().exists(remoteName) {
// Pull the VM in case it's OCI-based and doesn't exist locally yet
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
try await VMStorageOCI().pull(remoteName, registry: registry)
}
let remoteVM = try VMStorageHelper.open(sourceName)
let ociStorage = VMStorageOCI()
let localStorage = VMStorageLocal()
let remoteConfig = try VMConfig.init(fromURL: remoteVM.configURL)
let needToGenerateNewMAC = try localVMExistsWith(macAddress: remoteConfig.macAddress.string)
try remoteVM.clone(to: VMStorageLocal().create(newName), generateMAC: needToGenerateNewMAC)
} else {
try VMStorageHelper.open(sourceName).clone(to: VMStorageLocal().create(newName), generateMAC: true)
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
// Pull the VM in case it's OCI-based and doesn't exist locally yet
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
try await ociStorage.pull(remoteName, registry: registry)
}
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)
@@ -36,15 +59,16 @@ struct Clone: AsyncParsableCommand {
Foundation.exit(1)
}
}
}
private func localVMExistsWith(macAddress: String) throws -> Bool {
var needToGenerateNewMAC = false
for (_, localDir) in try VMStorageLocal().list() {
let localConfig = try VMConfig.init(fromURL: localDir.configURL)
if localConfig.macAddress.string == macAddress {
needToGenerateNewMAC = true
}
}
return needToGenerateNewMAC
fileprivate extension VMDirectory {
func macAddress() throws -> String {
try VMConfig(fromURL: configURL).macAddress.string
}
}
fileprivate extension VMStorageLocal {
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
try list().contains { try $1.macAddress() == macAddress }
}
}
+39 -10
View File
@@ -9,27 +9,56 @@ struct Create: AsyncParsableCommand {
@Argument(help: "VM name")
var name: String
@Option(help: ArgumentHelp("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?
@Flag(help: "create a Linux VM")
var linux: Bool = false
@Option(help: ArgumentHelp("Disk size in Gb"))
var diskSize: UInt8 = 50
var diskSize: UInt16 = 50
func validate() throws {
if fromIPSW == nil {
throw ValidationError("Please specify a --from-ipsw option!")
if fromIPSW == nil && !linux {
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
}
}
func run() async throws {
do {
let vmDir = try VMStorageLocal().create(name)
let tmpVMDir = try VMDirectory.temporary()
if fromIPSW! == "latest" {
_ = try await VM(vmDir: vmDir, ipswURL: nil, diskSizeGB: diskSize)
} else {
_ = try await VM(vmDir: vmDir, ipswURL: URL(fileURLWithPath: fromIPSW!), diskSizeGB: diskSize)
}
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
try await withTaskCancellationHandler(operation: {
if let fromIPSW = fromIPSW {
let ipswURL: URL
if fromIPSW == "latest" {
ipswURL = try await VM.latestIPSWURL()
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
ipswURL = URL(string: fromIPSW)!
} else {
ipswURL = URL(fileURLWithPath: fromIPSW)
}
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
}
if linux {
if #available(macOS 13, *) {
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
} else {
throw UnsupportedOSError("Linux VMs", "are")
}
}
try VMStorageLocal().move(name, from: tmpVMDir)
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
Foundation.exit(0)
} catch {
+4 -2
View File
@@ -6,11 +6,13 @@ struct Delete: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Delete a VM")
@Argument(help: "VM name")
var name: String
var name: [String]
func run() async throws {
do {
try VMStorageHelper.delete(name)
for it in name {
try VMStorageHelper.delete(it)
}
Foundation.exit(0)
} catch {
+31
View File
@@ -0,0 +1,31 @@
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
func run() async throws {
do {
let vmDir = try VMStorageLocal().open(name)
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
let diskSize = try vmDir.sizeBytes() / 1000 / 1000 / 1000
print("CPU\tMemory\tDisk\tDisplay")
var s = "\(vmConfig.cpuCount)\t"
s += "\(vmConfig.memorySize / 1024 / 1024) MB\t"
s += "\(diskSize) GB\t"
s += "\(vmConfig.display.width)x\(vmConfig.display.height)"
print(s)
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
}
}
+13 -6
View File
@@ -16,14 +16,21 @@ struct IP: AsyncParsableCommand {
do {
let vmDir = try VMStorageLocal().open(name)
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
guard let ip = try await resolveIP(vmConfig, secondsToWait: wait) else {
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
print("no IP address found, is your VM running?")
Foundation.exit(1)
}
print(ip)
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 {
@@ -33,16 +40,16 @@ struct IP: AsyncParsableCommand {
}
}
private func resolveIP(_ config: VMConfig, 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 vmMacAddress = MACAddress(fromString: config.macAddress.string)!
repeat {
if let ip = try ARPCache.ResolveMACAddress(macAddress: vmMacAddress) {
if let leases = try Leases(), let ip = try leases.resolveMACAddress(macAddress: vmMACAddress) {
return ip
}
try await Task.sleep(nanoseconds: 1_000_000)
// wait a second
try await Task.sleep(nanoseconds: 1_000_000_000)
} while Date.now < waitUntil
return nil
+12 -3
View File
@@ -5,12 +5,17 @@ import SwiftUI
struct List: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "List created VMs")
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names"))
var quiet: Bool = false
func run() async throws {
do {
print("Source\tName")
if !quiet {
print("Source\tName")
}
displayTable("local", try VMStorageLocal().list())
displayTable("oci", try VMStorageOCI().list())
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
Foundation.exit(0)
} catch {
@@ -22,7 +27,11 @@ struct List: AsyncParsableCommand {
private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) {
print("\(source)\t\(name)")
if quiet {
print(name)
} else {
print("\(source)\t\(name)")
}
}
}
}
+59 -2
View File
@@ -8,11 +8,52 @@ struct Login: AsyncParsableCommand {
@Argument(help: "host")
var host: String
@Option(help: "username")
var username: String?
@Flag(help: "password-stdin")
var passwordStdin: Bool = false
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
func validate() throws {
let usernameProvided = username != nil
let passwordProvided = passwordStdin
if usernameProvided != passwordProvided {
throw ValidationError("both --username and --password-stdin are required")
}
}
func run() async throws {
do {
let (user, password) = try Credentials.retrieveStdin()
var user: String
var password: String
try Credentials.store(host: host, user: user, password: password)
if let username = username {
user = username
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
password = String(decoding: passwordData, as: UTF8.self)
} else {
(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 {
@@ -22,3 +63,19 @@ struct Login: AsyncParsableCommand {
}
}
}
fileprivate class DictionaryCredentialsProvider: CredentialsProvider {
var credentials: Dictionary<String, (String, String)>
init(_ credentials: Dictionary<String, (String, String)>) {
self.credentials = credentials
}
func retrieve(host: String) throws -> (String, String)? {
credentials[host]
}
func store(host: String, user: String, password: String) throws {
credentials[host] = (user, password)
}
}
+115
View File
@@ -0,0 +1,115 @@
import ArgumentParser
import Dispatch
import SwiftUI
import SwiftDate
struct Prune: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
@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.",
valueName: "n"))
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",
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
valueName: "n"))
var cacheBudget: UInt?
@Flag(help: .hidden)
var gc: Bool = false
func validate() throws {
if olderThan == nil && cacheBudget == nil && !gc {
throw ValidationError("at least one pruning criteria must be specified")
}
}
func run() async throws {
do {
if gc {
try VMStorageOCI().gc()
}
// Clean up cache entries based on last accessed date
if let olderThan = olderThan {
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
}
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
if let cacheBudget = cacheBudget {
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
}
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
}
static func pruneOlderThan(olderThanDate: Date) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
}
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() }
var cacheBudgetBytes = cacheBudgetBytes
var prunablesToDelete: [Prunable] = []
for prunable in prunables {
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
if prunableSizeBytes <= cacheBudgetBytes {
// Don't mark for deletion as
// there's a budget available
cacheBudgetBytes -= prunableSizeBytes
} else {
// Mark for deletion
prunablesToDelete.append(prunable)
}
}
try prunablesToDelete.forEach { try $0.delete() }
}
static func pruneReclaim(reclaimBytes: UInt64) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() }
// Does it even make sense to start?
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
if cacheUsedBytes < reclaimBytes {
return
}
var cacheReclaimedBytes: Int = 0
var it = prunables.makeIterator()
while cacheReclaimedBytes <= reclaimBytes {
guard let prunable = it.next() else {
break
}
cacheReclaimedBytes += try prunable.sizeBytes()
try prunable.delete()
puppy.info("deleting \(prunable.url)...")
}
puppy.info("reclaimed \(cacheReclaimedBytes) bytes")
}
}
+4 -1
View File
@@ -8,6 +8,9 @@ struct Pull: AsyncParsableCommand {
@Argument(help: "remote VM name")
var remoteName: String
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
func run() async throws {
do {
// Be more liberal when accepting local image as argument,
@@ -19,7 +22,7 @@ struct Pull: AsyncParsableCommand {
}
let remoteName = try RemoteName(remoteName)
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
defaultLogger.appendNewLine("pulling \(remoteName)...")
+45 -5
View File
@@ -12,6 +12,21 @@ struct Push: AsyncParsableCommand {
@Argument(help: "remote VM name(s)")
var remoteNames: [String]
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
discussion: """
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
For example, AWS Elastic Container Registry supports only chunks larger than 5MB but GitHub Container Registry supports only chunks smaller than 4MB. Google Container Registry on the other hand doesn't support chunked uploads at all.
Please refer to the documentation of your particular registry in order to see if this option is suitable for you and what's the recommended chunk size.
"""))
var chunkSize: Int = 0
@Flag(help: ArgumentHelp("cache pushed images locally",
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
var populateCache: Bool = false
func run() async throws {
do {
let localVMDir = try VMStorageLocal().open(localName)
@@ -33,14 +48,27 @@ struct Push: AsyncParsableCommand {
// Push VM
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)
let listOfTagsAndDigests = "{" + remoteNamesForRegistry.map{$0.fullyQualifiedReference }
.joined(separator: ",") + "}"
defaultLogger.appendNewLine("pushing \(localName) to "
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(listOfTagsAndDigests)...")
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
try await localVMDir.pushToRegistry(registry: registry, references: remoteNamesForRegistry.map{ $0.reference })
let pushedRemoteName = try await localVMDir.pushToRegistry(
registry: registry,
references: remoteNamesForRegistry.map{ $0.reference.value },
chunkSizeMb: chunkSize
)
// Populate the local cache (if requested)
if populateCache {
let ociStorage = VMStorageOCI()
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
for remoteName in remoteNamesForRegistry {
try ociStorage.link(from: remoteName, to: pushedRemoteName)
}
}
}
Foundation.exit(0)
@@ -51,3 +79,15 @@ struct Push: AsyncParsableCommand {
}
}
}
extension Collection where Element == RemoteName {
func referenceNames() -> String {
let references = self.map{ $0.reference.fullyQualified }
switch count {
case 0: return "∅"
case 1: return references.first!
default: return "{" + references.joined(separator: ",") + "}"
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import ArgumentParser
import Foundation
struct Rename: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Rename a VM")
@Argument(help: "VM name")
var name: String
@Argument(help: "new VM name")
var newName: String
func validate() throws {
if newName.contains("/") {
throw ValidationError("<new-name> should be a local name")
}
}
func run() async throws {
do {
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)
}
}
}
+234 -16
View File
@@ -5,40 +5,256 @@ import Virtualization
var vm: VM?
struct IPNotFound: Error {
}
struct Run: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Run a VM")
@Argument(help: "VM name")
var name: String
@Flag var noGraphics: Bool = false
@Flag(help: ArgumentHelp(
"Don't open a UI window.",
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
var noGraphics: Bool = false
@Flag var recovery: Bool = false
@Flag(help: "Force open a UI window, even when VNC is enabled.")
var graphics: Bool = false
@Flag(help: "Boot into recovery mode")
var recovery: Bool = false
@Flag(help: ArgumentHelp(
"Use screen sharing instead of the built-in UI.",
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
+ "Note that Remote Login option should be enabled inside the VM."))
var vnc: Bool = false
@Flag(help: ArgumentHelp(
"Use Virtualization.Framework's VNC server instead of the build-in UI.",
discussion: "Useful since this type of VNC is available in recovery mode and in macOS installation.\n"
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
var vncExperimental: Bool = false
@Flag(help: ArgumentHelp(visibility: .private))
var withSoftnet: Bool = false
@Option(help: ArgumentHelp("""
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
""", discussion: """
Learn how to create a disk image using Disk Utility here:
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
""", valueName: "path[:ro]"))
var disk: [String] = []
@Option(help: ArgumentHelp("""
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
""", discussion: """
Requires host to be macOS 13.0 (Ventura) or newer.
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".
For macOS guests, they must be running macOS 13.0 (Ventura) or newer.
""", valueName: "name:path[:ro]"))
var dir: [String] = []
@Option(help: ArgumentHelp("""
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
""", discussion: """
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
""", valueName: "interface name"))
var netBridged: String?
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
var netSoftnet: Bool = false
func validate() throws {
if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
}
if withSoftnet && netBridged != nil {
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
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
vm = try VM(vmDir: vmDir)
vm = try VM(
vmDir: vmDir,
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
additionalDiskAttachments: additionalDiskAttachments(),
directoryShares: directoryShares()
)
await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
do {
try await vm!.run(recovery)
let vncImpl: VNC? = try {
if vnc {
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
return ScreenSharingVNC(vmConfig: vmConfig)
} else if vncExperimental {
return FullFledgedVNC(virtualMachine: vm!.virtualMachine)
} else {
return nil
}
}()
Foundation.exit(0)
} catch {
print(error)
// 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() {
print("Virtual machine \"\(name)\" is already running!")
Foundation.exit(2)
}
Foundation.exit(1)
let task = Task {
do {
if let vncImpl = vncImpl {
let vncURL = try await vncImpl.waitForURL()
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
print("VNC server is running at \(vncURL)")
} else {
print("Opening \(vncURL)...")
NSWorkspace.shared.open(vncURL)
}
}
try await vm!.run(recovery)
if let vncImpl = vncImpl {
try vncImpl.stop()
}
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
}
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSrc.setEventHandler {
task.cancel()
}
sigintSrc.activate()
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
if noGraphics || useVNCWithoutGraphics {
dispatchMain()
} else {
runUI()
}
}
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
if withSoftnet || netSoftnet {
let config = try VMConfig.init(fromURL: vmDir.configURL)
return try Softnet(vmMACAddress: config.macAddress.string)
}
if let netBridged = netBridged {
let matchingInterfaces = VZBridgedNetworkInterface.networkInterfaces.filter { interface in
interface.identifier == netBridged || interface.localizedDisplayName == netBridged
}
if matchingInterfaces.isEmpty {
let available = bridgeInterfaces().joined(separator: ", ")
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
+ "available interfaces: \(available)")
}
if matchingInterfaces.count > 1 {
throw ValidationError("more than one bridge interface matched \"\(netBridged)\", "
+ "consider refining the search criteria")
}
return NetworkBridged(interface: matchingInterfaces.first!)
}
return nil
}
func bridgeInterfaces() -> [String] {
VZBridgedNetworkInterface.networkInterfaces.map { interface in
var bridgeDescription = interface.identifier
if let localizedDisplayName = interface.localizedDisplayName {
bridgeDescription += " (or \"\(localizedDisplayName)\")"
}
return bridgeDescription
}
}
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
var result: [VZDiskImageStorageDeviceAttachment] = []
let readOnlySuffix = ":ro"
let expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
for rawDisk in expandedDiskPaths {
if rawDisk.hasSuffix(readOnlySuffix) {
result.append(try VZDiskImageStorageDeviceAttachment(
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
readOnly: true
))
} else {
result.append(try VZDiskImageStorageDeviceAttachment(
url: URL(fileURLWithPath: rawDisk),
readOnly: false
))
}
}
return result
}
func directoryShares() throws -> [DirectoryShare] {
var result: [DirectoryShare] = []
for rawDir in dir {
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
if splits.count < 2 {
throw ValidationError("invalid --dir syntax: should at least include name and path, colon-separated")
}
var readOnly: Bool = false
if splits.count == 3 {
if splits[2] == "ro" {
readOnly = true
} else {
throw ValidationError("invalid --dir syntax: optional read-only specifier can only be \"ro\"")
}
}
if noGraphics {
dispatchMain()
} else {
runUI()
}
let (name, path) = (String(splits[0]), String(splits[1]))
result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath), readOnly: readOnly))
}
return result
}
private func runUI() {
@@ -54,6 +270,8 @@ struct Run: AsyncParsableCommand {
Group {
VMView(vm: vm!).onAppear {
NSWindow.allowsAutomaticWindowTabbing = false
}.onDisappear {
NSApplication.shared.terminate(self)
}
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
}.commands {
+6 -10
View File
@@ -2,7 +2,7 @@ import ArgumentParser
import Foundation
struct Set: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Modify VM's configuration")
static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
@Argument(help: "VM name")
var name: String
@@ -11,13 +11,13 @@ struct Set: AsyncParsableCommand {
var cpu: UInt16?
@Option(help: "VM memory size in megabytes")
var memory: UInt16?
var memory: UInt64?
@Option(help: "VM display settings in a format of <width>x<height>(x<dpi>)?. For example, 1200x800 or 1200x800x72")
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
var display: VMDisplayConfig?
@Option(help: .hidden)
var diskSize: UInt8?
var diskSize: UInt16?
func run() async throws {
do {
@@ -29,7 +29,7 @@ struct Set: AsyncParsableCommand {
}
if let memory = memory {
try vmConfig.setMemory(memorySize: UInt64(memory) * 1024 * 1024)
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
}
if let display = display {
@@ -39,9 +39,6 @@ struct Set: AsyncParsableCommand {
if (display.height > 0) {
vmConfig.display.height = display.height
}
if (display.dpi > 0) {
vmConfig.display.dpi = display.dpi
}
}
try vmConfig.save(toURL: vmDir.configURL)
@@ -66,8 +63,7 @@ extension VMDisplayConfig: ExpressibleByArgument {
}
self = VMDisplayConfig(
width: parts[safe: 0] ?? 0,
height: parts[safe: 1] ?? 0,
dpi: parts[safe: 2] ?? 0
height: parts[safe: 1] ?? 0
)
}
}
+72
View File
@@ -0,0 +1,72 @@
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 {
do {
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 {
print("VM \(name) is not running")
Foundation.exit(2)
}
// 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 {
Foundation.exit(0)
}
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))
print("failed to forcefully terminate the VM \(name): \(details)")
Foundation.exit(1)
}
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
}
}
+47 -4
View File
@@ -1,9 +1,52 @@
import Foundation
struct Config {
public static let tartHomeDir: URL = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".tart", isDirectory: true)
let tartHomeDir: URL
let tartCacheDir: URL
let tartTmpDir: URL
public static let tartCacheDir: URL = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
init() throws {
var tartHomeDir: URL
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
tartHomeDir = URL(fileURLWithPath: customTartHome)
} else {
tartHomeDir = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".tart", isDirectory: true)
}
self.tartHomeDir = tartHomeDir
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
try FileManager.default.createDirectory(at: tartCacheDir, withIntermediateDirectories: true)
tartTmpDir = tartHomeDir.appendingPathComponent("tmp", isDirectory: true)
try FileManager.default.createDirectory(at: tartTmpDir, withIntermediateDirectories: true)
}
func gc() throws {
for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
includingPropertiesForKeys: [], options: []) {
let lock = try FileLock(lockURL: entry)
if try !lock.trylock() {
continue
}
try FileManager.default.removeItem(at: entry)
try lock.unlock()
}
}
static func jsonEncoder() -> JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
return encoder
}
static func jsonDecoder() -> JSONDecoder {
JSONDecoder()
}
}
-82
View File
@@ -1,82 +0,0 @@
import Foundation
enum CredentialsError: Error {
case CredentialRequired(which: String)
case CredentialTooLong(message: String)
}
class Credentials {
static func retrieveKeychain(host: String) throws -> (String, String)? {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true,
kSecAttrLabel as String: "Tart Credentials",
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status != errSecSuccess {
if status == errSecItemNotFound {
return nil
}
throw RegistryError.AuthFailed(why: "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 RegistryError.AuthFailed(why: "Keychain item has unexpected format")
}
return (user, password)
}
static func retrieveStdin() throws -> (String, String) {
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
return (user, password)
}
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String {
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
throw CredentialsError.CredentialRequired(which: name)
}
let credential = String(cString: rawCredential).trimmingCharacters(in: .newlines)
if credential.count > maxCharacters {
throw CredentialsError.CredentialTooLong(
message: "\(name) should contain no more than \(maxCharacters) characters")
}
return credential
}
static func store(host: String, user: String, password: String) throws {
let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrAccount as String: user,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecValueData as String: password,
kSecAttrLabel as String: "Tart Credentials",
]
let status = SecItemAdd(attributes as CFDictionary, nil)
switch status {
case errSecSuccess, errSecDuplicateItem:
return
default:
throw RegistryError.AuthFailed(why: "Keychain returned unsuccessful status \(status)")
}
}
}
@@ -0,0 +1,10 @@
import Foundation
enum CredentialsProviderError: Error {
case Failed(message: String)
}
protocol CredentialsProvider {
func retrieve(host: String) throws -> (String, String)?
func store(host: String, user: String, password: String) throws
}
@@ -0,0 +1,88 @@
import Foundation
class DockerConfigCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? {
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
return nil
}
let config = try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: dockerConfigURL))
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
return credentialsFromAuth
}
if let helperProgram = config.credHelpers?[host] {
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
}
return nil
}
private func executeHelper(binaryName: String, host: String) throws -> (String, String)? {
guard let executableURL = resolveBinaryPath(binaryName) else {
throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH")
}
let process = Process.init()
process.executableURL = executableURL
process.arguments = ["get"]
let outPipe = Pipe()
let inPipe = Pipe()
process.standardOutput = outPipe
process.standardError = outPipe
process.standardInput = inPipe
process.launch()
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
inPipe.fileHandleForWriting.closeFile()
process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
}
let getOutput = try JSONDecoder().decode(
DockerGetOutput.self, from: outPipe.fileHandleForReading.readDataToEndOfFile()
)
return (getOutput.Username, getOutput.Secret)
}
func store(host: String, user: String, password: String) throws {
throw CredentialsProviderError.Failed(message: "Docker helpers don't support storing!")
}
}
struct DockerConfig: Codable {
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
var credHelpers: Dictionary<String, String>? = Dictionary()
}
struct DockerAuthConfig: Codable {
var auth: String? = nil
func decodeCredentials() -> (String, String)? {
// auth is a base64("username:password")
guard let authBase64 = auth else {
return nil
}
guard let data = Data(base64Encoded: authBase64) else {
return nil
}
guard let components = String(data: data, encoding: .utf8)?.components(separatedBy: ":") else {
return nil
}
if components.count != 2 {
return nil
}
return (components[0], components[1])
}
}
struct DockerGetOutput: Codable {
var Username: String
var Secret: String
}
@@ -0,0 +1,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 {
}
}
@@ -0,0 +1,70 @@
import Foundation
class KeychainCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true,
kSecAttrLabel as String: "Tart Credentials",
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status != errSecSuccess {
if status == errSecItemNotFound {
return nil
}
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
}
guard let item = item as? [String: Any],
let user = item[kSecAttrAccount as String] as? String,
let passwordData = item[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: .utf8)
else {
throw CredentialsProviderError.Failed(message: "Keychain item has unexpected format")
}
return (user, password)
}
func store(host: String, user: String, password: String) throws {
let passwordData = password.data(using: .utf8)
let key: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecAttrLabel as String: "Tart Credentials",
]
let value: [String: Any] = [kSecAttrAccount as String: user,
kSecValueData as String: passwordData,
]
let status = SecItemCopyMatching(key as CFDictionary, nil)
switch status {
case errSecItemNotFound:
let status = SecItemAdd(key.merging(value) { (current, _) in current } as CFDictionary, nil)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to add item: \(status.explanation())")
}
case errSecSuccess:
let status = SecItemUpdate(key as CFDictionary, value as CFDictionary)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
}
default:
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
}
}
}
extension OSStatus {
func explanation() -> CFString {
SecCopyErrorMessageString(self, nil) ?? "Unknown status code \(self)." as CFString
}
}
@@ -0,0 +1,31 @@
import Foundation
enum StdinCredentialsError: Error {
case CredentialRequired(which: String)
case CredentialTooLong(message: String)
}
class StdinCredentials {
static func retrieve() throws -> (String, String) {
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
return (user, password)
}
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String {
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
throw StdinCredentialsError.CredentialRequired(which: name)
}
let credential = String(cString: rawCredential).trimmingCharacters(in: .newlines)
if credential.count > maxCharacters {
throw StdinCredentialsError.CredentialTooLong(
message: "\(name) should contain no more than \(maxCharacters) characters")
}
return credential
}
}
File diff suppressed because one or more lines are too long
+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)
}
+48
View File
@@ -0,0 +1,48 @@
import Foundation
import System
enum FileLockError: Error, Equatable {
case Failed(_ message: String)
case AlreadyLocked
}
class FileLock {
let url: URL
let fd: Int32
init(lockURL: URL) throws {
url = lockURL
fd = open(lockURL.path, 0)
}
deinit {
close(fd)
}
func trylock() throws -> Bool {
try flockWrapper(LOCK_EX | LOCK_NB)
}
func lock() throws {
_ = try flockWrapper(LOCK_EX)
}
func unlock() throws {
_ = try flockWrapper(LOCK_UN)
}
func flockWrapper(_ operation: Int32) throws -> Bool {
let ret = flock(fd, operation)
if ret != 0 {
let details = Errno(rawValue: CInt(errno))
if (operation & LOCK_NB) != 0 && details == .wouldBlock {
return false
}
throw FileLockError.Failed("failed to lock \(url): \(details)")
}
return true
}
}
+20
View File
@@ -0,0 +1,20 @@
import Foundation
import Virtualization
class IPSWCache: PrunableStorage {
let baseURL: URL
init() throws {
baseURL = try Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
}
func locationFor(fileName: String) -> URL {
baseURL.appendingPathComponent(fileName, isDirectory: false)
}
func prunables() throws -> [Prunable] {
try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)
.filter { $0.lastPathComponent.hasSuffix(".ipsw")}
}
}
@@ -0,0 +1,119 @@
import Foundation
import Network
import Virtualization
struct ARPCommandFailedError: Error, CustomStringConvertible {
var terminationReason: Process.TerminationReason
var terminationStatus: Int32
var description: String {
var reason: String
switch terminationReason {
case .exit:
reason = "exit code \(terminationStatus)"
case .uncaughtSignal:
reason = "uncaught signal"
default:
reason = "unknown reason"
}
return "arp command failed: \(reason)"
}
}
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
var explanation: String
var description: String {
"arp command yielded invalid output: \(explanation)"
}
}
struct ARPCacheInternalError: Error, CustomStringConvertible {
var explanation: String
var description: String {
"ARPCache internal error: \(explanation)"
}
}
struct ARPCache {
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
let process = Process.init()
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
process.arguments = ["-an"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.standardInput = FileHandle.nullDevice
try process.run()
process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
throw ARPCommandFailedError(
terminationReason: process.terminationReason,
terminationStatus: process.terminationStatus)
}
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
}
let lines = String(decoding: rawLines, 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 {
func getCaptureGroup(name: String, for string: String) throws -> String {
let nsRange = self.range(withName: name)
if nsRange.location == NSNotFound {
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
}
guard let range = Range.init(nsRange, in: string) else {
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
}
return String(string[range])
}
}
@@ -0,0 +1,32 @@
import Network
struct Lease {
var mac: MACAddress
var ip: IPv4Address
init?(fromRawLease: [String : String]) {
// Retrieve the required fields
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
// Parse MAC address
let hwAddressSplits = hwAddress.split(separator: ",")
if hwAddressSplits.count != 2 {
return nil
}
if let hwAddressProto = Int(hwAddressSplits[0]), hwAddressProto != ARPHRD_ETHER {
return nil
}
guard let mac = MACAddress(fromString: String(hwAddressSplits[1])) else {
return nil
}
// Parse IP address
guard let ip = IPv4Address(ipAddress) else {
return nil
}
self.ip = ip
self.mac = mac
}
}
@@ -0,0 +1,113 @@
import Foundation
import Network
enum LeasesError: Error {
case UnexpectedFormat(name: String = "unexpected DHCPD leases file format", message: String, line: Int)
case Truncated(name: String = "truncated DHCPD leases file")
var description: String {
switch self {
case .UnexpectedFormat(name: let name, message: let message, line: let line):
return "\(name) on line \(line): \(message)"
case .Truncated(name: let name):
return "\(name)"
}
}
}
class Leases {
private let leases: [MACAddress : Lease]
convenience init?() throws {
try self.init(URL(fileURLWithPath: "/var/db/dhcpd_leases"))
}
convenience init?(_ fromURL: URL) throws {
do {
let urlContents = try String(contentsOf: fromURL, encoding: .utf8)
try self.init(urlContents)
} catch {
if error.isFileNotFound() {
return nil
}
throw error
}
}
init(_ fromString: String) throws {
var leases: [MACAddress : Lease] = Dictionary()
for lease in try Self.retrieveRawLeases(fromString).compactMap({ Lease(fromRawLease: $0) }) {
leases[lease.mac] = lease
}
self.leases = leases
}
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
///
/// [1]: https://github.com/apple-opensource/bootp/blob/master/bootplib/NICache.c#L285-L391
private static func retrieveRawLeases(_ dhcpdLeasesContents: String) throws -> [[String : String]] {
var rawLeases: [[String : String]] = Array()
enum State {
case Nowhere
case Start
case Body
case End
}
var state = State.Nowhere
var currentRawLease: [String : String] = Dictionary()
for (lineNumber, line) in dhcpdLeasesContents.split(separator: "\n").enumerated().map({ ($0 + 1, $1) }) {
if line == "{" {
// Handle lease block start
if state != .Nowhere && state != .End {
throw LeasesError.UnexpectedFormat(message: "unexpected lease block start ({)", line: lineNumber)
}
state = .Start
} else if line == "}" {
// Handle lease block end
if state != .Body {
throw LeasesError.UnexpectedFormat(message: "unexpected lease block end (})", line: lineNumber)
}
rawLeases.append(currentRawLease)
currentRawLease = Dictionary()
state = .End
} else {
// Handle lease block contents
let lineWithoutTabs = String(line.drop { $0 == " " || $0 == "\t"})
if lineWithoutTabs.isEmpty {
continue
}
let splits = lineWithoutTabs.split(separator: "=", maxSplits: 1)
if splits.count != 2 {
throw LeasesError.UnexpectedFormat(message: "key-value pair with only a key", line: lineNumber)
}
let (key, value) = (String(splits[0]), String(splits[1]))
currentRawLease[key] = value
state = .Body
}
}
if state == .Start || state == .Body {
throw LeasesError.Truncated()
}
return rawLeases
}
func resolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
leases[macAddress]?.ip
}
}
@@ -1,6 +1,6 @@
import Foundation
struct MACAddress: Equatable, CustomStringConvertible {
struct MACAddress: Equatable, Hashable, CustomStringConvertible {
var mac: [UInt8] = Array(repeating: 0, count: 6)
init?(fromString: String) {
@@ -16,6 +16,6 @@ struct MACAddress: Equatable, CustomStringConvertible {
}
var description: String {
return String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
}
}
+7
View File
@@ -0,0 +1,7 @@
import Virtualization
protocol Network {
func attachment() -> VZNetworkDeviceAttachment
func run(_ sema: DispatchSemaphore) throws
func stop() async throws
}
+22
View File
@@ -0,0 +1,22 @@
import Foundation
import Virtualization
class NetworkBridged: Network {
let interface: VZBridgedNetworkInterface
init(interface: VZBridgedNetworkInterface) {
self.interface = interface
}
func attachment() -> VZNetworkDeviceAttachment {
VZBridgedNetworkDeviceAttachment(interface: interface)
}
func run(_ sema: DispatchSemaphore) throws {
// no-op, only used for Softnet
}
func stop() async throws {
// no-op, only used for Softnet
}
}
+16
View File
@@ -0,0 +1,16 @@
import Foundation
import Virtualization
class NetworkShared: Network {
func attachment() -> VZNetworkDeviceAttachment {
VZNATNetworkDeviceAttachment()
}
func run(_ sema: DispatchSemaphore) throws {
// no-op, only used for Softnet
}
func stop() async throws {
// no-op, only used for Softnet
}
}
+90
View File
@@ -0,0 +1,90 @@
import Foundation
import Virtualization
import Atomics
enum SoftnetError: Error {
case InitializationFailed(why: String)
case RuntimeFailed(why: String)
}
class Softnet: Network {
private let process = Process()
private var monitorTask: Task<Void, Error>? = nil
private let monitorTaskFinished = ManagedAtomic<Bool>(false)
let vmFD: Int32
init(vmMACAddress: String) throws {
let binaryName = "softnet"
guard let executableURL = resolveBinaryPath(binaryName) else {
throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH")
}
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
if ret != 0 {
throw SoftnetError.InitializationFailed(why: "socketpair() failed with exit code \(ret)")
}
vmFD = fds[0]
let softnetFD = fds[1]
try setSocketBuffers(vmFD, 1 * 1024 * 1024);
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
process.executableURL = executableURL
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress]
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
}
func run(_ sema: DispatchSemaphore) throws {
try process.run()
monitorTask = Task {
// Wait for the Softnet to finish
process.waitUntilExit()
// Signal to the caller that the Softnet has finished
sema.signal()
// Signal to ourselves that the Softnet has finished
monitorTaskFinished.store(true, ordering: .sequentiallyConsistent)
}
}
func stop() async throws {
if monitorTaskFinished.load(ordering: .sequentiallyConsistent) {
// Consume the monitor task's value to ensure the task has finished
_ = try await monitorTask?.value
throw SoftnetError.RuntimeFailed(why: "Softnet process terminated prematurely")
} else {
process.interrupt()
// Consume the monitor task's value to ensure the task has finished
_ = try await monitorTask?.value
}
}
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
var option_value = sizeBytes
let option_len = socklen_t(MemoryLayout<Int>.size)
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &option_value, option_len)
if ret != 0 {
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)")
}
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &option_value, option_len)
if ret != 0 {
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
}
}
func attachment() -> VZNetworkDeviceAttachment {
let fh = FileHandle.init(fileDescriptor: vmFD)
return VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
}
}
+21
View File
@@ -0,0 +1,21 @@
import Foundation
protocol Authentication {
func header() -> (String, String)
func isValid() -> Bool
}
struct BasicAuthentication: Authentication {
let user: String
let password: String
func header() -> (String, String) {
let creds = Data("\(user):\(password)".utf8).base64EncodedString()
return ("Authorization", "Basic \(creds)")
}
func isValid() -> Bool {
true
}
}
+44
View File
@@ -3,11 +3,55 @@ import Foundation
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
// Annotations
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
struct OCIManifest: Codable, Equatable {
var schemaVersion: Int = 2
var mediaType: String = ociManifestMediaType
var config: OCIManifestConfig
var layers: [OCIManifestLayer] = Array()
var annotations: Dictionary<String, String>?
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil) {
self.config = config
self.layers = layers
if let uncompressedDiskSize = uncompressedDiskSize {
annotations = [
uncompressedDiskSizeAnnotation: String(uncompressedDiskSize)
]
}
}
init(fromJSON: Data) throws {
self = try Config.jsonDecoder().decode(Self.self, from: fromJSON)
}
func toJSON() throws -> Data {
try Config.jsonEncoder().encode(self)
}
func digest() throws -> String {
try Digest.hash(toJSON())
}
func uncompressedDiskSize() -> UInt64? {
guard let value = annotations?[uncompressedDiskSizeAnnotation] else {
return nil
}
return UInt64(value)
}
}
struct OCIConfig: Codable {
var architecture: Architecture = .arm64
var os: OS = .darwin
func toJSON() throws -> Data {
try Config.jsonEncoder().encode(self)
}
}
struct OCIManifestConfig: Codable, Equatable {
@@ -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];
+200 -123
View File
@@ -1,31 +1,47 @@
import Foundation
import NIOCore
import NIOHTTP1
import AsyncHTTPClient
import Algorithms
import AsyncAlgorithms
enum RegistryError: Error {
case UnexpectedHTTPStatusCode(when: String, code: UInt, details: String = "")
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
case MissingLocationHeader
case AuthFailed(why: String, details: String = "")
case MalformedHeader(why: String)
}
extension HTTPClientResponse.Body {
func readTextResponse() async throws -> String? {
let data = try await readResponse()
return String(decoding: data, as: UTF8.self)
}
enum HTTPMethod: String {
case GET = "GET"
case POST = "POST"
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()
for try await part in self {
result.append(Data(buffer: part))
for try await chunk in self {
result += chunk
}
return result
}
}
struct TokenResponse: Decodable {
struct TokenResponse: Decodable, Authentication {
let defaultIssuedAt = Date()
let defaultExpiresIn = 60
@@ -34,19 +50,20 @@ struct TokenResponse: Decodable {
var issuedAt: Date?
static func parse(fromData: Data) throws -> Self {
let decoder = JSONDecoder()
let decoder = Config.jsonDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
// RFC3339 date formatter from Apple's documentation[1]
//
// [1]: https://developer.apple.com/documentation/foundation/dateformatter
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime]
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
decoder.dateDecodingStrategy = .formatted(dateFormatter)
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
return dateFormatter.date(from: dateString) ?? Date()
}
return try decoder.decode(TokenResponse.self, from: fromData)
}
@@ -65,74 +82,79 @@ struct TokenResponse: Decodable {
}
}
var isValid: Bool {
get {
Date() < tokenExpiresAt
}
func header() -> (String, String) {
("Authorization", "Bearer \(token)")
}
func isValid() -> Bool {
Date() < tokenExpiresAt
}
}
fileprivate let httpClient = HTTPClient(eventLoopGroupProvider: .createNew)
class Registry {
var baseURL: URL
var namespace: String
let baseURL: URL
let namespace: String
let credentialsProviders: [CredentialsProvider]
var currentAuthToken: TokenResponse? = nil
var currentAuthToken: Authentication? = nil
init(urlComponents: URLComponents, namespace: String) throws {
init(urlComponents: URLComponents,
namespace: String,
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws {
baseURL = urlComponents.url!
self.namespace = namespace
self.credentialsProviders = credentialsProviders
}
convenience init(host: String, namespace: String) throws {
var baseURLComponents = URLComponents()
convenience init(
host: String,
namespace: String,
insecure: Bool = false,
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws {
let proto = insecure ? "http" : "https"
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
baseURLComponents.scheme = "https"
baseURLComponents.host = host
baseURLComponents.path = "/v2/"
try self.init(urlComponents: baseURLComponents, namespace: namespace)
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProviders: credentialsProviders)
}
func ping() async throws {
let response = try await endpointRequest(.GET, "/v2/")
if response.status != .ok {
throw RegistryError.UnexpectedHTTPStatusCode(when: "doing ping", code: response.status.code)
let (_, response) = try await dataRequest(.GET, endpointURL("/v2/"))
if response.statusCode != HTTPCode.Ok.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "doing ping", code: response.statusCode)
}
}
func pushManifest(reference: String, manifest: OCIManifest) async throws -> String {
let manifestJSON = try JSONEncoder().encode(manifest)
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],
body: manifestJSON)
if response.status != .created {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.status.code,
details: try await response.body.readTextResponse() ?? "")
if response.statusCode != HTTPCode.Created.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
details: data.asText())
}
return Digest.hash(manifestJSON)
}
public func pullManifest(reference: String) async throws -> (OCIManifest, Data) {
let response = try await endpointRequest(.GET, "\(namespace)/manifests/\(reference)",
let (data, response) = try await dataRequest(.GET, endpointURL("\(namespace)/manifests/\(reference)"),
headers: ["Accept": ociManifestMediaType])
if response.status != .ok {
let body = try await response.body.readTextResponse()
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.status.code,
details: body ?? "")
if response.statusCode != HTTPCode.Ok.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
details: data.asText())
}
let manifestData = try await response.body.readResponse()
let manifest = try JSONDecoder().decode(OCIManifest.self, from: manifestData)
let manifest = try OCIManifest(fromJSON: data)
return (manifest, manifestData)
return (manifest, data)
}
private func uploadLocationFromResponse(_ response: HTTPClientResponse) throws -> URLComponents {
guard let uploadLocationRaw = response.headers.first(name: "Location") else {
private func uploadLocationFromResponse(_ response: HTTPURLResponse) throws -> URLComponents {
guard let uploadLocationRaw = response.value(forHTTPHeaderField: "Location") else {
throw RegistryError.MissingLocationHeader
}
@@ -143,114 +165,161 @@ class Registry {
return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)!
}
public func pushBlob(fromData: Data, chunkSize: Int = 5 * 1024 * 1024) async throws -> String {
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
// 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"])
if postResponse.status != .accepted {
let body = try await postResponse.body.readTextResponse()
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.status.code,
details: body ?? "")
if postResponse.statusCode != HTTPCode.Accepted.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
details: data.asText())
}
// Figure out where to upload the blob
let uploadLocation = try uploadLocationFromResponse(postResponse)
// Upload the blob
let headers = [
"Content-Length": "\(fromData.count)",
"Content-Type": "application/octet-stream",
]
var uploadLocation = try uploadLocationFromResponse(postResponse)
let digest = Digest.hash(fromData)
let parameters = [
"digest": digest,
]
let putResponse = try await rawRequest(.PUT, uploadLocation, headers: headers, parameters: parameters,
body: fromData)
if putResponse.status != .created {
let body = try await postResponse.body.readTextResponse()
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT)", code: putResponse.status.code,
details: body ?? "")
if chunkSizeMb == 0 {
// monolithic upload
let (data, response) = try await dataRequest(
.PUT,
uploadLocation,
headers: [
"Content-Type": "application/octet-stream",
],
parameters: ["digest": digest],
body: fromData
)
if response.statusCode != HTTPCode.Created.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
code: response.statusCode, details: data.asText())
}
return digest
}
// chunked upload
var uploadedBytes = 0
let chunks = fromData.chunks(ofCount: chunkSizeMb == 0 ? fromData.count : chunkSizeMb * 1_000_000)
for (index, chunk) in chunks.enumerated() {
let lastChunk = index == (chunks.count - 1)
let (data, response) = try await dataRequest(
lastChunk ? .PUT : .PATCH,
uploadLocation,
headers: [
"Content-Type": "application/octet-stream",
"Content-Range": "\(uploadedBytes)-\(uploadedBytes + chunk.count - 1)",
],
parameters: lastChunk ? ["digest": digest] : [:],
body: chunk
)
// always accept both statuses since AWS ECR is not following specification
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
code: response.statusCode, details: data.asText())
}
uploadedBytes += chunk.count
// Update location for the next chunk
uploadLocation = try uploadLocationFromResponse(response)
}
return digest
}
public func pullBlob(_ digest: String, handler: (ByteBuffer) throws -> Void) async throws {
let response = try await endpointRequest(.GET, "\(namespace)/blobs/\(digest)")
if response.status != .ok {
let body = try await response.body.readTextResponse()
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.status.code,
details: body ?? "")
public func pullBlob(_ digest: String, handler: (Data) throws -> Void) async throws {
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
if response.statusCode != HTTPCode.Ok.rawValue {
let body = try await channel.asData().asText()
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
details: body)
}
for try await part in response.body {
try handler(part)
for try await part in channel {
try Task.checkCancellation()
try handler(Data(part))
}
}
private func endpointRequest(
_ method: HTTPMethod,
_ endpoint: String,
headers: Dictionary<String, String> = Dictionary(),
parameters: Dictionary<String, String> = Dictionary(),
body: Data? = nil
) async throws -> HTTPClientResponse {
private func endpointURL(_ endpoint: String) -> URLComponents {
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,
_ urlComponents: URLComponents,
headers: Dictionary<String, String> = Dictionary(),
parameters: Dictionary<String, String> = Dictionary(),
body: Data? = nil,
doAuth: Bool = true
) async throws -> (Data, HTTPURLResponse) {
let (channel, response) = try await channelRequest(method, urlComponents,
headers: headers, parameters: parameters, body: body, doAuth: doAuth)
return (try await channel.asData(), response)
}
private func channelRequest(
_ method: HTTPMethod,
_ urlComponents: URLComponents,
headers: Dictionary<String, String> = Dictionary(),
parameters: Dictionary<String, String> = Dictionary(),
body: Data? = nil
) async throws -> HTTPClientResponse {
body: Data? = nil,
doAuth: Bool = true,
viaFile: Bool = false
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
var urlComponents = urlComponents
if urlComponents.queryItems == nil {
if urlComponents.queryItems == nil && !parameters.isEmpty {
urlComponents.queryItems = []
}
urlComponents.queryItems?.append(contentsOf: parameters.map { key, value -> URLQueryItem in
URLQueryItem(name: key, value: value)
})
var request = HTTPClientRequest(url: urlComponents.string!)
request.method = method
var request = URLRequest(url: urlComponents.url!)
request.httpMethod = method.rawValue
for (key, value) in headers {
request.headers.add(name: key, value: value)
request.addValue(value, forHTTPHeaderField: key)
}
if body != nil {
request.body = HTTPClientRequest.Body.bytes(body!)
if let body = body {
request.addValue("\(body.count)", forHTTPHeaderField: "Content-Length")
request.httpBody = body
}
// Invalidate token if it has expired
if currentAuthToken?.isValid == false {
if currentAuthToken?.isValid() == false {
currentAuthToken = nil
}
var response = try await authAwareRequest(request: request)
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
if response.status == .unauthorized {
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
_ = try await channel.asData()
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
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")
}
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
if wwwAuthenticate.scheme == "Basic" {
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
currentAuthToken = BasicAuthentication(user: user, password: password)
}
return
}
if wwwAuthenticate.scheme != "Bearer" {
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
@@ -281,29 +350,37 @@ class Registry {
var headers: Dictionary<String, String> = Dictionary()
if let (user, password) = try Credentials.retrieveKeychain(host: baseURL.host!) {
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
headers["Authorization"] = "Basic \(encodedCredentials!)"
}
let response = try await rawRequest(.GET, authenticateURL, headers: headers)
if response.status != .ok {
let body = try await response.body.readTextResponse() ?? ""
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.status.code) "
+ "while retrieving an authentication token", details: body)
let (data, response) = try await dataRequest(.GET, authenticateURL, headers: headers, doAuth: false)
if response.statusCode != HTTPCode.Ok.rawValue {
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.statusCode) "
+ "while retrieving an authentication token", details: data.asText())
}
let bodyData = try await response.body.readResponse()
currentAuthToken = try TokenResponse.parse(fromData: bodyData)
currentAuthToken = try TokenResponse.parse(fromData: data)
}
private func authAwareRequest(request: HTTPClientRequest) async throws -> HTTPClientResponse {
private func lookupCredentials(host: String) throws -> (String, String)? {
for provider in credentialsProviders {
if let (user, password) = try provider.retrieve(host: host) {
return (user, password)
}
}
return nil
}
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
var request = request
if let token = currentAuthToken {
request.headers.add(name: "Authorization", value: "Bearer \(token.token)")
let (name, value) = token.header()
request.addValue(value, forHTTPHeaderField: name)
}
return try await httpClient.execute(request, deadline: .distantFuture)
return try await Fetcher.fetch(request, viaFile: viaFile)
}
}
+105 -59
View File
@@ -1,83 +1,129 @@
import Foundation
import Parsing
import Antlr4
struct Tail {
enum TailType {
struct Reference: Comparable, Hashable, CustomStringConvertible {
enum ReferenceType: Comparable {
case Tag
case Digest
}
var type: TailType
var value: String
}
let type: ReferenceType
let value: String
struct RemoteName: Comparable, CustomStringConvertible {
var host: String
var namespace: String
var reference: String = "latest"
var fullyQualifiedReference: String {
var fullyQualified: String {
get {
if reference.starts(with: "sha256:") {
return "@" + reference
switch type {
case .Tag:
return ":" + value
case .Digest:
return "@" + value
}
return ":" + reference
}
}
init(host: String, namespace: String, reference: String) {
init(tag: String) {
type = .Tag
value = tag
}
init(digest: String) {
type = .Digest
value = digest
}
static func <(lhs: Reference, rhs: Reference) -> Bool {
if lhs.type != rhs.type {
return lhs.type < rhs.type
} else {
return lhs.value < rhs.value
}
}
var description: String {
get {
fullyQualified
}
}
}
class ReferenceCollector: ReferenceBaseListener {
var host: String? = nil
var port: String? = nil
var namespace: String? = nil
var reference: String? = nil
override func exitHost(_ ctx: ReferenceParser.HostContext) {
host = ctx.getText()
}
override func exitPort(_ ctx: ReferenceParser.PortContext) {
port = ctx.getText()
}
override func exitNamespace(_ ctx: ReferenceParser.NamespaceContext) {
namespace = ctx.getText()
}
override func exitReference(_ ctx: ReferenceParser.ReferenceContext) {
reference = ctx.getText()
}
}
class ErrorCollector: BaseErrorListener {
var error: String? = nil
override func syntaxError<T>(_ recognizer: Recognizer<T>, _ offendingSymbol: AnyObject?, _ line: Int, _ charPositionInLine: Int, _ msg: String, _ e: AnyObject?) {
if error == nil {
error = "\(msg) (character \(charPositionInLine + 1))"
}
}
}
struct RemoteName: Comparable, Hashable, CustomStringConvertible {
var host: String
var namespace: String
var reference: Reference
init(host: String, namespace: String, reference: Reference) {
self.host = host
self.namespace = namespace
self.reference = reference
}
init(_ name: String) throws {
let csNormal = [
UInt8(ascii: "a")...UInt8(ascii: "z"),
UInt8(ascii: "A")...UInt8(ascii: "Z"),
UInt8(ascii: "0")...UInt8(ascii: "9"),
].asCharacterSet().union(CharacterSet(charactersIn: "_-."))
let errorCollector = ErrorCollector()
let inputStream = ANTLRInputStream(Array(name.unicodeScalars), name.count)
let lexer = ReferenceLexer(inputStream)
lexer.removeErrorListeners()
lexer.addErrorListener(errorCollector)
let csHex = [
UInt8(ascii: "a")...UInt8(ascii: "f"),
UInt8(ascii: "0")...UInt8(ascii: "9"),
].asCharacterSet()
let tokenStream = CommonTokenStream(lexer)
let parser = try ReferenceParser(tokenStream)
parser.removeErrorListeners()
parser.addErrorListener(errorCollector)
let parser = Parse {
Consumed {
csNormal
Optionally {
":"
Digits()
}
}
"/"
csNormal.union(CharacterSet(charactersIn: "/"))
Optionally {
OneOf {
Parse {
":"
csNormal.map {
Tail(type: .Tag, value: String($0))
}
}
Parse {
"@sha256:"
csHex.map {
Tail(type: .Digest, value: "sha256:" + String($0))
}
}
}
}
End()
let referenceCollector = ReferenceCollector()
try ParseTreeWalker().walk(referenceCollector, try parser.root())
if let error = errorCollector.error {
throw RuntimeError("failed to parse remote name: \(error)")
}
let result = try parser.parse(name)
host = String(result.0)
namespace = String(result.1)
if let tail = result.2 {
reference = tail.value
host = referenceCollector.host!
if let port = referenceCollector.port {
host += ":" + port
}
namespace = referenceCollector.namespace!
if let reference = referenceCollector.reference {
if reference.starts(with: "@sha256:") {
self.reference = Reference(digest: String(reference.dropFirst(1)))
} else if reference.starts(with: ":") {
self.reference = Reference(tag: String(reference.dropFirst(1)))
} else {
throw RuntimeError("failed to parse remote name: unknown reference format")
}
} else {
self.reference = Reference(tag: "latest")
}
}
@@ -92,7 +138,7 @@ struct RemoteName: Comparable, CustomStringConvertible {
}
var description: String {
"\(host)/\(namespace)\(fullyQualifiedReference)"
"\(host)/\(namespace)\(reference.fullyQualified)"
}
}
+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("\(message): \(details)")
}
return (true, result)
}
}
@@ -0,0 +1,13 @@
import Foundation
struct PassphraseGenerator: Sequence {
func makeIterator() -> PassphraseIterator {
PassphraseIterator()
}
}
struct PassphraseIterator: IteratorProtocol {
mutating func next() -> String? {
passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
}
}
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
import Foundation
enum Architecture: String, Codable {
case arm64
case amd64
}
func CurrentArchitecture() -> Architecture {
#if arch(arm64)
return .arm64
#elseif arch(x86_64)
return .amd64
#endif
}
+97
View File
@@ -0,0 +1,97 @@
import Virtualization
struct Darwin: Platform {
var ecid: VZMacMachineIdentifier
var hardwareModel: VZMacHardwareModel
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
self.ecid = ecid
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
}
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()]
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import Virtualization
@available(macOS 13, *)
struct Linux: Platform {
func os() -> OS {
.linux
}
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
let result = VZEFIBootLoader()
result.variableStore = VZEFIVariableStore(url: nvramURL)
return result
}
func platform(nvramURL: URL) -> VZPlatformConfiguration {
VZGenericPlatformConfiguration()
}
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZVirtioGraphicsDeviceConfiguration()
result.scanouts = [
VZVirtioGraphicsScanoutConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height
)
]
return result
}
func pointingDevices() -> [VZPointingDeviceConfiguration] {
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
}
+6
View File
@@ -0,0 +1,6 @@
import Virtualization
enum OS: String, Codable {
case darwin
case linux
}
+9
View File
@@ -0,0 +1,9 @@
import Virtualization
protocol Platform: Codable {
func os() -> OS
func bootLoader(nvramURL: URL) throws -> VZBootLoader
func platform(nvramURL: URL) -> VZPlatformConfiguration
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
func pointingDevices() -> [VZPointingDeviceConfiguration]
}
+12
View File
@@ -0,0 +1,12 @@
import Foundation
protocol PrunableStorage {
func prunables() throws -> [Prunable]
}
protocol Prunable {
var url: URL { get }
func delete() throws
func accessDate() throws -> Date
func sizeBytes() throws -> Int
}
+29 -1
View File
@@ -1,5 +1,14 @@
import ArgumentParser
import Foundation
import Puppy
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
struct Root: AsyncParsableCommand {
@@ -11,16 +20,23 @@ struct Root: AsyncParsableCommand {
Clone.self,
Run.self,
Set.self,
Get.self,
List.self,
Login.self,
IP.self,
Pull.self,
Push.self,
Prune.self,
Rename.self,
Stop.self,
Delete.self,
])
public static func main() async throws {
// Handle cancellation by Ctrl+C
// Ensure the default SIGINT handled is disabled,
// otherwise there's a race between two handlers
signal(SIGINT, SIG_IGN);
// Handle cancellation by Ctrl+C ourselves
let task = withUnsafeCurrentTask { $0 }!
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSrc.setEventHandler {
@@ -28,10 +44,22 @@ struct Root: AsyncParsableCommand {
}
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
do {
var command = try parseAsRoot()
// Run garbage-collection before each command (shouldn't take too long)
try Config().gc()
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
+25
View File
@@ -0,0 +1,25 @@
import Foundation
extension URL {
func accessDate() throws -> Date {
let attrs = try resourceValues(forKeys: [.contentAccessDateKey])
return attrs.contentAccessDate!
}
func updateAccessDate(_ accessDate: Date = Date()) throws {
let attrs = try resourceValues(forKeys: [.contentAccessDateKey])
let modificationDate = attrs.contentAccessDate!
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
let ret = utimes(path, times)
if ret != 0 {
throw RuntimeError("utimes(2) failed: \(ret.explanation())")
}
}
}
extension Date {
func asTimeval() -> timeval {
timeval(tv_sec: Int(timeIntervalSince1970), tv_usec: 0)
}
}
+15
View File
@@ -0,0 +1,15 @@
import Foundation
extension URL: Prunable {
var url: URL {
self
}
func delete() throws {
try FileManager.default.removeItem(at: self)
}
func sizeBytes() throws -> Int {
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
}
}
+17
View File
@@ -5,3 +5,20 @@ extension Collection {
indices.contains(index) ? self[index] : nil
}
}
func resolveBinaryPath(_ name: String) -> URL? {
guard let path = ProcessInfo.processInfo.environment["PATH"] else {
return nil
}
for pathComponent in path.split(separator: ":") {
let url = URL(fileURLWithPath: String(pathComponent))
.appendingPathComponent(name, isDirectory: false)
if FileManager.default.fileExists(atPath: url.path) {
return url
}
}
return nil
}
+1
View File
@@ -5,6 +5,7 @@ import Dynamic
// Kudos to @saagarjha's VirtualApple for finding about _VZVirtualMachineStartOptions
extension VZVirtualMachine {
@available(macOS 12, *)
func start(_ recovery: Bool) async throws {
if !recovery {
// just use the regular API
+182 -69
View File
@@ -1,5 +1,6 @@
import Foundation
import Virtualization
import AsyncAlgorithms
struct UnsupportedRestoreImageError: Error {
}
@@ -10,6 +11,17 @@ struct NoMainScreenFoundError: Error {
struct DownloadFailed: Error {
}
struct UnsupportedOSError: Error, CustomStringConvertible {
let description: String
init(_ what: String, _ plural: String) {
description = "error: \(what) \(plural) only supported on hosts running macOS 13.0 (Ventura) or newer"
}
}
struct UnsupportedArchitectureError: Error {
}
class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Virtualization.Framework's virtual machine
@Published var virtualMachine: VZVirtualMachine
@@ -23,63 +35,113 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// VM's config
var config: VMConfig
init(vmDir: VMDirectory) throws {
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
var network: Network
init(vmDir: VMDirectory,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
directoryShares: [DirectoryShare] = []
) throws {
name = vmDir.name
config = try VMConfig.init(fromURL: vmDir.configURL)
let configuration = try VM.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config)
if config.arch != CurrentArchitecture() {
throw UnsupportedArchitectureError()
}
// Initialize the virtual machine and its configuration
self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
nvramURL: vmDir.nvramURL, vmConfig: config,
network: network, additionalDiskAttachments: additionalDiskAttachments,
directoryShares: directoryShares)
virtualMachine = VZVirtualMachine(configuration: configuration)
super.init()
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...")
let image = try await withCheckedThrowingContinuation { continuation in
VZMacOSRestoreImage.fetchLatestSupported() { result in
continuation.resume(with: result)
}
}
let ipswCacheFolder = Config.tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
try FileManager.default.createDirectory(at: ipswCacheFolder, withIntermediateDirectories: true)
let expectedIPSWLocation = ipswCacheFolder.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false)
if FileManager.default.fileExists(atPath: expectedIPSWLocation.path) {
defaultLogger.appendNewLine("Using cached *.ipsw file...")
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
return image.url
}
init(vmDir: VMDirectory, ipswURL: URL?, diskSizeGB: UInt8) async throws {
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
var inFinalState: Bool {
get {
virtualMachine.state == VZVirtualMachine.State.stopped ||
virtualMachine.state == VZVirtualMachine.State.paused ||
virtualMachine.state == VZVirtualMachine.State.error
}
}
init(
vmDir: VMDirectory,
ipswURL: URL,
diskSizeGB: UInt16,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
) async throws {
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
// that match both the image and our platform
@@ -94,7 +156,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
// Create NVRAM
let auxStorage = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
// Create disk
try vmDir.resizeDisk(diskSizeGB)
@@ -102,7 +164,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
name = vmDir.name
// Create config
config = VMConfig(
hardwareModel: requirements.hardwareModel,
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
cpuCountMin: requirements.minimumSupportedCPUCount,
memorySizeMin: requirements.minimumSupportedMemorySize
)
@@ -111,16 +173,19 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
try config.save(toURL: vmDir.configURL)
// Initialize the virtual machine and its configuration
let configuration = try VM.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config)
self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
vmConfig: config, network: network,
additionalDiskAttachments: additionalDiskAttachments,
directoryShares: [])
virtualMachine = VZVirtualMachine(configuration: configuration)
super.init()
virtualMachine.delegate = self
// Run automated installation
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
DispatchQueue.main.async {
DispatchQueue.main.async { [ipswURL] in
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: ipswURL)
defaultLogger.appendNewLine("Installing OS...")
@@ -133,8 +198,37 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
}
@available(macOS 13, *)
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
// Create NVRAM
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
// Create disk
try vmDir.resizeDisk(diskSizeGB)
// Create config
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024)
try config.save(toURL: vmDir.configURL)
return try VM(vmDir: vmDir)
}
func run(_ recovery: Bool) async throws {
try await virtualMachine.start(recovery)
try network.run(sema)
DispatchQueue.main.sync {
Task {
if #available(macOS 13, *) {
// new API introduced in Ventura
let startOptions = VZMacOSVirtualMachineStartOptions()
startOptions.startUpFromMacOSRecovery = recovery
try await virtualMachine.start(options: startOptions)
} else {
// use method that also available on Monterey
try await virtualMachine.start(recovery)
}
}
}
await withTaskCancellationHandler(operation: {
sema.wait()
@@ -149,61 +243,74 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
}
}
try await network.stop()
}
static func craftConfiguration(diskURL: URL, auxStorage: VZMacAuxiliaryStorage, vmConfig: VMConfig) throws -> VZVirtualMachineConfiguration {
static func craftConfiguration(
diskURL: URL,
nvramURL: URL,
vmConfig: VMConfig,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
directoryShares: [DirectoryShare]
) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration()
// Boot loader
configuration.bootLoader = VZMacOSBootLoader()
configuration.bootLoader = try vmConfig.platform.bootLoader(nvramURL: nvramURL)
// CPU and memory
configuration.cpuCount = vmConfig.cpuCount
configuration.memorySize = vmConfig.memorySize
// Platform
let platform = VZMacPlatformConfiguration()
platform.machineIdentifier = vmConfig.ecid
platform.auxiliaryStorage = auxStorage
platform.hardwareModel = vmConfig.hardwareModel
configuration.platform = platform
configuration.platform = vmConfig.platform.platform(nvramURL: nvramURL)
// Display
let graphicsDeviceConfiguration = VZMacGraphicsDeviceConfiguration()
graphicsDeviceConfiguration.displays = [
VZMacGraphicsDisplayConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height,
pixelsPerInch: vmConfig.display.dpi
)
]
configuration.graphicsDevices = [graphicsDeviceConfiguration]
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
// Audio
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
soundDeviceConfiguration.streams = [VZVirtioSoundDeviceInputStreamConfiguration(), VZVirtioSoundDeviceOutputStreamConfiguration()]
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
configuration.audioDevices = [soundDeviceConfiguration]
// Keyboard and mouse
configuration.keyboards = [VZUSBKeyboardConfiguration()]
configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
configuration.pointingDevices = vmConfig.platform.pointingDevices()
// Networking
let vio = VZVirtioNetworkDeviceConfiguration()
vio.attachment = VZNATNetworkDeviceAttachment()
vio.attachment = network.attachment()
vio.macAddress = vmConfig.macAddress
configuration.networkDevices = [vio]
// Storage
let attachment = try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
let storage = VZVirtioBlockDeviceConfiguration(attachment: attachment)
configuration.storageDevices = [storage]
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
attachments.append(contentsOf: additionalDiskAttachments)
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
// Entropy
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
// Directory share
if #available(macOS 13, *) {
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()
return configuration
@@ -215,7 +322,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
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()
}
@@ -224,3 +331,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
sema.signal()
}
}
struct DirectoryShare {
let name: String
let path: URL
let readOnly: Bool
}
+37 -40
View File
@@ -16,43 +16,46 @@ class LessThanMinimalResourcesError: NSObject, LocalizedError {
enum CodingKeys: String, CodingKey {
case version
case ecid
case hardwareModel
case os
case arch
case cpuCountMin
case cpuCount
case memorySizeMin
case memorySize
case macAddress
case display
// macOS-specific keys
case ecid
case hardwareModel
}
struct VMDisplayConfig: Codable {
var width: Int = 1024
var height: Int = 768
var dpi: Int = 72
}
struct VMConfig: Codable {
var version: Int = 1
var ecid: VZMacMachineIdentifier
var hardwareModel: VZMacHardwareModel
var os: OS
var arch: Architecture
var platform: Platform
var cpuCountMin: Int
private(set) var cpuCount: Int
var memorySizeMin: UInt64
private(set) var memorySize: UInt64
var macAddress: VZMACAddress
var display: VMDisplayConfig = VMDisplayConfig()
init(
ecid: VZMacMachineIdentifier = VZMacMachineIdentifier(),
hardwareModel: VZMacHardwareModel,
cpuCountMin: Int,
memorySizeMin: UInt64,
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
platform: Platform,
cpuCountMin: Int,
memorySizeMin: UInt64,
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
) {
self.ecid = ecid
self.hardwareModel = hardwareModel
self.os = platform.os()
self.arch = CurrentArchitecture()
self.platform = platform
self.macAddress = macAddress
self.cpuCountMin = cpuCountMin
self.memorySizeMin = memorySizeMin
@@ -60,12 +63,16 @@ struct VMConfig: Codable {
memorySize = memorySizeMin
}
init(fromData: Data) throws {
self = try JSONDecoder().decode(VMConfig.self, from: fromData)
init(fromJSON: Data) throws {
self = try Config.jsonDecoder().decode(Self.self, from: fromJSON)
}
init(fromURL: URL) throws {
self = try Self(fromData: try Data(contentsOf: fromURL))
self = try Self(fromJSON: try Data(contentsOf: fromURL))
}
func toJSON() throws -> Data {
try Config.jsonEncoder().encode(self)
}
func save(toURL: URL) throws {
@@ -78,29 +85,18 @@ struct VMConfig: Codable {
let container = try decoder.container(keyedBy: CodingKeys.self)
version = try container.decode(Int.self, forKey: .version)
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")
os = try container.decodeIfPresent(OS.self, forKey: .os) ?? .darwin
arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
switch os {
case .darwin:
platform = try Darwin(from: decoder)
case .linux:
if #available(macOS 13, *) {
platform = try Linux(from: decoder)
} else {
throw UnsupportedOSError("Linux VMs", "are")
}
}
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
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
cpuCount = try container.decode(Int.self, forKey: .cpuCount)
memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin)
@@ -122,8 +118,9 @@ struct VMConfig: Codable {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(version, forKey: .version)
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
try container.encode(os, forKey: .os)
try container.encode(arch, forKey: .arch)
try platform.encode(to: encoder)
try container.encode(cpuCountMin, forKey: .cpuCountMin)
try container.encode(cpuCount, forKey: .cpuCount)
try container.encode(memorySizeMin, forKey: .memorySizeMin)
+27 -24
View File
@@ -35,8 +35,8 @@ extension VMDirectory {
throw OCIError.FailedToCreateVmFile
}
let configFile = try FileHandle(forWritingTo: configURL)
try await registry.pullBlob(configLayers.first!.digest) { buffer in
configFile.write(Data(buffer: buffer))
try await registry.pullBlob(configLayers.first!.digest) { data in
configFile.write(data)
}
try configFile.close()
@@ -70,8 +70,7 @@ extension VMDirectory {
ProgressObserver(progress).log(defaultLogger)
for diskLayer in diskLayers {
try await registry.pullBlob(diskLayer.digest) { buffer in
let data = Data(buffer: buffer)
try await registry.pullBlob(diskLayer.digest) { data in
try filter.write(data)
progress.completedUnitCount += Int64(data.count)
}
@@ -92,19 +91,20 @@ extension VMDirectory {
throw OCIError.FailedToCreateVmFile
}
let nvram = try FileHandle(forWritingTo: nvramURL)
try await registry.pullBlob(nvramLayers.first!.digest) { buffer in
nvram.write(Data(buffer: buffer))
try await registry.pullBlob(nvramLayers.first!.digest) { data in
nvram.write(data)
}
try nvram.close()
}
func pushToRegistry(registry: Registry, references: [String]) async throws {
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int) async throws -> RemoteName {
var layers = Array<OCIManifestLayer>()
// Read VM's config and push it as blob
let config = try VMConfig(fromURL: configURL)
let configJSON = try JSONEncoder().encode(config)
let configDigest = try await registry.pushBlob(fromData: configJSON)
defaultLogger.appendNewLine("pushing config...")
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
layers.append(OCIManifestLayer(mediaType: Self.configMediaType, size: configJSON.count, digest: configDigest))
// Progress
@@ -116,37 +116,37 @@ extension VMDirectory {
// Read VM's compressed disk as chunks
// and sequentially upload them as blobs
let disk = try FileHandle(forReadingFrom: diskURL)
let compressingFilter = try InputFilter<Data>(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { _ in
let data = try disk.read(upToCount: Self.bufferSizeBytes)
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
let mappedDiskSize = mappedDisk.count
var mappedDiskReadOffset = 0
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
let bytesRead = min(length, mappedDiskSize - mappedDiskReadOffset)
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
mappedDiskReadOffset += bytesRead
progress.completedUnitCount += Int64(data?.count ?? 0)
progress.completedUnitCount = Int64(mappedDiskReadOffset)
return data
}
while let chunk = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
let chunkDigest = try await registry.pushBlob(fromData: chunk)
layers.append(OCIManifestLayer(mediaType: Self.diskMediaType, size: chunk.count, digest: chunkDigest))
while let compressedLayerData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
let layerDigest = try await registry.pushBlob(fromData: compressedLayerData, chunkSizeMb: chunkSizeMb)
layers.append(OCIManifestLayer(mediaType: Self.diskMediaType, size: compressedLayerData.count, digest: layerDigest))
}
// Read VM's NVRAM and push it as blob
defaultLogger.appendNewLine("pushing NVRAM...")
let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()!
let nvramDigest = try await registry.pushBlob(fromData: nvram)
let nvramDigest = try await registry.pushBlob(fromData: nvram, chunkSizeMb: chunkSizeMb)
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
// Craft a stub OCI config for Docker Hub compatibility
struct OCIConfig: Codable {
var architecture: String = "arm64"
var os: String = "darwin"
}
let ociConfigJSON = try JSONEncoder().encode(OCIConfig())
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON)
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
layers: layers
layers: layers,
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
)
// Manifest
@@ -155,6 +155,9 @@ extension VMDirectory {
_ = try await registry.pushManifest(reference: reference, manifest: manifest)
}
let pushedReference = Reference(digest: try manifest.digest())
return RemoteName(host: registry.baseURL.host!, namespace: registry.namespace, reference: pushedReference)
}
}
+44 -10
View File
@@ -1,13 +1,7 @@
import Foundation
import Virtualization
struct UninitializedVMDirectoryError: Error {
}
struct AlreadyInitializedVMDirectoryError: Error {
}
struct VMDirectory {
struct VMDirectory: Prunable {
var baseURL: URL
var configURL: URL {
@@ -20,10 +14,25 @@ struct VMDirectory {
baseURL.appendingPathComponent("nvram.bin")
}
var explicitlyPulledMark: URL {
baseURL.appendingPathComponent(".explicitly-pulled")
}
var name: String {
baseURL.lastPathComponent
}
var url: URL {
baseURL
}
static func temporary() throws -> VMDirectory {
let tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
return VMDirectory(baseURL: tmpDir)
}
var initialized: Bool {
FileManager.default.fileExists(atPath: configURL.path) &&
FileManager.default.fileExists(atPath: diskURL.path) &&
@@ -32,7 +41,7 @@ struct VMDirectory {
func initialize(overwrite: Bool = false) throws {
if !overwrite && initialized {
throw AlreadyInitializedVMDirectoryError()
throw RuntimeError("VM directory is already initialized, preventing overwrite")
}
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
@@ -43,8 +52,13 @@ struct VMDirectory {
}
func validate() throws {
if !FileManager.default.fileExists(atPath: baseURL.path) {
throw RuntimeError("the specified VM does not exist")
}
if !initialized {
throw UninitializedVMDirectoryError()
throw RuntimeError("VM is missing some of its files (\(configURL.lastPathComponent),"
+ " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))")
}
}
@@ -61,7 +75,7 @@ struct VMDirectory {
try newVMConfig.save(toURL: to.configURL)
}
func resizeDisk(_ sizeGB: UInt8) throws {
func resizeDisk(_ sizeGB: UInt16) throws {
if !FileManager.default.fileExists(atPath: diskURL.path) {
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
}
@@ -70,4 +84,24 @@ struct VMDirectory {
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
try diskFileHandle.close()
}
func delete() throws {
try FileManager.default.removeItem(at: baseURL)
}
func accessDate() throws -> Date {
try baseURL.accessDate()
}
func sizeBytes() throws -> Int {
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
}
func markExplicitlyPulled() {
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
}
func isExplicitlyPulled() -> Bool {
FileManager.default.fileExists(atPath: explicitlyPulledMark.path)
}
}
+10 -1
View File
@@ -1,7 +1,7 @@
import Foundation
class VMStorageLocal {
let baseURL: URL = Config.tartHomeDir.appendingPathComponent("vms", isDirectory: true)
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
private func vmURL(_ name: String) -> URL {
baseURL.appendingPathComponent(name, isDirectory: true)
@@ -27,6 +27,15 @@ class VMStorageLocal {
return vmDir
}
func move(_ name: String, from: VMDirectory) throws {
_ = try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
_ = try FileManager.default.replaceItemAt(vmURL(name), withItemAt: from.baseURL)
}
func rename(_ name: String, _ newName: String) throws {
_ = try FileManager.default.replaceItemAt(vmURL(newName), withItemAt: vmURL(name))
}
func delete(_ name: String) throws {
try FileManager.default.removeItem(at: vmURL(name))
}
+150 -24
View File
@@ -1,12 +1,16 @@
import Foundation
class VMStorageOCI {
let baseURL = Config.tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
class VMStorageOCI: PrunableStorage {
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
private func vmURL(_ name: RemoteName) -> URL {
baseURL.appendingRemoteName(name)
}
private func hostDirectoryURL(_ name: RemoteName) -> URL {
baseURL.appendingHost(name)
}
func exists(_ name: RemoteName) -> Bool {
VMDirectory(baseURL: vmURL(name)).initialized
}
@@ -16,6 +20,8 @@ class VMStorageOCI {
try vmDir.validate()
try vmDir.baseURL.updateAccessDate()
return vmDir
}
@@ -27,12 +33,61 @@ class VMStorageOCI {
return vmDir
}
func delete(_ name: RemoteName) throws {
try FileManager.default.removeItem(at: vmURL(name))
func move(_ name: RemoteName, from: VMDirectory) throws{
let targetURL = vmURL(name)
// Pre-create intermediate directories (e.g. creates ~/.tart/cache/OCIs/github.com/org/repo/
// for github.com/org/repo:latest)
try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
}
func list() throws -> [(String, VMDirectory)] {
var result: [(String, VMDirectory)] = Array()
func delete(_ name: RemoteName) throws {
try FileManager.default.removeItem(at: vmURL(name))
try gc()
}
func gc() throws {
var refCounts = Dictionary<URL, UInt>()
guard let enumerator = FileManager.default.enumerator(at: baseURL,
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
return
}
for case let foundURL as URL in enumerator {
let isSymlink = try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink!
// Perform garbage collection for tag-based images
// with broken outgoing references
if isSymlink && foundURL == foundURL.resolvingSymlinksInPath() {
try FileManager.default.removeItem(at: foundURL)
continue
}
let vmDir = VMDirectory(baseURL: foundURL.resolvingSymlinksInPath())
if !vmDir.initialized {
continue
}
refCounts[vmDir.baseURL] = (refCounts[vmDir.baseURL] ?? 0) + (isSymlink ? 1 : 0)
}
// Perform garbage collection for digest-based images
// with no incoming references
for (baseURL, incRefCount) in refCounts {
let vmDir = VMDirectory(baseURL: baseURL)
if !vmDir.isExplicitlyPulled() && incRefCount == 0 {
try FileManager.default.removeItem(at: baseURL)
}
}
}
func list() throws -> [(String, VMDirectory, Bool)] {
var result: [(String, VMDirectory, Bool)] = Array()
guard let enumerator = FileManager.default.enumerator(at: baseURL,
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
@@ -49,42 +104,109 @@ class VMStorageOCI {
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
var name: String
if try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink! {
let isSymlink = try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink!
if isSymlink {
name = parts.joined(separator: ":")
} else {
name = parts.joined(separator: "@")
}
result.append((name, vmDir))
result.append((name, vmDir, isSymlink))
}
return result
}
func prunables() throws -> [Prunable] {
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
}
func pull(_ name: RemoteName, registry: Registry) async throws {
defaultLogger.appendNewLine("pulling manifest...")
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference)
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
// Create directory for manifest's digest
var digestName = name
digestName.reference = Digest.hash(manifestData)
if !exists(digestName) {
let vmDir = try create(digestName)
try await vmDir.pullFromRegistry(registry: registry, manifest: manifest)
} else {
defaultLogger.appendNewLine("\(digestName.reference) image is already cached! creating a symlink...")
let digestName = RemoteName(host: name.host, namespace: name.namespace,
reference: Reference(digest: Digest.hash(manifestData)))
// Ensure that host directory for given RemoteName exists in OCI storage
let hostDirectoryURL = hostDirectoryURL(digestName)
try FileManager.default.createDirectory(at: hostDirectoryURL, withIntermediateDirectories: true)
// Acquire a lock on it to prevent concurrent pulls for a single host
let lock = try FileLock(lockURL: hostDirectoryURL)
let sucessfullyLocked = try lock.trylock()
if !sucessfullyLocked {
print("waiting for lock...")
try lock.lock()
}
defer { try! lock.unlock() }
if Task.isCancelled {
throw CancellationError()
}
// Create directory for reference if it's different
if digestName != name {
// Overwrite the old symbolic link
if FileManager.default.fileExists(atPath: vmURL(name).path) {
try FileManager.default.removeItem(at: vmURL(name))
if !exists(digestName) {
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
// Try to reclaim some cache space if we know the VM size in advance
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey])
let capacityImportant = attrs.volumeAvailableCapacityForImportantUsage!
let capacityAvailable = attrs.volumeAvailableCapacity!
let availableCapacityBytes = max(UInt64(capacityImportant), UInt64(capacityAvailable))
if capacityImportant == 0 || capacityAvailable == 0 {
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.
// Let's validate to avoid unnecessary pruning.
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 FileManager.default.createSymbolicLink(at: vmURL(name), withDestinationURL: vmURL(digestName))
try await withTaskCancellationHandler(operation: {
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
try move(digestName, from: tmpVMDir)
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
} else {
defaultLogger.appendNewLine("\(digestName) image is already cached! creating a symlink...")
}
if name != digestName {
// Create new or overwrite the old symbolic link
try link(from: digestName, to: name)
} else {
// Ensure that images pulled by content digest
// are excluded from garbage collection
VMDirectory(baseURL: vmURL(name)).markExplicitlyPulled()
}
}
func link(from: RemoteName, to: RemoteName) throws {
if FileManager.default.fileExists(atPath: vmURL(to).path) {
try FileManager.default.removeItem(at: vmURL(to))
}
try FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
try gc()
}
}
@@ -92,10 +214,14 @@ extension URL {
func appendingRemoteName(_ name: RemoteName) -> URL {
var result: URL = self
for pathComponent in (name.host + "/" + name.namespace + "/" + name.reference).split(separator: "/") {
for pathComponent in (name.host + "/" + name.namespace + "/" + name.reference.value).split(separator: "/") {
result = result.appendingPathComponent(String(pathComponent))
}
return result
}
func appendingHost(_ name: RemoteName) -> URL {
self.appendingPathComponent(name.host, isDirectory: true)
}
}
+38
View File
@@ -0,0 +1,38 @@
import Foundation
import Dynamic
import Virtualization
class FullFledgedVNC: VNC {
let password: String
private let vnc: Dynamic
init(virtualMachine: VZVirtualMachine) {
password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
vnc = Dynamic._VZVNCServer(port: 0, queue: DispatchQueue.global(),
securityConfiguration: securityConfiguration)
vnc.virtualMachine = virtualMachine
vnc.start()
}
func waitForURL() async throws -> URL {
while true {
// Port is 0 shortly after start(),
// but will be initialized later
if let port = vnc.port.asUInt16, port != 0 {
return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
}
// Wait 50 ms.
try await Task.sleep(nanoseconds: 50_000_000)
}
}
func stop() throws {
vnc.stop()
}
deinit {
try? stop()
}
}
+26
View File
@@ -0,0 +1,26 @@
import Foundation
import Dynamic
import Virtualization
class ScreenSharingVNC: VNC {
let vmConfig: VMConfig
init(vmConfig: VMConfig) {
self.vmConfig = vmConfig
}
func waitForURL() async throws -> URL {
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
let ip = try await IP.resolveIP(vmMACAddress, secondsToWait: 60)
if let ip = ip {
return URL(string: "vnc://\(ip)")!
}
throw IPNotFound()
}
func stop() throws {
// nothing to do
}
}
+6
View File
@@ -0,0 +1,6 @@
import Foundation
protocol VNC {
func waitForURL() async throws -> URL
func stop() throws
}
+34
View File
@@ -0,0 +1,34 @@
import XCTest
@testable import tart
final class FileLockTests: XCTestCase {
func testSimple() throws {
// Create a temporary file that will be used as a lock
let url = temporaryFile()
// Make sure this file can be locked and unlocked
let lock = try FileLock(lockURL: url)
try lock.lock()
try lock.unlock()
}
func testDoubleLockResultsInError() throws {
// Create a temporary file that will be used as a lock
let url = temporaryFile()
// Create two locks on a same file and ensure one of them fails
let firstLock = try FileLock(lockURL: url)
try firstLock.lock()
let secondLock = try! FileLock(lockURL: url)
XCTAssertFalse(try secondLock.trylock())
}
private func temporaryFile() -> URL {
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
FileManager.default.createFile(atPath: url.path, contents: nil)
return url
}
}
@@ -0,0 +1,35 @@
import XCTest
import Network
@testable import tart
final class MACAddressResolverTests: XCTestCase {
func testSingleEntry() throws {
let leases = try Leases("""
{
ip_address=1.2.3.4
hw_address=1,00:11:22:33:44:55
}
""")
XCTAssertEqual(IPv4Address("1.2.3.4"),
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
}
func testMultipleEntries() throws {
let leases = try Leases("""
{
ip_address=1.2.3.4
hw_address=1,00:11:22:33:44:55
}
{
ip_address=5.6.7.8
hw_address=1,AA:BB:CC:DD:EE:FF
}
""")
XCTAssertEqual(IPv4Address("1.2.3.4"),
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
XCTAssertEqual(IPv4Address("5.6.7.8"),
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
}
}
+7 -11
View File
@@ -34,26 +34,26 @@ final class RegistryTests: XCTestCase {
// Pull it
var pulledBlob = Data()
try await registry.pullBlob(pushedBlobDigest) { buffer in
pulledBlob.append(Data(buffer: buffer))
try await registry.pullBlob(pushedBlobDigest) { data in
pulledBlob.append(data)
}
// Ensure that both blobs are identical
XCTAssertEqual(pushedBlob, pulledBlob)
}
func testPushPullBlobHuge() async throws {
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)
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))
try await registry.pullBlob(largeBlobDigest) { data in
pulledLargeBlob.append(data)
}
// Ensure that both blobs are identical
@@ -62,11 +62,7 @@ final class RegistryTests: XCTestCase {
func testPushPullManifest() async throws {
// Craft a basic config
struct OCIConfig: Codable {
var architecture: String = "arm64"
var os: String = "darwin"
}
let configData = try JSONEncoder().encode(OCIConfig())
let configData = try OCIConfig().toJSON()
let configDigest = try await registry.pushBlob(fromData: configData)
// Craft a basic layer
+7 -3
View File
@@ -3,13 +3,13 @@ import XCTest
final class RemoteNameTests: XCTestCase {
func testTag() throws {
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: "latest")
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: Reference(tag: "latest"))
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest"))
}
func testComplexTag() throws {
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: "1.2.3-RC-1")
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: Reference(tag: "1.2.3-RC-1"))
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:1.2.3-RC-1"))
}
@@ -18,7 +18,7 @@ final class RemoteNameTests: XCTestCase {
let expectedRemoteName = RemoteName(
host: "ghcr.io",
namespace: "a/b",
reference: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
reference: Reference(digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
)
XCTAssertEqual(expectedRemoteName,
@@ -44,4 +44,8 @@ final class RemoteNameTests: XCTestCase {
// Port must be specified when ":" is used
XCTAssertEqual(try? RemoteName("127.0.0.1:/a/b").host, nil)
}
func testNoPathTraversal() throws {
XCTAssertEqual(try? RemoteName("ghcr.io/a/../b/c:latest"), nil)
}
}
+4 -4
View File
@@ -11,7 +11,7 @@ final class TokenResponseTests: XCTestCase {
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
XCTAssertTrue(tokenResponse.isValid)
XCTAssertTrue(tokenResponse.isValid())
}
func testExpirationBasic() throws {
@@ -23,9 +23,9 @@ final class TokenResponseTests: XCTestCase {
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
XCTAssertTrue(tokenResponse.isValid)
XCTAssertTrue(tokenResponse.isValid())
_ = XCTWaiter.wait(for: [expectation(description: "Wait 3 seconds for the token to become invalid")], timeout: 2)
XCTAssertFalse(tokenResponse.isValid)
XCTAssertFalse(tokenResponse.isValid())
}
func testExpirationWithIssuedAt() throws {
@@ -33,6 +33,6 @@ final class TokenResponseTests: XCTestCase {
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
XCTAssertEqual(Date(timeIntervalSince1970: 3600), tokenResponse.tokenExpiresAt)
XCTAssertFalse(tokenResponse.isValid)
XCTAssertFalse(tokenResponse.isValid())
}
}
+22
View File
@@ -0,0 +1,22 @@
import XCTest
@testable import tart
final class URLAccessDateTests: XCTestCase {
func testGetAndSetAccessTime() throws {
// Create a temporary file
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
var tmpFile = tmpDir.appendingPathComponent(UUID().uuidString)
FileManager.default.createFile(atPath: tmpFile.path, contents: nil)
// Ensure it's access date is different than our desired access date
let arbitraryDate = Date.init(year: 2008, month: 09, day: 28, hour: 23, minute: 15)
XCTAssertNotEqual(arbitraryDate, try tmpFile.accessDate())
// Set our desired access date for a file
try tmpFile.updateAccessDate(arbitraryDate)
// Ensure the access date has changed to our value
tmpFile.removeCachedResourceValue(forKey: .contentAccessDateKey)
XCTAssertEqual(arbitraryDate, try tmpFile.accessDate())
}
}
+12
View File
@@ -0,0 +1,12 @@
source = [".build/arm64-apple-macosx/debug/tart"]
bundle_id = "com.github.cirruslabs.tart"
apple_id {
username = "hello@cirruslabs.org"
password = "@env:AC_PASSWORD"
}
sign {
application_identity = "Developer ID Application: Cirrus Labs, Inc."
entitlements_file = "Resources/tart.entitlements"
}
+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 == ""

Some files were not shown because too many files have changed in this diff Show More