mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-01 19:51:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1380ece108 | ||
|
|
9cdb7087f2 | ||
|
|
c3e37854c3 | ||
|
|
828351a8fb | ||
|
|
f9ac994e18 | ||
|
|
fdeaf979c2 | ||
|
|
d9f1c37cdd | ||
|
|
81253417a4 | ||
|
|
c9caeab098 | ||
|
|
6d7dc40c57 | ||
|
|
15b26f78ae | ||
|
|
d9f4f9e954 | ||
|
|
31635e59d7 | ||
|
|
e061d00afc | ||
|
|
4555dd5824 | ||
|
|
ad9c3c661e | ||
|
|
c27d4a089c | ||
|
|
70f9fcc12e | ||
|
|
456ebc1c7b | ||
|
|
14cbc727ad | ||
|
|
03e6d9345d | ||
|
|
c09cbefdd0 | ||
|
|
3896728eb9 | ||
|
|
de993fbf6d | ||
|
|
f08ddf3855 | ||
|
|
8524d93741 | ||
|
|
833c162187 | ||
|
|
31ba71dad7 | ||
|
|
5e77968989 | ||
|
|
f37372da28 | ||
|
|
e600d2f036 | ||
|
|
b21dbbe3a3 | ||
|
|
ee0fbdd83d | ||
|
|
8961c5189a | ||
|
|
555715588d | ||
|
|
8d096966b4 | ||
|
|
fb954b7cc1 | ||
|
|
8cbcd2285b | ||
|
|
3d0d889c99 | ||
|
|
0e77f14dd7 | ||
|
|
39e1b84423 | ||
|
|
af7530ee50 | ||
|
|
36bb68a72d | ||
|
|
afd707eedf | ||
|
|
c67efb88f3 | ||
|
|
667de2a199 | ||
|
|
44650e9713 | ||
|
|
62ee42de3b | ||
|
|
89301d114e | ||
|
|
dbb33d0651 | ||
|
|
166e3e570f | ||
|
|
7a2c20ba30 | ||
|
|
e90d53eceb | ||
|
|
afbc2e0764 | ||
|
|
0229138bd5 | ||
|
|
4d08e6365e |
+55
-4
@@ -1,13 +1,44 @@
|
||||
use_compute_credits: true
|
||||
|
||||
task:
|
||||
name: Test on Ventura
|
||||
alias: test
|
||||
persistent_worker:
|
||||
labels:
|
||||
name: Mac-Mini-M1
|
||||
build_script: swift test
|
||||
test_script: swift test
|
||||
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: Lint
|
||||
alias: lint
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
lint_script:
|
||||
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
||||
always:
|
||||
swiftformat_report_artifacts:
|
||||
path: swiftformat.json
|
||||
format: swiftformat
|
||||
|
||||
task:
|
||||
name: Build
|
||||
alias: build
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
@@ -19,6 +50,10 @@ task:
|
||||
task:
|
||||
name: Release
|
||||
only_if: $CIRRUS_TAG != ''
|
||||
depends_on:
|
||||
- lint
|
||||
- test
|
||||
- build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
env:
|
||||
@@ -26,6 +61,9 @@ task:
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||
SENTRY_ORG: cirrus-labs
|
||||
SENTRY_PROJECT: persistent-workers
|
||||
SENTRY_AUTH_TOKEN: ENCRYPTED[!c16a5cf7da5f856b4bc2f21fe8cb7aa2a6c981f851c094ed4d3025fd02ea59a58a86cee8b193a69a1fc20fa217e56ac3!]
|
||||
setup_script:
|
||||
- cd $HOME
|
||||
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
||||
@@ -36,10 +74,23 @@ task:
|
||||
- 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 go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
|
||||
- brew install mitchellh/gon/gon
|
||||
info_script:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
- swift -version
|
||||
release_script: goreleaser
|
||||
upload_sentry_debug_files_script:
|
||||
- cd .build/arm64-apple-macosx/debug/
|
||||
# Generate and upload symbols
|
||||
- dsymutil tart
|
||||
- sentry-cli debug-files upload tart.dSYM/
|
||||
# Bundle and upload sources
|
||||
- sentry-cli debug-files bundle-sources tart.dSYM
|
||||
- sentry-cli debug-files upload tart.src.zip
|
||||
create_sentry_release_script:
|
||||
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
|
||||
- sentry-cli releases new $SENTRY_RELEASE
|
||||
- sentry-cli releases set-commits $SENTRY_RELEASE --auto
|
||||
- sentry-cli releases finalize $SENTRY_RELEASE
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
* @edigaryev @fkorotkov
|
||||
@@ -0,0 +1,5 @@
|
||||
--disable all
|
||||
--enable indent
|
||||
--indent 2
|
||||
--exclude Sources/tart/OCI/Reference/Generated
|
||||
--swiftversion 5.7
|
||||
+41
-68
@@ -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" : "df87a860fdc41a595d5ca67f74cde9adbccc099a",
|
||||
"version" : "1.11.4"
|
||||
"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" : "sentry-cocoa",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "c3c19e29f775ee95b77aa3168f3e2fd6c20deba6",
|
||||
"version" : "7.31.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -36,6 +54,15 @@
|
||||
"version" : "1.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-async-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "f05e450f0b909c0e80670a47516c4b9700b9e5da"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -46,12 +73,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-case-paths",
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-case-paths",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "ce9c0d897db8a840c39de64caaa9b60119cf4be8",
|
||||
"version" : "0.8.1"
|
||||
"revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
|
||||
"version" : "1.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -59,53 +86,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "5d66f7ba25daf4f94100e7022febf3c75e37a6c7",
|
||||
"version" : "1.4.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"state" : {
|
||||
"revision" : "124119f0bb12384cef35aa041d7c3a686108722d",
|
||||
"version" : "2.40.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-extras.git",
|
||||
"state" : {
|
||||
"revision" : "8eea84ec6144167354387ef9244b0939f5852dc8",
|
||||
"version" : "1.11.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-http2",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-http2.git",
|
||||
"state" : {
|
||||
"revision" : "108ac15087ea9b79abb6f6742699cf31de0e8772",
|
||||
"version" : "1.22.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-ssl",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-ssl.git",
|
||||
"state" : {
|
||||
"revision" : "1750873bce84b4129b5303655cce2c3d35b9ed3a",
|
||||
"version" : "2.19.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-transport-services",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-transport-services.git",
|
||||
"state" : {
|
||||
"revision" : "1a4692acb88156e3da1b0c6732a8a38b2a744166",
|
||||
"version" : "1.12.0"
|
||||
"revision" : "6fe203dc33195667ce1759bf0182975e4653ba1c",
|
||||
"version" : "1.4.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -117,15 +99,6 @@
|
||||
"version" : "1.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-parsing",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-parsing",
|
||||
"state" : {
|
||||
"revision" : "28d32e9ace1c4c43f5e5a177be837a202494c2d5",
|
||||
"version" : "0.9.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftdate",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -136,12 +109,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "xctest-dynamic-overlay",
|
||||
"identity" : "swiftformat",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||
"location" : "https://github.com/nicklockwood/SwiftFormat",
|
||||
"state" : {
|
||||
"revision" : "50a70a9d3583fe228ce672e8923010c8df2deddd",
|
||||
"version" : "0.2.1"
|
||||
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
|
||||
"version" : "0.50.6"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+20
-6
@@ -12,21 +12,35 @@ 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.11.4"),
|
||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
|
||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1")
|
||||
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
|
||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
|
||||
.package(url: "https://github.com/sushichop/Puppy", from: "0.5.1"),
|
||||
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "7.31.3"),
|
||||
],
|
||||
targets: [
|
||||
.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"),
|
||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||
|
||||
], exclude: [
|
||||
"OCI/Reference/Makefile",
|
||||
"OCI/Reference/Reference.g4",
|
||||
"OCI/Reference/Generated/Reference.interp",
|
||||
"OCI/Reference/Generated/Reference.tokens",
|
||||
"OCI/Reference/Generated/ReferenceLexer.interp",
|
||||
"OCI/Reference/Generated/ReferenceLexer.tokens",
|
||||
]),
|
||||
.testTarget(name: "TartTests", dependencies: ["tart"])
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||

|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
|
||||
|
||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
|
||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon.
|
||||
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
||||
|
||||
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/14966395?baseline=14966339).
|
||||
@@ -22,20 +22,64 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Many more companies are using Tart in their internal setups. Here are a few of them:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://ahrefs.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://suran.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://symflower.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
|
||||
|
||||
## Usage
|
||||
|
||||
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
|
||||
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
|
||||
|
||||
```shell
|
||||
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
|
||||
```
|
||||
|
||||

|
||||
<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.
|
||||
@@ -64,14 +108,14 @@ brew install cirruslabs/cli/cirrus
|
||||
cirrus run
|
||||
```
|
||||
|
||||

|
||||
<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
|
||||
#### 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
|
||||
@@ -89,7 +133,7 @@ task:
|
||||
|
||||
Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
|
||||
|
||||
```bash
|
||||
```shell
|
||||
cirrus run --artifacts-dir artifacts
|
||||
```
|
||||
|
||||
@@ -123,7 +167,9 @@ After the initial booting of the VM you'll need to manually go through the macOS
|
||||
|
||||
#### Creating a Linux VM image from scratch
|
||||
|
||||
```bash
|
||||
Linux VMs are supported on hosts running macOS 13.0 (Ventura) or newer.
|
||||
|
||||
```shell
|
||||
# Create a bare VM
|
||||
tart create --linux ubuntu
|
||||
|
||||
@@ -152,32 +198,35 @@ Please refer to `tart set --help` for additional details.
|
||||
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -197,6 +246,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:
|
||||
@@ -221,6 +276,50 @@ tart clone acme.io/remoteorg/name:latest my-local-vm-name
|
||||
|
||||
This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
|
||||
|
||||
### Mounting directories
|
||||
|
||||
To mount a directory, run the VM with the `--dir` argument:
|
||||
|
||||
```shell
|
||||
tart run --dir=project:~/src/project vm
|
||||
```
|
||||
|
||||
Here, the `project` specifies a mount name, whereas the `~/src/project` is a path to the host's directory to expose to the VM.
|
||||
|
||||
It is also possible to mount directories in read-only mode by adding a third parameter, `ro`:
|
||||
|
||||
```shell
|
||||
tart run --dir=project:~/src/project:ro vm
|
||||
```
|
||||
|
||||
To mount multiple directories, repeat the `--dir` argument for each directory:
|
||||
|
||||
```shell
|
||||
tart run --dir=www1:~/project1/www --dir=www2:~/project2/www
|
||||
```
|
||||
|
||||
Note that the first parameter in each `--dir` argument must be unique, otherwise only the last `--dir` argument using that name will be used.
|
||||
|
||||
Note: to use the directory mounting feature, the host needs to run macOS 13.0 (Ventura) or newer.
|
||||
|
||||
#### Accessing mounted directories in macOS guests
|
||||
|
||||
All shared directories are automatically mounted to `/Volumes/My Shared Files` directory.
|
||||
|
||||
The directory we've mounted above will be accessible from the `/Volumes/My Shared Files/project` path inside a guest VM.
|
||||
|
||||
Note: to use the directory mounting feature, the guest VM needs to run macOS 13.0 (Ventura) or newer.
|
||||
|
||||
#### Accessing mounted directories in Linux guests
|
||||
|
||||
To be able to access the shared directories from the Linux guest, you need to manually mount the virtual filesystem first:
|
||||
|
||||
```shell
|
||||
mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
|
||||
```
|
||||
|
||||
The directory we've mounted above will be accessible from the `/mnt/shared/project` path inside a guest VM.
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
@@ -259,7 +358,7 @@ This invocation calls the `tart pull` implicitly (if the image is not being pres
|
||||
<summary>VM location on disk</summary>
|
||||
|
||||
Tart stores all it's files in `~/.tart/` directory. Local images that you can run are stored in `~/.tart/vms/`.
|
||||
Remote images are pulled into `~/.tart/vms/cache/OCIs/`.
|
||||
Remote images are pulled into `~/.tart/cache/OCIs/`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -274,7 +373,7 @@ This invocation calls the `tart pull` implicitly (if the image is not being pres
|
||||
|
||||
To change the default network to `192.168.77.1`:
|
||||
|
||||
```
|
||||
```shell
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Address -string 192.168.77.1
|
||||
```
|
||||
|
||||
@@ -282,7 +381,17 @@ This invocation calls the `tart pull` implicitly (if the image is not being pres
|
||||
|
||||
The default subnet mask `255.255.255.0` should suffice for most use-cases, however, you can also change it to `255.255.0.0`, for example:
|
||||
|
||||
```
|
||||
```shell
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>How to connect to a VM over SSH?</summary>
|
||||
|
||||
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
|
||||
|
||||
```shell
|
||||
ssh admin@$(tart ip macos-monterey-base)
|
||||
```
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:23728bb5438c88b3d0826170a5fa4b7aaad0fbd94a4769c8d492c9f75b57ba81
|
||||
size 155885
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3a43f541b1ab0b57ae2060d371cba5dbb1f5c80b89c76434b7154d8144f66e61
|
||||
size 205325
|
||||
oid sha256:7a4929ca4e02d4968904749028ada7072704d2a52cccb69335e1596452c822c7
|
||||
size 1359834
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4d1f58f6460217434ae133c6d6e444320eb705c5dcf9efebbb215b117ae452c1
|
||||
size 589
|
||||
@@ -0,0 +1,4 @@
|
||||
If you'd like to highlight your use of Tart, please create a `456px` by `130px` logo and create a PR
|
||||
that adds it to `README.md` in alphabetical order. Don't forget to include a small description of your usage pattern.
|
||||
|
||||
You can refer to `Background.png` as a base for your logo.
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:630c4975b9eead9e4f62e2a5375509dc0ee6cab2940ec3ffc2ae16b08d325964
|
||||
size 6765
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5e48fbe90f6cc84e100fc286a5349c681cacb22358811181046d35ea298309de
|
||||
size 16379
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1c7f7f9bf0fc0c9807f3a415fe6bf773c8a416b351c5aa08de46149a13de7387
|
||||
size 5297
|
||||
@@ -4,6 +4,10 @@ struct CI {
|
||||
static var version: String {
|
||||
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
|
||||
}
|
||||
|
||||
static var release: String? {
|
||||
rawVersion.expanded() ? "tart@\(rawVersion)" : nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
|
||||
@@ -21,43 +21,35 @@ struct Clone: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let ociStorage = VMStorageOCI()
|
||||
let localStorage = VMStorageLocal()
|
||||
let ociStorage = VMStorageOCI()
|
||||
let localStorage = VMStorageLocal()
|
||||
|
||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
try await ociStorage.pull(remoteName, registry: registry)
|
||||
}
|
||||
|
||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
try lock.lock()
|
||||
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
|
||||
try lock.unlock()
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ struct Create: AsyncParsableCommand {
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file (or \"latest\") to fetch the latest appropriate IPSW", valueName: "path"))
|
||||
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file or URL (or \"latest\", to fetch the latest supported IPSW automatically)", valueName: "path"))
|
||||
var fromIPSW: String?
|
||||
|
||||
@Flag(help: "create a Linux VM")
|
||||
@@ -25,40 +25,38 @@ struct Create: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
// 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 {
|
||||
if fromIPSW == "latest" {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
|
||||
} else {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW), diskSizeGB: diskSize)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
if linux {
|
||||
if #available(macOS 13, *) {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
} else {
|
||||
throw UnsupportedOSError("Linux VMs", "are")
|
||||
}
|
||||
_ = 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 {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,17 +6,11 @@ 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)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
for it in name {
|
||||
try VMStorageHelper.delete(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Get: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
||||
|
||||
@Argument(help: "VM name.")
|
||||
var name: String
|
||||
|
||||
@Flag(help: "Number of VM CPUs.")
|
||||
var cpu: Bool = false
|
||||
|
||||
@Flag(help: "VM memory size in megabytes.")
|
||||
var memory: Bool = false
|
||||
|
||||
@Flag(help: "Disk size in gigabytes.")
|
||||
var diskSize: Bool = false
|
||||
|
||||
@Flag(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800.")
|
||||
var display: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
if [cpu, memory, diskSize, display].filter({$0}).count > 1 {
|
||||
throw ValidationError("Options --cpu, --memory, --disk-size and --display are mutually exclusive")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let diskSizeInGb = try vmDir.sizeBytes() / 1000 / 1000 / 1000
|
||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||
|
||||
if cpu {
|
||||
print(vmConfig.cpuCount)
|
||||
} else if memory {
|
||||
print(memorySizeInMb)
|
||||
} else if diskSize {
|
||||
print(diskSizeInGb)
|
||||
} else if display {
|
||||
print("\(vmConfig.display.width)x\(vmConfig.display.height)")
|
||||
} else {
|
||||
print(
|
||||
"CPU\tMemory\tDisk\tDisplay\n" +
|
||||
"\(vmConfig.cpuCount)\t" +
|
||||
"\(memorySizeInMb) MB\t" +
|
||||
"\(diskSizeInGb) GB\t" +
|
||||
"\(vmConfig.display)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import ArgumentParser
|
||||
import Foundation
|
||||
import Network
|
||||
import SystemConfiguration
|
||||
import Sentry
|
||||
|
||||
struct IP: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
||||
@@ -13,42 +14,49 @@ struct IP: AsyncParsableCommand {
|
||||
var wait: UInt16 = 0
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
|
||||
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
||||
print("no IP address found, is your VM running?")
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
|
||||
if let ipViaARP = try ARPCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
||||
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
||||
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
||||
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
||||
}
|
||||
|
||||
print(ipViaDHCP)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
||||
throw RuntimeError.NoIPAddressFound("no IP address found, is your VM running?")
|
||||
}
|
||||
|
||||
let arpCache = try ARPCache()
|
||||
|
||||
if let ipViaARP = try arpCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
||||
// Capture the warning into Sentry
|
||||
SentrySDK.capture(message: "DHCP lease and ARP cache entries for a single MAC address differ") { scope in
|
||||
scope.setLevel(.warning)
|
||||
|
||||
scope.setContext(value: [
|
||||
"MAC address": vmMACAddress,
|
||||
"IP via ARP": ipViaARP,
|
||||
"IP via DHCP": ipViaDHCP,
|
||||
], key: "Address conflict details")
|
||||
|
||||
scope.add(Attachment(path: "/var/db/dhcpd_leases", filename: "dhcpd_leases.txt", contentType: "text/plain"))
|
||||
scope.add(Attachment(data: arpCache.arpCommandOutput, filename: "arp-an-output.txt", contentType: "text/plain"))
|
||||
}
|
||||
|
||||
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
||||
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
||||
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
||||
}
|
||||
|
||||
print(ipViaDHCP)
|
||||
}
|
||||
|
||||
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
|
||||
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
||||
|
||||
repeat {
|
||||
if let ip = try Leases().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
|
||||
|
||||
@@ -5,24 +5,44 @@ 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
|
||||
|
||||
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
|
||||
var source: String?
|
||||
|
||||
func validate() throws {
|
||||
guard let source = source else {
|
||||
return
|
||||
}
|
||||
|
||||
if !["local", "oci"].contains(source) {
|
||||
throw ValidationError("'\(source)' is not a valid <source>")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
if !quiet {
|
||||
print("Source\tName")
|
||||
}
|
||||
|
||||
if source == nil || source == "local" {
|
||||
displayTable("local", try VMStorageLocal().list())
|
||||
}
|
||||
|
||||
if source == nil || source == "oci" {
|
||||
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
|
||||
for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) {
|
||||
print("\(source)\t\(name)")
|
||||
if quiet {
|
||||
print(name)
|
||||
} else {
|
||||
let source = source.padding(toLength: "Source".count, withPad: " ", startingAt: 0)
|
||||
print("\(source)\t\(name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,40 +27,30 @@ struct Login: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
var user: String
|
||||
var password: String
|
||||
var user: String
|
||||
var password: String
|
||||
|
||||
if let username = username {
|
||||
user = username
|
||||
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 {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
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 {
|
||||
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
||||
}
|
||||
|
||||
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import Sentry
|
||||
import SwiftUI
|
||||
import SwiftDate
|
||||
|
||||
@@ -7,13 +8,13 @@ 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"))
|
||||
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"))
|
||||
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)
|
||||
@@ -26,29 +27,21 @@ struct Prune: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
if gc {
|
||||
try VMStorageOCI().gc()
|
||||
}
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,29 +55,33 @@ struct Prune: AsyncParsableCommand {
|
||||
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() }
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() > $1.accessDate() }
|
||||
|
||||
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
||||
var cacheReclaimedBytes: Int = 0
|
||||
var cacheBudgetBytes = cacheBudgetBytes
|
||||
var prunablesToDelete: [Prunable] = []
|
||||
|
||||
var it = prunables.makeIterator()
|
||||
for prunable in prunables {
|
||||
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
|
||||
|
||||
while (cacheUsedBytes - cacheReclaimedBytes) > cacheBudgetBytes {
|
||||
guard let prunable = it.next() else {
|
||||
break
|
||||
if prunableSizeBytes <= cacheBudgetBytes {
|
||||
// Don't mark for deletion as
|
||||
// there's a budget available
|
||||
cacheBudgetBytes -= prunableSizeBytes
|
||||
} else {
|
||||
// Mark for deletion
|
||||
prunablesToDelete.append(prunable)
|
||||
}
|
||||
|
||||
cacheReclaimedBytes -= try prunable.sizeBytes()
|
||||
try prunable.delete()
|
||||
}
|
||||
|
||||
try prunablesToDelete.forEach { try $0.delete() }
|
||||
}
|
||||
|
||||
static func pruneReclaim(reclaimBytes: UInt64) throws {
|
||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||
let prunables: [Prunable] = try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||
.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, +)
|
||||
@@ -103,6 +100,11 @@ struct Prune: AsyncParsableCommand {
|
||||
|
||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||
try prunable.delete()
|
||||
puppy.info("deleting \(prunable.url)...")
|
||||
}
|
||||
|
||||
puppy.info("reclaimed \(cacheReclaimedBytes) bytes")
|
||||
|
||||
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,27 +12,19 @@ struct Pull: AsyncParsableCommand {
|
||||
var insecure: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
// Be more liberal when accepting local image as argument,
|
||||
// see https://github.com/cirruslabs/tart/issues/36
|
||||
if VMStorageLocal().exists(remoteName) {
|
||||
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
||||
// Be more liberal when accepting local image as argument,
|
||||
// see https://github.com/cirruslabs/tart/issues/36
|
||||
if VMStorageLocal().exists(remoteName) {
|
||||
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
||||
|
||||
Foundation.exit(0)
|
||||
}
|
||||
|
||||
let remoteName = try RemoteName(remoteName)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
|
||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
let remoteName = try RemoteName(remoteName)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
|
||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,66 +16,58 @@ struct Push: AsyncParsableCommand {
|
||||
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.
|
||||
"""))
|
||||
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."))
|
||||
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)
|
||||
let localVMDir = try VMStorageLocal().open(localName)
|
||||
|
||||
// Parse remote names supplied by the user
|
||||
let remoteNames = try remoteNames.map{
|
||||
try RemoteName($0)
|
||||
}
|
||||
// Parse remote names supplied by the user
|
||||
let remoteNames = try remoteNames.map{
|
||||
try RemoteName($0)
|
||||
}
|
||||
|
||||
// Group remote names by registry
|
||||
struct RegistryIdentifier: Hashable, Equatable {
|
||||
var host: String
|
||||
var namespace: String
|
||||
}
|
||||
// Group remote names by registry
|
||||
struct RegistryIdentifier: Hashable, Equatable {
|
||||
var host: String
|
||||
var namespace: String
|
||||
}
|
||||
|
||||
let registryGroups = Dictionary(grouping: remoteNames, by: {
|
||||
RegistryIdentifier(host: $0.host, namespace: $0.namespace)
|
||||
})
|
||||
let registryGroups = Dictionary(grouping: remoteNames, by: {
|
||||
RegistryIdentifier(host: $0.host, namespace: $0.namespace)
|
||||
})
|
||||
|
||||
// Push VM
|
||||
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
||||
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
|
||||
insecure: insecure)
|
||||
// Push VM
|
||||
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
||||
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
|
||||
insecure: insecure)
|
||||
|
||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||
|
||||
let pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||
registry: registry,
|
||||
references: remoteNamesForRegistry.map{ $0.reference.value },
|
||||
chunkSizeMb: chunkSize
|
||||
)
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,39 +2,31 @@ import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Rename: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Rename a VM")
|
||||
static var configuration = CommandConfiguration(abstract: "Rename a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@Argument(help: "new VM name")
|
||||
var newName: 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 validate() throws {
|
||||
if newName.contains("/") {
|
||||
throw ValidationError("<new-name> should be a local name")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
let localStorage = VMStorageLocal()
|
||||
|
||||
if !localStorage.exists(name) {
|
||||
throw ValidationError("failed to rename a non-existent VM: \(name)")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if localStorage.exists(newName) {
|
||||
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
|
||||
}
|
||||
|
||||
try localStorage.rename(name, newName)
|
||||
}
|
||||
}
|
||||
|
||||
+161
-45
@@ -2,6 +2,7 @@ import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
import Sentry
|
||||
|
||||
var vm: VM?
|
||||
|
||||
@@ -15,17 +16,20 @@ struct Run: AsyncParsableCommand {
|
||||
var name: String
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Don't open a UI window.",
|
||||
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
|
||||
"Don't open a UI window.",
|
||||
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
|
||||
var noGraphics: Bool = false
|
||||
|
||||
@Flag(help: "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."))
|
||||
"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(
|
||||
@@ -34,33 +38,52 @@ struct Run: AsyncParsableCommand {
|
||||
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
||||
var vncExperimental: Bool = false
|
||||
|
||||
@Flag var withSoftnet: 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]"))
|
||||
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(name: [.customLong("rosetta")], help: ArgumentHelp(
|
||||
"Attaches a Rosetta share to the guest Linux VM with a specific tag (e.g. --rosetta=\"rosetta\")",
|
||||
discussion: """
|
||||
Requires host to be macOS 13.0 (Ventura) with Rosetta installed. The latter can be done
|
||||
by running "softwareupdate --install-rosetta" (without quotes) in the Terminal.app.
|
||||
|
||||
Note that you also have to configure Rosetta in the guest Linux VM by following the
|
||||
steps from "Mount the Shared Directory and Register Rosetta" section here:
|
||||
https://developer.apple.com/documentation/virtualization/running_intel_binaries_in_linux_vms_with_rosetta#3978496
|
||||
""",
|
||||
valueName: "tag"
|
||||
))
|
||||
var rosettaTag: String?
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"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]"))
|
||||
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"))
|
||||
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")
|
||||
@@ -69,16 +92,41 @@ struct Run: AsyncParsableCommand {
|
||||
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)
|
||||
|
||||
let additionalDiskAttachments = try additionalDiskAttachments()
|
||||
|
||||
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
|
||||
// see https://github.com/cirruslabs/tart/issues/323 for more details.
|
||||
for additionalDiskAttachment in additionalDiskAttachments {
|
||||
// Read-only attachments do not seem to acquire the lock
|
||||
if additionalDiskAttachment.isReadOnly {
|
||||
continue
|
||||
}
|
||||
|
||||
if try !FileLock(lockURL: additionalDiskAttachment.url).trylock() {
|
||||
throw RuntimeError.DiskAlreadyInUse("disk \(additionalDiskAttachment.url.path) seems to be already in use, "
|
||||
+ "unmount it first in Finder")
|
||||
}
|
||||
}
|
||||
|
||||
vm = try VM(
|
||||
vmDir: vmDir,
|
||||
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||
additionalDiskAttachments: additionalDiskAttachments(),
|
||||
directoryShares: directoryShares()
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare()
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
@@ -92,6 +140,23 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}()
|
||||
|
||||
// Lock the VM
|
||||
//
|
||||
// More specifically, lock the "config.json", because we can't lock
|
||||
// directories with fcntl(2)-based locking and we better not interfere
|
||||
// with the VM's disk and NVRAM, because they are opened (and even seem
|
||||
// to be locked) directly by the Virtualization.Framework's process.
|
||||
//
|
||||
// Note that due to "completely stupid semantics"[1] of the fcntl-based
|
||||
// file locking, we need to acquire the lock after we read the VM's
|
||||
// configuration file, otherwise we will loose the lock.
|
||||
//
|
||||
// [1]: https://man.openbsd.org/fcntl
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
if try !lock.trylock() {
|
||||
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
|
||||
}
|
||||
|
||||
let task = Task {
|
||||
do {
|
||||
if let vncImpl = vncImpl {
|
||||
@@ -113,12 +178,12 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
|
||||
print("Virtual machine \"\(name)\" is already running!")
|
||||
Foundation.exit(2)
|
||||
}
|
||||
// Capture the error into Sentry
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -129,7 +194,8 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
sigintSrc.activate()
|
||||
|
||||
if noGraphics || vnc || vncExperimental {
|
||||
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||
if noGraphics || useVNCWithoutGraphics {
|
||||
dispatchMain()
|
||||
} else {
|
||||
runUI()
|
||||
@@ -137,7 +203,7 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
|
||||
if withSoftnet {
|
||||
if withSoftnet || netSoftnet {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
return try Softnet(vmMACAddress: config.macAddress.string)
|
||||
@@ -199,8 +265,22 @@ struct Run: AsyncParsableCommand {
|
||||
return result
|
||||
}
|
||||
|
||||
func directoryShares() throws -> [DirectoryShare] {
|
||||
var result: [DirectoryShare] = []
|
||||
func directoryShares() throws -> [VZDirectorySharingDeviceConfiguration] {
|
||||
if dir.isEmpty {
|
||||
return []
|
||||
}
|
||||
|
||||
guard #available(macOS 13, *) else {
|
||||
throw UnsupportedOSError("directory sharing", "is")
|
||||
}
|
||||
|
||||
struct DirectoryShare {
|
||||
let name: String
|
||||
let path: URL
|
||||
let readOnly: Bool
|
||||
}
|
||||
|
||||
var directoryShares: [DirectoryShare] = []
|
||||
|
||||
for rawDir in dir {
|
||||
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
|
||||
@@ -221,10 +301,46 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
let (name, path) = (String(splits[0]), String(splits[1]))
|
||||
|
||||
result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath), readOnly: readOnly))
|
||||
directoryShares.append(DirectoryShare(
|
||||
name: name,
|
||||
path: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath),
|
||||
readOnly: readOnly)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
var directories: [String : VZSharedDirectory] = Dictionary()
|
||||
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
|
||||
|
||||
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
||||
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
|
||||
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
|
||||
|
||||
return [sharingDevice]
|
||||
}
|
||||
|
||||
private func rosettaDirectoryShare() throws -> [VZDirectorySharingDeviceConfiguration] {
|
||||
guard let rosettaTag = rosettaTag else {
|
||||
return []
|
||||
}
|
||||
|
||||
guard #available(macOS 13, *) else {
|
||||
throw UnsupportedOSError("Rosetta directory share", "is")
|
||||
}
|
||||
|
||||
switch VZLinuxRosettaDirectoryShare.availability {
|
||||
case .notInstalled:
|
||||
throw UnsupportedOSError("Rosetta directory share", "is", "that have Rosetta installed")
|
||||
case .notSupported:
|
||||
throw UnsupportedOSError("Rosetta directory share", "is", "running Apple silicon")
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
try VZVirtioFileSystemDeviceConfiguration.validateTag(rosettaTag)
|
||||
let device = VZVirtioFileSystemDeviceConfiguration(tag: rosettaTag)
|
||||
device.share = try VZLinuxRosettaDirectoryShare()
|
||||
|
||||
return [device]
|
||||
}
|
||||
|
||||
private func runUI() {
|
||||
@@ -245,16 +361,16 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
||||
}.commands {
|
||||
// Remove some standard menu options
|
||||
CommandGroup(replacing: .help, addition: {})
|
||||
CommandGroup(replacing: .newItem, addition: {})
|
||||
CommandGroup(replacing: .pasteboard, addition: {})
|
||||
CommandGroup(replacing: .textEditing, addition: {})
|
||||
CommandGroup(replacing: .undoRedo, addition: {})
|
||||
CommandGroup(replacing: .windowSize, addition: {})
|
||||
// Replace some standard menu options
|
||||
CommandGroup(replacing: .appInfo) { AboutTart() }
|
||||
}
|
||||
// Remove some standard menu options
|
||||
CommandGroup(replacing: .help, addition: {})
|
||||
CommandGroup(replacing: .newItem, addition: {})
|
||||
CommandGroup(replacing: .pasteboard, addition: {})
|
||||
CommandGroup(replacing: .textEditing, addition: {})
|
||||
CommandGroup(replacing: .undoRedo, addition: {})
|
||||
CommandGroup(replacing: .windowSize, addition: {})
|
||||
// Replace some standard menu options
|
||||
CommandGroup(replacing: .appInfo) { AboutTart() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ struct Set: AsyncParsableCommand {
|
||||
var cpu: UInt16?
|
||||
|
||||
@Option(help: "VM memory size in megabytes")
|
||||
var memory: UInt16?
|
||||
var memory: UInt64?
|
||||
|
||||
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||
var display: VMDisplayConfig?
|
||||
@@ -20,38 +20,30 @@ struct Set: AsyncParsableCommand {
|
||||
var diskSize: UInt16?
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
|
||||
if let cpu = cpu {
|
||||
try vmConfig.setCPU(cpuCount: Int(cpu))
|
||||
if let cpu = cpu {
|
||||
try vmConfig.setCPU(cpuCount: Int(cpu))
|
||||
}
|
||||
|
||||
if let memory = memory {
|
||||
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
|
||||
}
|
||||
|
||||
if let display = display {
|
||||
if (display.width > 0) {
|
||||
vmConfig.display.width = display.width
|
||||
}
|
||||
|
||||
if let memory = memory {
|
||||
try vmConfig.setMemory(memorySize: UInt64(memory) * 1024 * 1024)
|
||||
if (display.height > 0) {
|
||||
vmConfig.display.height = display.height
|
||||
}
|
||||
}
|
||||
|
||||
if let display = display {
|
||||
if (display.width > 0) {
|
||||
vmConfig.display.width = display.width
|
||||
}
|
||||
if (display.height > 0) {
|
||||
vmConfig.display.height = display.height
|
||||
}
|
||||
}
|
||||
try vmConfig.save(toURL: vmDir.configURL)
|
||||
|
||||
try vmConfig.save(toURL: vmDir.configURL)
|
||||
|
||||
if diskSize != nil {
|
||||
try vmDir.resizeDisk(diskSize!)
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
if diskSize != nil {
|
||||
try vmDir.resizeDisk(diskSize!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import System
|
||||
import SwiftDate
|
||||
|
||||
struct Stop: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
|
||||
var timeout: UInt64 = 30
|
||||
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
|
||||
// Find the VM's PID
|
||||
var pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||
}
|
||||
|
||||
// Try to gracefully terminate the VM
|
||||
//
|
||||
// Note that we don't check the return code here
|
||||
// to provide a clean exit from "tart stop" in cases
|
||||
// when the VM is already shutting down and we hit
|
||||
// a race condition.
|
||||
//
|
||||
// We check the return code in the kill(2) below, though,
|
||||
// because it's a less common scenario and it would be
|
||||
// nice to know for the user that we've tried all methods
|
||||
// and failed to shutdown the VM.
|
||||
kill(pid, SIGINT)
|
||||
|
||||
// Ensure that the VM has terminated
|
||||
var gracefulWaitDuration = Measurement(value: Double(timeout), unit: UnitDuration.seconds)
|
||||
let gracefulTickDuration = Measurement(value: Double(100), unit: UnitDuration.milliseconds)
|
||||
|
||||
while gracefulWaitDuration.value > 0 {
|
||||
pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
try await Task.sleep(nanoseconds: UInt64(gracefulTickDuration.converted(to: .nanoseconds).value))
|
||||
gracefulWaitDuration = gracefulWaitDuration - gracefulTickDuration
|
||||
}
|
||||
|
||||
// Seems that VM is still running, proceed with forceful termination
|
||||
let ret = kill(pid, SIGKILL)
|
||||
if ret != 0 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError.VMTerminationFailed("failed to forcefully terminate the VM \"\(name)\": \(details)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ struct Config {
|
||||
tartHomeDir = URL(fileURLWithPath: customTartHome)
|
||||
} else {
|
||||
tartHomeDir = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".tart", isDirectory: true)
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".tart", isDirectory: true)
|
||||
}
|
||||
self.tartHomeDir = tartHomeDir
|
||||
|
||||
@@ -26,7 +26,7 @@ struct Config {
|
||||
|
||||
func gc() throws {
|
||||
for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
|
||||
includingPropertiesForKeys: [], options: []) {
|
||||
includingPropertiesForKeys: [], options: []) {
|
||||
let lock = try FileLock(lockURL: entry)
|
||||
if try !lock.trylock() {
|
||||
continue
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
enum CredentialsProviderError: Error {
|
||||
case Failed(message: String)
|
||||
case Failed(message: String)
|
||||
}
|
||||
|
||||
protocol CredentialsProvider {
|
||||
func retrieve(host: String) throws -> (String, String)?
|
||||
func store(host: String, user: String, password: String) throws
|
||||
func retrieve(host: String) throws -> (String, String)?
|
||||
func store(host: String, user: String, password: String) throws
|
||||
}
|
||||
|
||||
@@ -22,14 +22,14 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
||||
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
|
||||
@@ -38,7 +38,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
||||
|
||||
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
||||
inPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
class EnvironmentCredentialsProvider: CredentialsProvider {
|
||||
func retrieve(host: String) throws -> (String, String)? {
|
||||
let username = ProcessInfo.processInfo.environment["TART_REGISTRY_USERNAME"]
|
||||
let password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"]
|
||||
if let username = username, let password = password {
|
||||
return (username, password)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func store(host: String, user: String, password: String) throws {
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,66 @@
|
||||
import Foundation
|
||||
|
||||
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",
|
||||
]
|
||||
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)
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
|
||||
if status != errSecSuccess {
|
||||
if status == errSecItemNotFound {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
|
||||
}
|
||||
|
||||
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())")
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import AsyncAlgorithms
|
||||
|
||||
fileprivate let urlSession = createURLSession()
|
||||
|
||||
class Fetcher {
|
||||
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
if viaFile {
|
||||
return try await fetchViaFile(request)
|
||||
}
|
||||
|
||||
return try await fetchViaMemory(request)
|
||||
}
|
||||
|
||||
private static func fetchViaMemory(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
|
||||
Task {
|
||||
await dataCh.send(data)
|
||||
|
||||
dataCh.finish()
|
||||
}
|
||||
|
||||
return (dataCh, response as! HTTPURLResponse)
|
||||
}
|
||||
|
||||
private static func fetchViaFile(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||
|
||||
let (fileURL, response) = try await urlSession.download(for: request)
|
||||
|
||||
// Acquire a handle to the downloaded file and then remove it.
|
||||
//
|
||||
// This keeps a working reference to that file, yet we don't
|
||||
// have to deal with the cleanup any more.
|
||||
let fh = try FileHandle(forReadingFrom: fileURL)
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
|
||||
Task {
|
||||
while let data = try fh.read(upToCount: 64 * 1024 * 1024) {
|
||||
await dataCh.send(data)
|
||||
}
|
||||
|
||||
dataCh.finish()
|
||||
}
|
||||
|
||||
return (dataCh, response as! HTTPURLResponse)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func createURLSession() -> URLSession {
|
||||
let config = URLSessionConfiguration.default
|
||||
|
||||
// Harbor expects a CSRF token to be present if the HTTP client
|
||||
// carries a session cookie between its requests[1] and fails if
|
||||
// it was not present[2].
|
||||
//
|
||||
// To fix that, we disable the automatic cookies carry in URLSession.
|
||||
//
|
||||
// [1]: https://github.com/goharbor/harbor/blob/a4c577f9ec4f18396207a5e686433a6ba203d4ef/src/server/middleware/csrf/csrf.go#L78
|
||||
// [2]: https://github.com/cirruslabs/tart/issues/295
|
||||
config.httpShouldSetCookies = false
|
||||
|
||||
return URLSession(configuration: config)
|
||||
}
|
||||
+37
-37
@@ -2,47 +2,47 @@ import Foundation
|
||||
import System
|
||||
|
||||
enum FileLockError: Error, Equatable {
|
||||
case Failed(_ message: String)
|
||||
case AlreadyLocked
|
||||
case Failed(_ message: String)
|
||||
case AlreadyLocked
|
||||
}
|
||||
|
||||
class FileLock {
|
||||
let url: URL
|
||||
let fd: Int32
|
||||
let url: URL
|
||||
let fd: Int32
|
||||
|
||||
init(lockURL: URL) throws {
|
||||
url = lockURL
|
||||
fd = open(lockURL.path, 0)
|
||||
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)")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ class IPSWCache: PrunableStorage {
|
||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func locationFor(image: VZMacOSRestoreImage) -> URL {
|
||||
baseURL.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false)
|
||||
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")}
|
||||
.filter { $0.lastPathComponent.hasSuffix(".ipsw")}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,117 +3,124 @@ import Network
|
||||
import Virtualization
|
||||
|
||||
struct ARPCommandFailedError: Error, CustomStringConvertible {
|
||||
var terminationReason: Process.TerminationReason
|
||||
var terminationStatus: Int32
|
||||
var terminationReason: Process.TerminationReason
|
||||
var terminationStatus: Int32
|
||||
|
||||
var description: String {
|
||||
var reason: String
|
||||
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)"
|
||||
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 explanation: String
|
||||
|
||||
var description: String {
|
||||
"arp command yielded invalid output: \(explanation)"
|
||||
}
|
||||
var description: String {
|
||||
"arp command yielded invalid output: \(explanation)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCacheInternalError: Error, CustomStringConvertible {
|
||||
var explanation: String
|
||||
var explanation: String
|
||||
|
||||
var description: String {
|
||||
"ARPCache internal error: \(explanation)"
|
||||
}
|
||||
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 arpCommandOutput: Data
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
init() throws {
|
||||
let process = Process.init()
|
||||
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
||||
process.arguments = ["-an"]
|
||||
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
throw ARPCommandFailedError(
|
||||
terminationReason: process.terminationReason,
|
||||
terminationStatus: process.terminationStatus)
|
||||
}
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
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
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
throw ARPCommandFailedError(
|
||||
terminationReason: process.terminationReason,
|
||||
terminationStatus: process.terminationStatus)
|
||||
}
|
||||
|
||||
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||
}
|
||||
|
||||
self.arpCommandOutput = arpCommandOutput
|
||||
}
|
||||
|
||||
func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||
let lines = String(decoding: arpCommandOutput, as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: "\n")
|
||||
|
||||
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
|
||||
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
|
||||
|
||||
for line in lines {
|
||||
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
|
||||
|
||||
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
|
||||
}
|
||||
|
||||
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
|
||||
guard let ip = IPv4Address(rawIP) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
|
||||
}
|
||||
|
||||
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
|
||||
if rawMAC == "(incomplete)" {
|
||||
continue
|
||||
}
|
||||
guard let mac = MACAddress(fromString: rawMAC) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
|
||||
}
|
||||
|
||||
let interface = try match.getCaptureGroup(name: "interface", for: line)
|
||||
if bridgeOnly && !interface.starts(with: "bridge") {
|
||||
continue
|
||||
}
|
||||
|
||||
if macAddress == mac {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension NSTextCheckingResult {
|
||||
func getCaptureGroup(name: String, for string: String) throws -> String {
|
||||
let nsRange = self.range(withName: name)
|
||||
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])
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,21 @@ enum LeasesError: Error {
|
||||
class Leases {
|
||||
private let leases: [MACAddress : Lease]
|
||||
|
||||
convenience init() throws {
|
||||
convenience init?() throws {
|
||||
try self.init(URL(fileURLWithPath: "/var/db/dhcpd_leases"))
|
||||
}
|
||||
|
||||
convenience init(_ fromURL: URL) throws {
|
||||
let fileContents = try String(contentsOf: fromURL, encoding: .utf8)
|
||||
convenience init?(_ fromURL: URL) throws {
|
||||
do {
|
||||
let urlContents = try String(contentsOf: fromURL, encoding: .utf8)
|
||||
try self.init(urlContents)
|
||||
} catch {
|
||||
if error.isFileNotFound() {
|
||||
return nil
|
||||
}
|
||||
|
||||
try self.init(fileContents)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
init(_ fromString: String) throws {
|
||||
|
||||
@@ -2,6 +2,6 @@ import Virtualization
|
||||
|
||||
protocol Network {
|
||||
func attachment() -> VZNetworkDeviceAttachment
|
||||
func run() throws
|
||||
func stop() throws
|
||||
func run(_ sema: DispatchSemaphore) throws
|
||||
func stop() async throws
|
||||
}
|
||||
|
||||
@@ -2,21 +2,21 @@ import Foundation
|
||||
import Virtualization
|
||||
|
||||
class NetworkBridged: Network {
|
||||
let interface: VZBridgedNetworkInterface
|
||||
let interface: VZBridgedNetworkInterface
|
||||
|
||||
init(interface: VZBridgedNetworkInterface) {
|
||||
self.interface = interface
|
||||
}
|
||||
init(interface: VZBridgedNetworkInterface) {
|
||||
self.interface = interface
|
||||
}
|
||||
|
||||
func attachment() -> VZNetworkDeviceAttachment {
|
||||
VZBridgedNetworkDeviceAttachment(interface: interface)
|
||||
}
|
||||
func attachment() -> VZNetworkDeviceAttachment {
|
||||
VZBridgedNetworkDeviceAttachment(interface: interface)
|
||||
}
|
||||
|
||||
func run() throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
func stop() async throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ import Foundation
|
||||
import Virtualization
|
||||
|
||||
class NetworkShared: Network {
|
||||
func attachment() -> VZNetworkDeviceAttachment {
|
||||
VZNATNetworkDeviceAttachment()
|
||||
}
|
||||
func attachment() -> VZNetworkDeviceAttachment {
|
||||
VZNATNetworkDeviceAttachment()
|
||||
}
|
||||
|
||||
func run() throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
func stop() async throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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
|
||||
|
||||
@@ -35,25 +39,49 @@ class Softnet: Network {
|
||||
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
||||
}
|
||||
|
||||
func run() throws {
|
||||
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() throws {
|
||||
process.interrupt()
|
||||
process.waitUntilExit()
|
||||
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)
|
||||
// The system expects the value of SO_RCVBUF to be at least double the value of SO_SNDBUF,
|
||||
// and for optimal performance, the recommended value of SO_RCVBUF is four times the value of SO_SNDBUF.
|
||||
// See: https://developer.apple.com/documentation/virtualization/vzfilehandlenetworkdeviceattachment/3969266-maximumtransmissionunit
|
||||
var receiveBufferSize = 4 * sizeBytes
|
||||
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &receiveBufferSize, option_len)
|
||||
if ret != 0 {
|
||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)")
|
||||
}
|
||||
|
||||
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &option_value, option_len)
|
||||
var sendBufferSize = sizeBytes
|
||||
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sendBufferSize, option_len)
|
||||
if ret != 0 {
|
||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ class Digest {
|
||||
extension SHA256.Digest {
|
||||
func hexdigest() -> String {
|
||||
"sha256:" + self.map {
|
||||
String(format: "%02x", $0)
|
||||
}
|
||||
.joined()
|
||||
String(format: "%02x", $0)
|
||||
}
|
||||
.joined()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
token literal names:
|
||||
null
|
||||
':'
|
||||
'/'
|
||||
'.'
|
||||
'-'
|
||||
'@'
|
||||
'_'
|
||||
null
|
||||
null
|
||||
|
||||
token symbolic names:
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
DIGIT
|
||||
LETTER
|
||||
|
||||
rule names:
|
||||
root
|
||||
host
|
||||
port
|
||||
host_component
|
||||
namespace
|
||||
namespace_component
|
||||
reference
|
||||
tag
|
||||
separator
|
||||
name
|
||||
|
||||
|
||||
atn:
|
||||
[4, 1, 8, 95, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 1, 0, 1, 0, 1, 0, 3, 0, 24, 8, 0, 1, 0, 1, 0, 1, 0, 3, 0, 29, 8, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 5, 1, 36, 8, 1, 10, 1, 12, 1, 39, 9, 1, 1, 2, 4, 2, 42, 8, 2, 11, 2, 12, 2, 43, 1, 3, 1, 3, 1, 3, 5, 3, 49, 8, 3, 10, 3, 12, 3, 52, 9, 3, 1, 4, 1, 4, 1, 4, 5, 4, 57, 8, 4, 10, 4, 12, 4, 60, 9, 4, 1, 5, 1, 5, 3, 5, 64, 8, 5, 4, 5, 66, 8, 5, 11, 5, 12, 5, 67, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 3, 6, 77, 8, 6, 1, 7, 1, 7, 1, 7, 1, 7, 5, 7, 83, 8, 7, 10, 7, 12, 7, 86, 9, 7, 1, 8, 1, 8, 1, 9, 4, 9, 91, 8, 9, 11, 9, 12, 9, 92, 1, 9, 0, 0, 10, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 0, 2, 2, 0, 3, 4, 6, 6, 1, 0, 7, 8, 95, 0, 20, 1, 0, 0, 0, 2, 32, 1, 0, 0, 0, 4, 41, 1, 0, 0, 0, 6, 45, 1, 0, 0, 0, 8, 53, 1, 0, 0, 0, 10, 65, 1, 0, 0, 0, 12, 76, 1, 0, 0, 0, 14, 78, 1, 0, 0, 0, 16, 87, 1, 0, 0, 0, 18, 90, 1, 0, 0, 0, 20, 23, 3, 2, 1, 0, 21, 22, 5, 1, 0, 0, 22, 24, 3, 4, 2, 0, 23, 21, 1, 0, 0, 0, 23, 24, 1, 0, 0, 0, 24, 25, 1, 0, 0, 0, 25, 26, 5, 2, 0, 0, 26, 28, 3, 8, 4, 0, 27, 29, 3, 12, 6, 0, 28, 27, 1, 0, 0, 0, 28, 29, 1, 0, 0, 0, 29, 30, 1, 0, 0, 0, 30, 31, 5, 0, 0, 1, 31, 1, 1, 0, 0, 0, 32, 37, 3, 6, 3, 0, 33, 34, 5, 3, 0, 0, 34, 36, 3, 6, 3, 0, 35, 33, 1, 0, 0, 0, 36, 39, 1, 0, 0, 0, 37, 35, 1, 0, 0, 0, 37, 38, 1, 0, 0, 0, 38, 3, 1, 0, 0, 0, 39, 37, 1, 0, 0, 0, 40, 42, 5, 7, 0, 0, 41, 40, 1, 0, 0, 0, 42, 43, 1, 0, 0, 0, 43, 41, 1, 0, 0, 0, 43, 44, 1, 0, 0, 0, 44, 5, 1, 0, 0, 0, 45, 50, 3, 18, 9, 0, 46, 47, 5, 4, 0, 0, 47, 49, 3, 18, 9, 0, 48, 46, 1, 0, 0, 0, 49, 52, 1, 0, 0, 0, 50, 48, 1, 0, 0, 0, 50, 51, 1, 0, 0, 0, 51, 7, 1, 0, 0, 0, 52, 50, 1, 0, 0, 0, 53, 58, 3, 10, 5, 0, 54, 55, 5, 2, 0, 0, 55, 57, 3, 10, 5, 0, 56, 54, 1, 0, 0, 0, 57, 60, 1, 0, 0, 0, 58, 56, 1, 0, 0, 0, 58, 59, 1, 0, 0, 0, 59, 9, 1, 0, 0, 0, 60, 58, 1, 0, 0, 0, 61, 63, 3, 18, 9, 0, 62, 64, 3, 16, 8, 0, 63, 62, 1, 0, 0, 0, 63, 64, 1, 0, 0, 0, 64, 66, 1, 0, 0, 0, 65, 61, 1, 0, 0, 0, 66, 67, 1, 0, 0, 0, 67, 65, 1, 0, 0, 0, 67, 68, 1, 0, 0, 0, 68, 11, 1, 0, 0, 0, 69, 70, 5, 1, 0, 0, 70, 77, 3, 14, 7, 0, 71, 72, 5, 5, 0, 0, 72, 73, 3, 18, 9, 0, 73, 74, 5, 1, 0, 0, 74, 75, 3, 18, 9, 0, 75, 77, 1, 0, 0, 0, 76, 69, 1, 0, 0, 0, 76, 71, 1, 0, 0, 0, 77, 13, 1, 0, 0, 0, 78, 84, 3, 18, 9, 0, 79, 80, 3, 16, 8, 0, 80, 81, 3, 18, 9, 0, 81, 83, 1, 0, 0, 0, 82, 79, 1, 0, 0, 0, 83, 86, 1, 0, 0, 0, 84, 82, 1, 0, 0, 0, 84, 85, 1, 0, 0, 0, 85, 15, 1, 0, 0, 0, 86, 84, 1, 0, 0, 0, 87, 88, 7, 0, 0, 0, 88, 17, 1, 0, 0, 0, 89, 91, 7, 1, 0, 0, 90, 89, 1, 0, 0, 0, 91, 92, 1, 0, 0, 0, 92, 90, 1, 0, 0, 0, 92, 93, 1, 0, 0, 0, 93, 19, 1, 0, 0, 0, 11, 23, 28, 37, 43, 50, 58, 63, 67, 76, 84, 92]
|
||||
@@ -0,0 +1,14 @@
|
||||
T__0=1
|
||||
T__1=2
|
||||
T__2=3
|
||||
T__3=4
|
||||
T__4=5
|
||||
T__5=6
|
||||
DIGIT=7
|
||||
LETTER=8
|
||||
':'=1
|
||||
'/'=2
|
||||
'.'=3
|
||||
'-'=4
|
||||
'@'=5
|
||||
'_'=6
|
||||
@@ -0,0 +1,167 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
|
||||
import Antlr4
|
||||
|
||||
|
||||
/**
|
||||
* This class provides an empty implementation of {@link ReferenceListener},
|
||||
* which can be extended to create a listener which only needs to handle a subset
|
||||
* of the available methods.
|
||||
*/
|
||||
open class ReferenceBaseListener: ReferenceListener {
|
||||
public init() { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterRoot(_ ctx: ReferenceParser.RootContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitRoot(_ ctx: ReferenceParser.RootContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterHost(_ ctx: ReferenceParser.HostContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitHost(_ ctx: ReferenceParser.HostContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterPort(_ ctx: ReferenceParser.PortContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitPort(_ ctx: ReferenceParser.PortContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterHost_component(_ ctx: ReferenceParser.Host_componentContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitHost_component(_ ctx: ReferenceParser.Host_componentContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterNamespace(_ ctx: ReferenceParser.NamespaceContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitNamespace(_ ctx: ReferenceParser.NamespaceContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterReference(_ ctx: ReferenceParser.ReferenceContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitReference(_ ctx: ReferenceParser.ReferenceContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterTag(_ ctx: ReferenceParser.TagContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitTag(_ ctx: ReferenceParser.TagContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterSeparator(_ ctx: ReferenceParser.SeparatorContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitSeparator(_ ctx: ReferenceParser.SeparatorContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterName(_ ctx: ReferenceParser.NameContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitName(_ ctx: ReferenceParser.NameContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterEveryRule(_ ctx: ParserRuleContext) throws { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitEveryRule(_ ctx: ParserRuleContext) throws { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func visitTerminal(_ node: TerminalNode) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func visitErrorNode(_ node: ErrorNode) { }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
token literal names:
|
||||
null
|
||||
':'
|
||||
'/'
|
||||
'.'
|
||||
'-'
|
||||
'@'
|
||||
'_'
|
||||
null
|
||||
null
|
||||
|
||||
token symbolic names:
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
DIGIT
|
||||
LETTER
|
||||
|
||||
rule names:
|
||||
T__0
|
||||
T__1
|
||||
T__2
|
||||
T__3
|
||||
T__4
|
||||
T__5
|
||||
DIGIT
|
||||
LETTER
|
||||
|
||||
channel names:
|
||||
DEFAULT_TOKEN_CHANNEL
|
||||
HIDDEN
|
||||
|
||||
mode names:
|
||||
DEFAULT_MODE
|
||||
|
||||
atn:
|
||||
[4, 0, 8, 33, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 6, 1, 6, 1, 7, 1, 7, 0, 0, 8, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 1, 0, 2, 1, 0, 48, 57, 2, 0, 65, 90, 97, 122, 32, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0, 1, 17, 1, 0, 0, 0, 3, 19, 1, 0, 0, 0, 5, 21, 1, 0, 0, 0, 7, 23, 1, 0, 0, 0, 9, 25, 1, 0, 0, 0, 11, 27, 1, 0, 0, 0, 13, 29, 1, 0, 0, 0, 15, 31, 1, 0, 0, 0, 17, 18, 5, 58, 0, 0, 18, 2, 1, 0, 0, 0, 19, 20, 5, 47, 0, 0, 20, 4, 1, 0, 0, 0, 21, 22, 5, 46, 0, 0, 22, 6, 1, 0, 0, 0, 23, 24, 5, 45, 0, 0, 24, 8, 1, 0, 0, 0, 25, 26, 5, 64, 0, 0, 26, 10, 1, 0, 0, 0, 27, 28, 5, 95, 0, 0, 28, 12, 1, 0, 0, 0, 29, 30, 7, 0, 0, 0, 30, 14, 1, 0, 0, 0, 31, 32, 7, 1, 0, 0, 32, 16, 1, 0, 0, 0, 1, 0, 0]
|
||||
@@ -0,0 +1,90 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
import Antlr4
|
||||
|
||||
open class ReferenceLexer: Lexer {
|
||||
|
||||
internal static var _decisionToDFA: [DFA] = {
|
||||
var decisionToDFA = [DFA]()
|
||||
let length = ReferenceLexer._ATN.getNumberOfDecisions()
|
||||
for i in 0..<length {
|
||||
decisionToDFA.append(DFA(ReferenceLexer._ATN.getDecisionState(i)!, i))
|
||||
}
|
||||
return decisionToDFA
|
||||
}()
|
||||
|
||||
internal static let _sharedContextCache = PredictionContextCache()
|
||||
|
||||
public
|
||||
static let T__0=1, T__1=2, T__2=3, T__3=4, T__4=5, T__5=6, DIGIT=7, LETTER=8
|
||||
|
||||
public
|
||||
static let channelNames: [String] = [
|
||||
"DEFAULT_TOKEN_CHANNEL", "HIDDEN"
|
||||
]
|
||||
|
||||
public
|
||||
static let modeNames: [String] = [
|
||||
"DEFAULT_MODE"
|
||||
]
|
||||
|
||||
public
|
||||
static let ruleNames: [String] = [
|
||||
"T__0", "T__1", "T__2", "T__3", "T__4", "T__5", "DIGIT", "LETTER"
|
||||
]
|
||||
|
||||
private static let _LITERAL_NAMES: [String?] = [
|
||||
nil, "':'", "'/'", "'.'", "'-'", "'@'", "'_'"
|
||||
]
|
||||
private static let _SYMBOLIC_NAMES: [String?] = [
|
||||
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
|
||||
]
|
||||
public
|
||||
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
||||
|
||||
|
||||
override open
|
||||
func getVocabulary() -> Vocabulary {
|
||||
return ReferenceLexer.VOCABULARY
|
||||
}
|
||||
|
||||
public
|
||||
required init(_ input: CharStream) {
|
||||
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
||||
super.init(input)
|
||||
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
|
||||
}
|
||||
|
||||
override open
|
||||
func getGrammarFileName() -> String { return "Reference.g4" }
|
||||
|
||||
override open
|
||||
func getRuleNames() -> [String] { return ReferenceLexer.ruleNames }
|
||||
|
||||
override open
|
||||
func getSerializedATN() -> [Int] { return ReferenceLexer._serializedATN }
|
||||
|
||||
override open
|
||||
func getChannelNames() -> [String] { return ReferenceLexer.channelNames }
|
||||
|
||||
override open
|
||||
func getModeNames() -> [String] { return ReferenceLexer.modeNames }
|
||||
|
||||
override open
|
||||
func getATN() -> ATN { return ReferenceLexer._ATN }
|
||||
|
||||
static let _serializedATN:[Int] = [
|
||||
4,0,8,33,6,-1,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,
|
||||
2,7,7,7,1,0,1,0,1,1,1,1,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,6,1,6,1,7,1,
|
||||
7,0,0,8,1,1,3,2,5,3,7,4,9,5,11,6,13,7,15,8,1,0,2,1,0,48,57,2,0,65,90,97,
|
||||
122,32,0,1,1,0,0,0,0,3,1,0,0,0,0,5,1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,
|
||||
1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,1,17,1,0,0,0,3,19,1,0,0,0,5,21,1,0,0,
|
||||
0,7,23,1,0,0,0,9,25,1,0,0,0,11,27,1,0,0,0,13,29,1,0,0,0,15,31,1,0,0,0,
|
||||
17,18,5,58,0,0,18,2,1,0,0,0,19,20,5,47,0,0,20,4,1,0,0,0,21,22,5,46,0,0,
|
||||
22,6,1,0,0,0,23,24,5,45,0,0,24,8,1,0,0,0,25,26,5,64,0,0,26,10,1,0,0,0,
|
||||
27,28,5,95,0,0,28,12,1,0,0,0,29,30,7,0,0,0,30,14,1,0,0,0,31,32,7,1,0,0,
|
||||
32,16,1,0,0,0,1,0,0
|
||||
]
|
||||
|
||||
public
|
||||
static let _ATN: ATN = try! ATNDeserializer().deserialize(_serializedATN)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
T__0=1
|
||||
T__1=2
|
||||
T__2=3
|
||||
T__3=4
|
||||
T__4=5
|
||||
T__5=6
|
||||
DIGIT=7
|
||||
LETTER=8
|
||||
':'=1
|
||||
'/'=2
|
||||
'.'=3
|
||||
'-'=4
|
||||
'@'=5
|
||||
'_'=6
|
||||
@@ -0,0 +1,129 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
import Antlr4
|
||||
|
||||
/**
|
||||
* This interface defines a complete listener for a parse tree produced by
|
||||
* {@link ReferenceParser}.
|
||||
*/
|
||||
public protocol ReferenceListener: ParseTreeListener {
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#root}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterRoot(_ ctx: ReferenceParser.RootContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#root}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitRoot(_ ctx: ReferenceParser.RootContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#host}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterHost(_ ctx: ReferenceParser.HostContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#host}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitHost(_ ctx: ReferenceParser.HostContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#port}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterPort(_ ctx: ReferenceParser.PortContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#port}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitPort(_ ctx: ReferenceParser.PortContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#host_component}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterHost_component(_ ctx: ReferenceParser.Host_componentContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#host_component}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitHost_component(_ ctx: ReferenceParser.Host_componentContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#namespace}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterNamespace(_ ctx: ReferenceParser.NamespaceContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#namespace}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitNamespace(_ ctx: ReferenceParser.NamespaceContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#namespace_component}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#namespace_component}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#reference}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterReference(_ ctx: ReferenceParser.ReferenceContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#reference}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitReference(_ ctx: ReferenceParser.ReferenceContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#tag}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterTag(_ ctx: ReferenceParser.TagContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#tag}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitTag(_ ctx: ReferenceParser.TagContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#separator}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterSeparator(_ ctx: ReferenceParser.SeparatorContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#separator}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitSeparator(_ ctx: ReferenceParser.SeparatorContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#name}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterName(_ ctx: ReferenceParser.NameContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#name}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitName(_ ctx: ReferenceParser.NameContext)
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
import Antlr4
|
||||
|
||||
open class ReferenceParser: Parser {
|
||||
|
||||
internal static var _decisionToDFA: [DFA] = {
|
||||
var decisionToDFA = [DFA]()
|
||||
let length = ReferenceParser._ATN.getNumberOfDecisions()
|
||||
for i in 0..<length {
|
||||
decisionToDFA.append(DFA(ReferenceParser._ATN.getDecisionState(i)!, i))
|
||||
}
|
||||
return decisionToDFA
|
||||
}()
|
||||
|
||||
internal static let _sharedContextCache = PredictionContextCache()
|
||||
|
||||
public
|
||||
enum Tokens: Int {
|
||||
case EOF = -1, T__0 = 1, T__1 = 2, T__2 = 3, T__3 = 4, T__4 = 5, T__5 = 6,
|
||||
DIGIT = 7, LETTER = 8
|
||||
}
|
||||
|
||||
public
|
||||
static let RULE_root = 0, RULE_host = 1, RULE_port = 2, RULE_host_component = 3,
|
||||
RULE_namespace = 4, RULE_namespace_component = 5, RULE_reference = 6,
|
||||
RULE_tag = 7, RULE_separator = 8, RULE_name = 9
|
||||
|
||||
public
|
||||
static let ruleNames: [String] = [
|
||||
"root", "host", "port", "host_component", "namespace", "namespace_component",
|
||||
"reference", "tag", "separator", "name"
|
||||
]
|
||||
|
||||
private static let _LITERAL_NAMES: [String?] = [
|
||||
nil, "':'", "'/'", "'.'", "'-'", "'@'", "'_'"
|
||||
]
|
||||
private static let _SYMBOLIC_NAMES: [String?] = [
|
||||
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
|
||||
]
|
||||
public
|
||||
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
||||
|
||||
override open
|
||||
func getGrammarFileName() -> String { return "java-escape" }
|
||||
|
||||
override open
|
||||
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
|
||||
|
||||
override open
|
||||
func getSerializedATN() -> [Int] { return ReferenceParser._serializedATN }
|
||||
|
||||
override open
|
||||
func getATN() -> ATN { return ReferenceParser._ATN }
|
||||
|
||||
|
||||
override open
|
||||
func getVocabulary() -> Vocabulary {
|
||||
return ReferenceParser.VOCABULARY
|
||||
}
|
||||
|
||||
override public
|
||||
init(_ input:TokenStream) throws {
|
||||
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
||||
try super.init(input)
|
||||
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
|
||||
}
|
||||
|
||||
|
||||
public class RootContext: ParserRuleContext {
|
||||
open
|
||||
func host() -> HostContext? {
|
||||
return getRuleContext(HostContext.self, 0)
|
||||
}
|
||||
open
|
||||
func namespace() -> NamespaceContext? {
|
||||
return getRuleContext(NamespaceContext.self, 0)
|
||||
}
|
||||
open
|
||||
func EOF() -> TerminalNode? {
|
||||
return getToken(ReferenceParser.Tokens.EOF.rawValue, 0)
|
||||
}
|
||||
open
|
||||
func port() -> PortContext? {
|
||||
return getRuleContext(PortContext.self, 0)
|
||||
}
|
||||
open
|
||||
func reference() -> ReferenceContext? {
|
||||
return getRuleContext(ReferenceContext.self, 0)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_root
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterRoot(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitRoot(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func root() throws -> RootContext {
|
||||
var _localctx: RootContext
|
||||
_localctx = RootContext(_ctx, getState())
|
||||
try enterRule(_localctx, 0, ReferenceParser.RULE_root)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(20)
|
||||
try host()
|
||||
setState(23)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
if (_la == ReferenceParser.Tokens.T__0.rawValue) {
|
||||
setState(21)
|
||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||
setState(22)
|
||||
try port()
|
||||
|
||||
}
|
||||
|
||||
setState(25)
|
||||
try match(ReferenceParser.Tokens.T__1.rawValue)
|
||||
setState(26)
|
||||
try namespace()
|
||||
setState(28)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
if (_la == ReferenceParser.Tokens.T__0.rawValue || _la == ReferenceParser.Tokens.T__4.rawValue) {
|
||||
setState(27)
|
||||
try reference()
|
||||
|
||||
}
|
||||
|
||||
setState(30)
|
||||
try match(ReferenceParser.Tokens.EOF.rawValue)
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class HostContext: ParserRuleContext {
|
||||
open
|
||||
func host_component() -> [Host_componentContext] {
|
||||
return getRuleContexts(Host_componentContext.self)
|
||||
}
|
||||
open
|
||||
func host_component(_ i: Int) -> Host_componentContext? {
|
||||
return getRuleContext(Host_componentContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_host
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterHost(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitHost(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func host() throws -> HostContext {
|
||||
var _localctx: HostContext
|
||||
_localctx = HostContext(_ctx, getState())
|
||||
try enterRule(_localctx, 2, ReferenceParser.RULE_host)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(32)
|
||||
try host_component()
|
||||
setState(37)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
while (_la == ReferenceParser.Tokens.T__2.rawValue) {
|
||||
setState(33)
|
||||
try match(ReferenceParser.Tokens.T__2.rawValue)
|
||||
setState(34)
|
||||
try host_component()
|
||||
|
||||
|
||||
setState(39)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
}
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class PortContext: ParserRuleContext {
|
||||
open
|
||||
func DIGIT() -> [TerminalNode] {
|
||||
return getTokens(ReferenceParser.Tokens.DIGIT.rawValue)
|
||||
}
|
||||
open
|
||||
func DIGIT(_ i:Int) -> TerminalNode? {
|
||||
return getToken(ReferenceParser.Tokens.DIGIT.rawValue, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_port
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterPort(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitPort(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func port() throws -> PortContext {
|
||||
var _localctx: PortContext
|
||||
_localctx = PortContext(_ctx, getState())
|
||||
try enterRule(_localctx, 4, ReferenceParser.RULE_port)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(41)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
repeat {
|
||||
setState(40)
|
||||
try match(ReferenceParser.Tokens.DIGIT.rawValue)
|
||||
|
||||
|
||||
setState(43);
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
} while (_la == ReferenceParser.Tokens.DIGIT.rawValue)
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class Host_componentContext: ParserRuleContext {
|
||||
open
|
||||
func name() -> [NameContext] {
|
||||
return getRuleContexts(NameContext.self)
|
||||
}
|
||||
open
|
||||
func name(_ i: Int) -> NameContext? {
|
||||
return getRuleContext(NameContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_host_component
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterHost_component(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitHost_component(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func host_component() throws -> Host_componentContext {
|
||||
var _localctx: Host_componentContext
|
||||
_localctx = Host_componentContext(_ctx, getState())
|
||||
try enterRule(_localctx, 6, ReferenceParser.RULE_host_component)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(45)
|
||||
try name()
|
||||
setState(50)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
while (_la == ReferenceParser.Tokens.T__3.rawValue) {
|
||||
setState(46)
|
||||
try match(ReferenceParser.Tokens.T__3.rawValue)
|
||||
setState(47)
|
||||
try name()
|
||||
|
||||
|
||||
setState(52)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
}
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class NamespaceContext: ParserRuleContext {
|
||||
open
|
||||
func namespace_component() -> [Namespace_componentContext] {
|
||||
return getRuleContexts(Namespace_componentContext.self)
|
||||
}
|
||||
open
|
||||
func namespace_component(_ i: Int) -> Namespace_componentContext? {
|
||||
return getRuleContext(Namespace_componentContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_namespace
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterNamespace(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitNamespace(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func namespace() throws -> NamespaceContext {
|
||||
var _localctx: NamespaceContext
|
||||
_localctx = NamespaceContext(_ctx, getState())
|
||||
try enterRule(_localctx, 8, ReferenceParser.RULE_namespace)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(53)
|
||||
try namespace_component()
|
||||
setState(58)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
while (_la == ReferenceParser.Tokens.T__1.rawValue) {
|
||||
setState(54)
|
||||
try match(ReferenceParser.Tokens.T__1.rawValue)
|
||||
setState(55)
|
||||
try namespace_component()
|
||||
|
||||
|
||||
setState(60)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
}
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class Namespace_componentContext: ParserRuleContext {
|
||||
open
|
||||
func name() -> [NameContext] {
|
||||
return getRuleContexts(NameContext.self)
|
||||
}
|
||||
open
|
||||
func name(_ i: Int) -> NameContext? {
|
||||
return getRuleContext(NameContext.self, i)
|
||||
}
|
||||
open
|
||||
func separator() -> [SeparatorContext] {
|
||||
return getRuleContexts(SeparatorContext.self)
|
||||
}
|
||||
open
|
||||
func separator(_ i: Int) -> SeparatorContext? {
|
||||
return getRuleContext(SeparatorContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_namespace_component
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterNamespace_component(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitNamespace_component(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func namespace_component() throws -> Namespace_componentContext {
|
||||
var _localctx: Namespace_componentContext
|
||||
_localctx = Namespace_componentContext(_ctx, getState())
|
||||
try enterRule(_localctx, 10, ReferenceParser.RULE_namespace_component)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(65)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
repeat {
|
||||
setState(61)
|
||||
try name()
|
||||
setState(63)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
if ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
||||
setState(62)
|
||||
try separator()
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
setState(67);
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
} while (_la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue)
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class ReferenceContext: ParserRuleContext {
|
||||
open
|
||||
func tag() -> TagContext? {
|
||||
return getRuleContext(TagContext.self, 0)
|
||||
}
|
||||
open
|
||||
func name() -> [NameContext] {
|
||||
return getRuleContexts(NameContext.self)
|
||||
}
|
||||
open
|
||||
func name(_ i: Int) -> NameContext? {
|
||||
return getRuleContext(NameContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_reference
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterReference(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitReference(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func reference() throws -> ReferenceContext {
|
||||
var _localctx: ReferenceContext
|
||||
_localctx = ReferenceContext(_ctx, getState())
|
||||
try enterRule(_localctx, 12, ReferenceParser.RULE_reference)
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
setState(76)
|
||||
try _errHandler.sync(self)
|
||||
switch (ReferenceParser.Tokens(rawValue: try _input.LA(1))!) {
|
||||
case .T__0:
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(69)
|
||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||
setState(70)
|
||||
try tag()
|
||||
|
||||
|
||||
break
|
||||
|
||||
case .T__4:
|
||||
try enterOuterAlt(_localctx, 2)
|
||||
setState(71)
|
||||
try match(ReferenceParser.Tokens.T__4.rawValue)
|
||||
setState(72)
|
||||
try name()
|
||||
setState(73)
|
||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||
setState(74)
|
||||
try name()
|
||||
|
||||
|
||||
break
|
||||
default:
|
||||
throw ANTLRException.recognition(e: NoViableAltException(self))
|
||||
}
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class TagContext: ParserRuleContext {
|
||||
open
|
||||
func name() -> [NameContext] {
|
||||
return getRuleContexts(NameContext.self)
|
||||
}
|
||||
open
|
||||
func name(_ i: Int) -> NameContext? {
|
||||
return getRuleContext(NameContext.self, i)
|
||||
}
|
||||
open
|
||||
func separator() -> [SeparatorContext] {
|
||||
return getRuleContexts(SeparatorContext.self)
|
||||
}
|
||||
open
|
||||
func separator(_ i: Int) -> SeparatorContext? {
|
||||
return getRuleContext(SeparatorContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_tag
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterTag(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitTag(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func tag() throws -> TagContext {
|
||||
var _localctx: TagContext
|
||||
_localctx = TagContext(_ctx, getState())
|
||||
try enterRule(_localctx, 14, ReferenceParser.RULE_tag)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(78)
|
||||
try name()
|
||||
setState(84)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
while ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
||||
setState(79)
|
||||
try separator()
|
||||
setState(80)
|
||||
try name()
|
||||
|
||||
|
||||
setState(86)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
}
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class SeparatorContext: ParserRuleContext {
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_separator
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterSeparator(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitSeparator(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func separator() throws -> SeparatorContext {
|
||||
var _localctx: SeparatorContext
|
||||
_localctx = SeparatorContext(_ctx, getState())
|
||||
try enterRule(_localctx, 16, ReferenceParser.RULE_separator)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(87)
|
||||
_la = try _input.LA(1)
|
||||
if (!((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
|
||||
try _errHandler.recoverInline(self)
|
||||
}
|
||||
else {
|
||||
_errHandler.reportMatch(self)
|
||||
try consume()
|
||||
}
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class NameContext: ParserRuleContext {
|
||||
open
|
||||
func LETTER() -> [TerminalNode] {
|
||||
return getTokens(ReferenceParser.Tokens.LETTER.rawValue)
|
||||
}
|
||||
open
|
||||
func LETTER(_ i:Int) -> TerminalNode? {
|
||||
return getToken(ReferenceParser.Tokens.LETTER.rawValue, i)
|
||||
}
|
||||
open
|
||||
func DIGIT() -> [TerminalNode] {
|
||||
return getTokens(ReferenceParser.Tokens.DIGIT.rawValue)
|
||||
}
|
||||
open
|
||||
func DIGIT(_ i:Int) -> TerminalNode? {
|
||||
return getToken(ReferenceParser.Tokens.DIGIT.rawValue, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_name
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterName(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitName(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func name() throws -> NameContext {
|
||||
var _localctx: NameContext
|
||||
_localctx = NameContext(_ctx, getState())
|
||||
try enterRule(_localctx, 18, ReferenceParser.RULE_name)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
var _alt:Int
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(90);
|
||||
try _errHandler.sync(self)
|
||||
_alt = 1;
|
||||
repeat {
|
||||
switch (_alt) {
|
||||
case 1:
|
||||
setState(89)
|
||||
_la = try _input.LA(1)
|
||||
if (!(_la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue)) {
|
||||
try _errHandler.recoverInline(self)
|
||||
}
|
||||
else {
|
||||
_errHandler.reportMatch(self)
|
||||
try consume()
|
||||
}
|
||||
|
||||
|
||||
break
|
||||
default:
|
||||
throw ANTLRException.recognition(e: NoViableAltException(self))
|
||||
}
|
||||
setState(92);
|
||||
try _errHandler.sync(self)
|
||||
_alt = try getInterpreter().adaptivePredict(_input,10,_ctx)
|
||||
} while (_alt != 2 && _alt != ATN.INVALID_ALT_NUMBER)
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
static let _serializedATN:[Int] = [
|
||||
4,1,8,95,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,
|
||||
7,2,8,7,8,2,9,7,9,1,0,1,0,1,0,3,0,24,8,0,1,0,1,0,1,0,3,0,29,8,0,1,0,1,
|
||||
0,1,1,1,1,1,1,5,1,36,8,1,10,1,12,1,39,9,1,1,2,4,2,42,8,2,11,2,12,2,43,
|
||||
1,3,1,3,1,3,5,3,49,8,3,10,3,12,3,52,9,3,1,4,1,4,1,4,5,4,57,8,4,10,4,12,
|
||||
4,60,9,4,1,5,1,5,3,5,64,8,5,4,5,66,8,5,11,5,12,5,67,1,6,1,6,1,6,1,6,1,
|
||||
6,1,6,1,6,3,6,77,8,6,1,7,1,7,1,7,1,7,5,7,83,8,7,10,7,12,7,86,9,7,1,8,1,
|
||||
8,1,9,4,9,91,8,9,11,9,12,9,92,1,9,0,0,10,0,2,4,6,8,10,12,14,16,18,0,2,
|
||||
2,0,3,4,6,6,1,0,7,8,95,0,20,1,0,0,0,2,32,1,0,0,0,4,41,1,0,0,0,6,45,1,0,
|
||||
0,0,8,53,1,0,0,0,10,65,1,0,0,0,12,76,1,0,0,0,14,78,1,0,0,0,16,87,1,0,0,
|
||||
0,18,90,1,0,0,0,20,23,3,2,1,0,21,22,5,1,0,0,22,24,3,4,2,0,23,21,1,0,0,
|
||||
0,23,24,1,0,0,0,24,25,1,0,0,0,25,26,5,2,0,0,26,28,3,8,4,0,27,29,3,12,6,
|
||||
0,28,27,1,0,0,0,28,29,1,0,0,0,29,30,1,0,0,0,30,31,5,0,0,1,31,1,1,0,0,0,
|
||||
32,37,3,6,3,0,33,34,5,3,0,0,34,36,3,6,3,0,35,33,1,0,0,0,36,39,1,0,0,0,
|
||||
37,35,1,0,0,0,37,38,1,0,0,0,38,3,1,0,0,0,39,37,1,0,0,0,40,42,5,7,0,0,41,
|
||||
40,1,0,0,0,42,43,1,0,0,0,43,41,1,0,0,0,43,44,1,0,0,0,44,5,1,0,0,0,45,50,
|
||||
3,18,9,0,46,47,5,4,0,0,47,49,3,18,9,0,48,46,1,0,0,0,49,52,1,0,0,0,50,48,
|
||||
1,0,0,0,50,51,1,0,0,0,51,7,1,0,0,0,52,50,1,0,0,0,53,58,3,10,5,0,54,55,
|
||||
5,2,0,0,55,57,3,10,5,0,56,54,1,0,0,0,57,60,1,0,0,0,58,56,1,0,0,0,58,59,
|
||||
1,0,0,0,59,9,1,0,0,0,60,58,1,0,0,0,61,63,3,18,9,0,62,64,3,16,8,0,63,62,
|
||||
1,0,0,0,63,64,1,0,0,0,64,66,1,0,0,0,65,61,1,0,0,0,66,67,1,0,0,0,67,65,
|
||||
1,0,0,0,67,68,1,0,0,0,68,11,1,0,0,0,69,70,5,1,0,0,70,77,3,14,7,0,71,72,
|
||||
5,5,0,0,72,73,3,18,9,0,73,74,5,1,0,0,74,75,3,18,9,0,75,77,1,0,0,0,76,69,
|
||||
1,0,0,0,76,71,1,0,0,0,77,13,1,0,0,0,78,84,3,18,9,0,79,80,3,16,8,0,80,81,
|
||||
3,18,9,0,81,83,1,0,0,0,82,79,1,0,0,0,83,86,1,0,0,0,84,82,1,0,0,0,84,85,
|
||||
1,0,0,0,85,15,1,0,0,0,86,84,1,0,0,0,87,88,7,0,0,0,88,17,1,0,0,0,89,91,
|
||||
7,1,0,0,90,89,1,0,0,0,91,92,1,0,0,0,92,90,1,0,0,0,92,93,1,0,0,0,93,19,
|
||||
1,0,0,0,11,23,28,37,43,50,58,63,67,76,84,92
|
||||
]
|
||||
|
||||
public
|
||||
static let _ATN = try! ATNDeserializer().deserialize(_serializedATN)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
all: clean
|
||||
antlr -o Generated -Dlanguage=Swift Reference.g4
|
||||
|
||||
clean:
|
||||
rm -rf Generated
|
||||
@@ -0,0 +1,14 @@
|
||||
grammar Reference;
|
||||
|
||||
root: host (':' port)? '/' namespace reference? EOF;
|
||||
host: host_component ('.' host_component)*;
|
||||
port: DIGIT+;
|
||||
host_component: name ('-' name)*;
|
||||
namespace: namespace_component ('/' namespace_component)*;
|
||||
namespace_component: (name separator?)+;
|
||||
reference: (':' tag) | ('@' name ':' name);
|
||||
tag: name (separator name)*;
|
||||
separator: '.' | '-' | '_';
|
||||
name: (LETTER | DIGIT)+;
|
||||
DIGIT: [0-9];
|
||||
LETTER: [A-Za-z];
|
||||
+120
-106
@@ -1,28 +1,42 @@
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOHTTP1
|
||||
import AsyncHTTPClient
|
||||
import Algorithms
|
||||
import NIOPosix
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -78,14 +92,6 @@ struct TokenResponse: Decodable, Authentication {
|
||||
}
|
||||
|
||||
class Registry {
|
||||
private let httpClient = HTTPClient(
|
||||
eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup(numberOfThreads: 1))
|
||||
)
|
||||
|
||||
deinit {
|
||||
try! httpClient.syncShutdown()
|
||||
}
|
||||
|
||||
let baseURL: URL
|
||||
let namespace: String
|
||||
let credentialsProviders: [CredentialsProvider]
|
||||
@@ -94,7 +100,7 @@ class Registry {
|
||||
|
||||
init(urlComponents: URLComponents,
|
||||
namespace: String,
|
||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
baseURL = urlComponents.url!
|
||||
self.namespace = namespace
|
||||
@@ -105,7 +111,7 @@ class Registry {
|
||||
host: String,
|
||||
namespace: String,
|
||||
insecure: Bool = false,
|
||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
let proto = insecure ? "http" : "https"
|
||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||
@@ -114,43 +120,41 @@ class Registry {
|
||||
}
|
||||
|
||||
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 manifest.toJSON()
|
||||
|
||||
let response = try await endpointRequest(.PUT, "\(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() ?? "")
|
||||
let (data, response) = try await dataRequest(.PUT, endpointURL("\(namespace)/manifests/\(reference)"),
|
||||
headers: ["Content-Type": manifest.mediaType],
|
||||
body: manifestJSON)
|
||||
if response.statusCode != HTTPCode.Created.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
|
||||
details: data.asText())
|
||||
}
|
||||
|
||||
return Digest.hash(manifestJSON)
|
||||
}
|
||||
|
||||
public func pullManifest(reference: String) async throws -> (OCIManifest, Data) {
|
||||
let response = try await endpointRequest(.GET, "\(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 ?? "")
|
||||
let (data, response) = try await dataRequest(.GET, endpointURL("\(namespace)/manifests/\(reference)"),
|
||||
headers: ["Accept": ociManifestMediaType])
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
|
||||
details: data.asText())
|
||||
}
|
||||
|
||||
let manifestData = try await response.body.readResponse()
|
||||
let manifest = try OCIManifest(fromJSON: 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
|
||||
}
|
||||
|
||||
@@ -163,22 +167,21 @@ class Registry {
|
||||
|
||||
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
|
||||
// Initiate a blob upload
|
||||
let postResponse = try await endpointRequest(.POST, "\(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 ?? "")
|
||||
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
|
||||
headers: ["Content-Length": "0"])
|
||||
if postResponse.statusCode != HTTPCode.Accepted.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
|
||||
details: data.asText())
|
||||
}
|
||||
|
||||
// Figure out where to upload the blob
|
||||
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||
|
||||
let digest = Digest.hash(fromData)
|
||||
|
||||
|
||||
if chunkSizeMb == 0 {
|
||||
// monolithic upload
|
||||
let response = try await rawRequest(
|
||||
let (data, response) = try await dataRequest(
|
||||
.PUT,
|
||||
uploadLocation,
|
||||
headers: [
|
||||
@@ -187,10 +190,9 @@ class Registry {
|
||||
parameters: ["digest": digest],
|
||||
body: fromData
|
||||
)
|
||||
if response.status != .created {
|
||||
let body = try await response.body.readTextResponse()
|
||||
if response.statusCode != HTTPCode.Created.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
|
||||
code: response.status.code, details: body ?? "")
|
||||
code: response.statusCode, details: data.asText())
|
||||
}
|
||||
return digest
|
||||
}
|
||||
@@ -200,7 +202,7 @@ class Registry {
|
||||
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 response = try await rawRequest(
|
||||
let (data, response) = try await dataRequest(
|
||||
lastChunk ? .PUT : .PATCH,
|
||||
uploadLocation,
|
||||
headers: [
|
||||
@@ -210,56 +212,63 @@ class Registry {
|
||||
parameters: lastChunk ? ["digest": digest] : [:],
|
||||
body: chunk
|
||||
)
|
||||
let expectedStatus: HTTPResponseStatus = lastChunk ? .created : .accepted
|
||||
if response.status != expectedStatus {
|
||||
let body = try await response.body.readTextResponse()
|
||||
// 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.status.code, details: body ?? "")
|
||||
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 {
|
||||
for try await part in channel {
|
||||
try Task.checkCancellation()
|
||||
|
||||
try handler(part)
|
||||
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 -> HTTPClientResponse {
|
||||
) async throws -> (Data, HTTPURLResponse) {
|
||||
let (channel, response) = try await channelRequest(method, urlComponents,
|
||||
headers: headers, parameters: parameters, body: body, doAuth: doAuth)
|
||||
|
||||
return (try await channel.asData(), response)
|
||||
}
|
||||
|
||||
private func channelRequest(
|
||||
_ method: HTTPMethod,
|
||||
_ urlComponents: URLComponents,
|
||||
headers: Dictionary<String, String> = Dictionary(),
|
||||
parameters: Dictionary<String, String> = Dictionary(),
|
||||
body: Data? = nil,
|
||||
doAuth: Bool = true,
|
||||
viaFile: Bool = false
|
||||
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
var urlComponents = urlComponents
|
||||
|
||||
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
||||
@@ -269,14 +278,14 @@ class Registry {
|
||||
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.headers.add(name: "Content-Length", value: "\(body!.count)")
|
||||
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
|
||||
@@ -284,33 +293,34 @@ class Registry {
|
||||
currentAuthToken = nil
|
||||
}
|
||||
|
||||
var response = try await authAwareRequest(request: request)
|
||||
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||
|
||||
if doAuth && response.status == .unauthorized {
|
||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||
_ = try await channel.asData()
|
||||
try await auth(response: response)
|
||||
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!) {
|
||||
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
||||
if let (user, password) = try lookupCredentials() {
|
||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if wwwAuthenticate.scheme != "Bearer" {
|
||||
if wwwAuthenticate.scheme.lowercased() != "bearer" {
|
||||
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
|
||||
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
|
||||
}
|
||||
@@ -340,23 +350,27 @@ class Registry {
|
||||
|
||||
var headers: Dictionary<String, String> = Dictionary()
|
||||
|
||||
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
||||
if let (user, password) = try lookupCredentials() {
|
||||
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
|
||||
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
||||
}
|
||||
|
||||
let response = try await rawRequest(.GET, authenticateURL, headers: headers, doAuth: false)
|
||||
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 lookupCredentials(host: String) throws -> (String, String)? {
|
||||
private func lookupCredentials() throws -> (String, String)? {
|
||||
var host = baseURL.host!
|
||||
|
||||
if let port = baseURL.port {
|
||||
host += ":\(port)"
|
||||
}
|
||||
|
||||
for provider in credentialsProviders {
|
||||
if let (user, password) = try provider.retrieve(host: host) {
|
||||
return (user, password)
|
||||
@@ -365,14 +379,14 @@ class Registry {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func authAwareRequest(request: HTTPClientRequest) async throws -> HTTPClientResponse {
|
||||
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
var request = request
|
||||
|
||||
if let token = currentAuthToken {
|
||||
let (name, value) = token.header()
|
||||
request.headers.add(name: name, value: value)
|
||||
request.addValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
|
||||
return try await httpClient.execute(request, deadline: .distantFuture)
|
||||
return try await Fetcher.fetch(request, viaFile: viaFile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import Parsing
|
||||
import Antlr4
|
||||
|
||||
struct Reference: Comparable, Hashable, CustomStringConvertible {
|
||||
enum ReferenceType: Comparable {
|
||||
@@ -46,6 +46,39 @@ struct Reference: Comparable, Hashable, CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
class ReferenceCollector: ReferenceBaseListener {
|
||||
var host: String? = nil
|
||||
var port: String? = nil
|
||||
var namespace: String? = nil
|
||||
var reference: String? = nil
|
||||
|
||||
override func exitHost(_ ctx: ReferenceParser.HostContext) {
|
||||
host = ctx.getText()
|
||||
}
|
||||
|
||||
override func exitPort(_ ctx: ReferenceParser.PortContext) {
|
||||
port = ctx.getText()
|
||||
}
|
||||
|
||||
override func exitNamespace(_ ctx: ReferenceParser.NamespaceContext) {
|
||||
namespace = ctx.getText()
|
||||
}
|
||||
|
||||
override func exitReference(_ ctx: ReferenceParser.ReferenceContext) {
|
||||
reference = ctx.getText()
|
||||
}
|
||||
}
|
||||
|
||||
class ErrorCollector: BaseErrorListener {
|
||||
var error: String? = nil
|
||||
|
||||
override func syntaxError<T>(_ recognizer: Recognizer<T>, _ offendingSymbol: AnyObject?, _ line: Int, _ charPositionInLine: Int, _ msg: String, _ e: AnyObject?) {
|
||||
if error == nil {
|
||||
error = "\(msg) (character \(charPositionInLine + 1))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteName: Comparable, Hashable, CustomStringConvertible {
|
||||
var host: String
|
||||
var namespace: String
|
||||
@@ -58,51 +91,40 @@ struct RemoteName: Comparable, Hashable, CustomStringConvertible {
|
||||
}
|
||||
|
||||
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 {
|
||||
Reference(tag: String($0))
|
||||
}
|
||||
}
|
||||
Parse {
|
||||
"@sha256:"
|
||||
csHex.map {
|
||||
Reference(digest: "sha256:" + String($0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
End()
|
||||
let referenceCollector = ReferenceCollector()
|
||||
try ParseTreeWalker().walk(referenceCollector, try parser.root())
|
||||
|
||||
if let error = errorCollector.error {
|
||||
throw RuntimeError.FailedToParseRemoteName("\(error)")
|
||||
}
|
||||
|
||||
let result = try parser.parse(name)
|
||||
|
||||
host = String(result.0)
|
||||
namespace = String(result.1)
|
||||
reference = result.2 ?? Reference(tag: "latest")
|
||||
host = referenceCollector.host!
|
||||
if let port = referenceCollector.port {
|
||||
host += ":" + port
|
||||
}
|
||||
namespace = referenceCollector.namespace!
|
||||
if let reference = referenceCollector.reference {
|
||||
if reference.starts(with: "@sha256:") {
|
||||
self.reference = Reference(digest: String(reference.dropFirst(1)))
|
||||
} else if reference.starts(with: ":") {
|
||||
self.reference = Reference(tag: String(reference.dropFirst(1)))
|
||||
} else {
|
||||
throw RuntimeError.FailedToParseRemoteName("unknown reference format")
|
||||
}
|
||||
} else {
|
||||
self.reference = Reference(tag: "latest")
|
||||
}
|
||||
}
|
||||
|
||||
static func <(lhs: RemoteName, rhs: RemoteName) -> Bool {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import System
|
||||
|
||||
class PIDLock {
|
||||
let url: URL
|
||||
let fd: Int32
|
||||
|
||||
init(lockURL: URL) throws {
|
||||
url = lockURL
|
||||
fd = open(lockURL.path, O_RDWR)
|
||||
}
|
||||
|
||||
deinit {
|
||||
close(fd)
|
||||
}
|
||||
|
||||
func trylock() throws -> Bool {
|
||||
let (locked, _) = try lockWrapper(F_SETLK, F_WRLCK, "failed to lock \(url)")
|
||||
return locked
|
||||
}
|
||||
|
||||
func lock() throws {
|
||||
_ = try lockWrapper(F_SETLKW, F_WRLCK, "failed to lock \(url)")
|
||||
}
|
||||
|
||||
func unlock() throws {
|
||||
_ = try lockWrapper(F_SETLK, F_UNLCK, "failed to unlock \(url)")
|
||||
}
|
||||
|
||||
func pid() throws -> pid_t {
|
||||
let (_, result) = try lockWrapper(F_GETLK, F_RDLCK, "failed to get lock \(url) status")
|
||||
|
||||
return result.l_pid
|
||||
}
|
||||
|
||||
func lockWrapper(_ operation: Int32, _ type: Int32, _ message: String) throws -> (Bool, flock) {
|
||||
var result = flock(l_start: 0, l_len: 0, l_pid: 0, l_type: Int16(type), l_whence: Int16(SEEK_SET))
|
||||
|
||||
let ret = fcntl(fd, operation, &result)
|
||||
if ret != 0 {
|
||||
if operation == F_SETLK && errno == EAGAIN {
|
||||
return (false, result)
|
||||
}
|
||||
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError.PIDLockFailed("\(message): \(details)")
|
||||
}
|
||||
|
||||
return (true, result)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
struct PassphraseGenerator: Sequence {
|
||||
func makeIterator() -> PassphraseIterator {
|
||||
PassphraseIterator()
|
||||
}
|
||||
func makeIterator() -> PassphraseIterator {
|
||||
PassphraseIterator()
|
||||
}
|
||||
}
|
||||
|
||||
struct PassphraseIterator: IteratorProtocol {
|
||||
mutating func next() -> String? {
|
||||
passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
|
||||
}
|
||||
mutating func next() -> String? {
|
||||
passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ let passphrases = [
|
||||
"act",
|
||||
"action",
|
||||
"actor",
|
||||
" actress",
|
||||
"actress",
|
||||
"actual",
|
||||
"adapt",
|
||||
"add",
|
||||
@@ -829,7 +829,7 @@ let passphrases = [
|
||||
"grow",
|
||||
"grunt",
|
||||
"guard",
|
||||
" guess",
|
||||
"guess",
|
||||
"guide",
|
||||
"guilt",
|
||||
"guitar",
|
||||
@@ -1476,7 +1476,7 @@ let passphrases = [
|
||||
"retire",
|
||||
"retreat",
|
||||
"return",
|
||||
" reunion",
|
||||
"reunion",
|
||||
"reveal",
|
||||
"review",
|
||||
"reward",
|
||||
@@ -1764,7 +1764,7 @@ let passphrases = [
|
||||
"swim",
|
||||
"swing",
|
||||
"switch",
|
||||
" sword",
|
||||
"sword",
|
||||
"symbol",
|
||||
"symptom",
|
||||
"syrup",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
enum Architecture: String, Codable {
|
||||
case arm64
|
||||
case amd64
|
||||
case arm64
|
||||
case amd64
|
||||
}
|
||||
|
||||
func CurrentArchitecture() -> Architecture {
|
||||
#if arch(arm64)
|
||||
return .arm64
|
||||
#elseif arch(x86_64)
|
||||
return .amd64
|
||||
#endif
|
||||
#if arch(arm64)
|
||||
return .arm64
|
||||
#elseif arch(x86_64)
|
||||
return .amd64
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
import Virtualization
|
||||
|
||||
struct Darwin: Platform {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
|
||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
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
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
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
|
||||
)
|
||||
]
|
||||
|
||||
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
|
||||
return result
|
||||
}
|
||||
|
||||
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()]
|
||||
}
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 13, *) {
|
||||
// Trackpad is only supported starting with macOS Ventura
|
||||
// macOS Monterey will continue using a USB device == .darwin
|
||||
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
} else {
|
||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,36 +2,36 @@ import Virtualization
|
||||
|
||||
@available(macOS 13, *)
|
||||
struct Linux: Platform {
|
||||
func os() -> OS {
|
||||
.linux
|
||||
}
|
||||
func os() -> OS {
|
||||
.linux
|
||||
}
|
||||
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||
let result = VZEFIBootLoader()
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||
let result = VZEFIBootLoader()
|
||||
|
||||
result.variableStore = VZEFIVariableStore(url: nvramURL)
|
||||
result.variableStore = VZEFIVariableStore(url: nvramURL)
|
||||
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||
VZGenericPlatformConfiguration()
|
||||
}
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||
VZGenericPlatformConfiguration()
|
||||
}
|
||||
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
let result = VZVirtioGraphicsDeviceConfiguration()
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
let result = VZVirtioGraphicsDeviceConfiguration()
|
||||
|
||||
result.scanouts = [
|
||||
VZVirtioGraphicsScanoutConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height
|
||||
)
|
||||
]
|
||||
result.scanouts = [
|
||||
VZVirtioGraphicsScanoutConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height
|
||||
)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Virtualization
|
||||
|
||||
enum OS: String, Codable {
|
||||
case darwin
|
||||
case linux
|
||||
case darwin
|
||||
case linux
|
||||
}
|
||||
|
||||
@@ -1,9 +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]
|
||||
func os() -> OS
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ protocol PrunableStorage {
|
||||
}
|
||||
|
||||
protocol Prunable {
|
||||
var url: URL { get }
|
||||
func delete() throws
|
||||
func accessDate() throws -> Date
|
||||
func sizeBytes() throws -> Int
|
||||
|
||||
+74
-1
@@ -1,5 +1,15 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Puppy
|
||||
import Sentry
|
||||
|
||||
var puppy = Puppy.default
|
||||
|
||||
class LogFormatter: LogFormattable {
|
||||
func formatMessage(_ level: LogLevel, message: String, tag: String, function: String, file: String, line: UInt, swiftLogInfo: [String: String], label: String, date: Date, threadID: UInt64) -> String {
|
||||
"\(date) \(level) \(message)"
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct Root: AsyncParsableCommand {
|
||||
@@ -11,6 +21,7 @@ struct Root: AsyncParsableCommand {
|
||||
Clone.self,
|
||||
Run.self,
|
||||
Set.self,
|
||||
Get.self,
|
||||
List.self,
|
||||
Login.self,
|
||||
IP.self,
|
||||
@@ -18,10 +29,40 @@ struct Root: AsyncParsableCommand {
|
||||
Push.self,
|
||||
Prune.self,
|
||||
Rename.self,
|
||||
Stop.self,
|
||||
Delete.self,
|
||||
])
|
||||
|
||||
public static func main() async throws {
|
||||
// Initialize Sentry
|
||||
if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
|
||||
SentrySDK.start { options in
|
||||
options.dsn = dsn
|
||||
options.releaseName = CI.release
|
||||
options.tracesSampleRate = Float(
|
||||
ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
|
||||
) as NSNumber?
|
||||
|
||||
// By default only 5XX are captured
|
||||
// Let's capture everything but 401 (unauthorized)
|
||||
options.enableCaptureFailedRequests = true
|
||||
options.failedRequestStatusCodes = [
|
||||
HttpStatusCodeRange(min: 400, max: 400),
|
||||
HttpStatusCodeRange(min: 402, max: 599)
|
||||
]
|
||||
}
|
||||
}
|
||||
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
||||
|
||||
// Enrich future events with Cirrus CI-specific tags
|
||||
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
||||
SentrySDK.configureScope { scope in
|
||||
for (key, value) in tags.split(separator: ",").compactMap({ parseCirrusSentryTag($0) }) {
|
||||
scope.setTag(value: value, key: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the default SIGINT handled is disabled,
|
||||
// otherwise there's a race between two handlers
|
||||
signal(SIGINT, SIG_IGN);
|
||||
@@ -33,6 +74,15 @@ 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()
|
||||
@@ -46,7 +96,30 @@ struct Root: AsyncParsableCommand {
|
||||
try command.run()
|
||||
}
|
||||
} catch {
|
||||
exit(withError: error)
|
||||
if exitCode(for: error).rawValue == 0 {
|
||||
exit(withError: error)
|
||||
}
|
||||
|
||||
// Capture the error into Sentry
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
|
||||
print(error)
|
||||
|
||||
if let errorWithExitCode = error as? HasExitCode {
|
||||
Foundation.exit(errorWithExitCode.exitCode)
|
||||
}
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseCirrusSentryTag(_ tag: String.SubSequence) -> (String, String)? {
|
||||
let splits = tag.split(separator: "=", maxSplits: 1)
|
||||
if splits.count != 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (String(splits[0]), String(splits[1]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ extension URL {
|
||||
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
||||
let ret = utimes(path, times)
|
||||
if ret != 0 {
|
||||
throw RuntimeError("utimes(2) failed: \(ret.explanation())")
|
||||
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(ret.explanation())")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
extension URL: Prunable {
|
||||
var url: URL {
|
||||
self
|
||||
}
|
||||
|
||||
func delete() throws {
|
||||
try FileManager.default.removeItem(at: self)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func resolveBinaryPath(_ name: String) -> URL? {
|
||||
|
||||
for pathComponent in path.split(separator: ":") {
|
||||
let url = URL(fileURLWithPath: String(pathComponent))
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
return url
|
||||
|
||||
+88
-70
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
import AsyncAlgorithms
|
||||
|
||||
struct UnsupportedRestoreImageError: Error {
|
||||
}
|
||||
@@ -13,8 +14,8 @@ 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"
|
||||
init(_ what: String, _ plural: String, _ requires: String = "running macOS 13.0 (Ventura) or newer") {
|
||||
description = "error: \(what) \(plural) only supported on hosts \(requires)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +31,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
// VM's config
|
||||
var name: String
|
||||
|
||||
|
||||
// VM's config
|
||||
var config: VMConfig
|
||||
|
||||
@@ -39,7 +40,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
init(vmDir: VMDirectory,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||
directoryShares: [DirectoryShare] = []
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = []
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
@@ -51,70 +52,98 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// 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)
|
||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||
network: network, additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directorySharingDevices
|
||||
)
|
||||
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 expectedIPSWLocation = try IPSWCache().locationFor(image: image)
|
||||
|
||||
if FileManager.default.fileExists(atPath: expectedIPSWLocation.path) {
|
||||
defaultLogger.appendNewLine("Using cached *.ipsw file...")
|
||||
try expectedIPSWLocation.updateAccessDate()
|
||||
return expectedIPSWLocation
|
||||
}
|
||||
|
||||
defaultLogger.appendNewLine("Fetching \(expectedIPSWLocation.lastPathComponent)...")
|
||||
|
||||
let data: Data = try await withCheckedThrowingContinuation { continuation in
|
||||
let downloadedTask = URLSession.shared.dataTask(with: image.url) { data, response, error in
|
||||
if error != nil {
|
||||
continuation.resume(throwing: error!)
|
||||
return
|
||||
}
|
||||
if (data == nil) {
|
||||
continuation.resume(throwing: DownloadFailed())
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: data!)
|
||||
}
|
||||
ProgressObserver(downloadedTask.progress).log(defaultLogger)
|
||||
downloadedTask.resume()
|
||||
}
|
||||
|
||||
try data.write(to: expectedIPSWLocation, options: [.atomic])
|
||||
return expectedIPSWLocation
|
||||
return image.url
|
||||
}
|
||||
|
||||
|
||||
var inFinalState: Bool {
|
||||
get {
|
||||
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
||||
virtualMachine.state == VZVirtualMachine.State.paused ||
|
||||
virtualMachine.state == VZVirtualMachine.State.error
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
vmDir: VMDirectory,
|
||||
ipswURL: URL?,
|
||||
ipswURL: URL,
|
||||
diskSizeGB: UInt16,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = []
|
||||
) async throws {
|
||||
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
|
||||
var ipswURL = ipswURL
|
||||
|
||||
if !ipswURL.isFileURL {
|
||||
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
||||
}
|
||||
|
||||
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
|
||||
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
|
||||
// cannot deal with paths that contain symlinks, so expand them here first.
|
||||
ipswURL.resolveSymlinksInPath()
|
||||
|
||||
// Load the restore image and try to get the requirements
|
||||
// that match both the image and our platform
|
||||
@@ -148,9 +177,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// 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: [])
|
||||
vmConfig: config, network: network,
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directorySharingDevices
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
@@ -158,7 +188,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
// 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...")
|
||||
@@ -187,9 +217,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
func run(_ recovery: Bool) async throws {
|
||||
try network.run()
|
||||
try network.run(sema)
|
||||
|
||||
DispatchQueue.main.sync {
|
||||
let startTask = DispatchQueue.main.sync {
|
||||
Task {
|
||||
if #available(macOS 13, *) {
|
||||
// new API introduced in Ventura
|
||||
@@ -203,7 +233,12 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
await withTaskCancellationHandler(operation: {
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
// Await on VZVirtualMachine.start() result
|
||||
_ = try await startTask.value
|
||||
|
||||
// Wait for the VM to finish running
|
||||
// or for the exit condition
|
||||
sema.wait()
|
||||
}, onCancel: {
|
||||
sema.signal()
|
||||
@@ -217,7 +252,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
try network.stop()
|
||||
try await network.stop()
|
||||
}
|
||||
|
||||
static func craftConfiguration(
|
||||
@@ -226,7 +261,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
vmConfig: VMConfig,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
||||
directoryShares: [DirectoryShare]
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration]
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -270,19 +305,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// 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")
|
||||
}
|
||||
// Directory sharing devices
|
||||
configuration.directorySharingDevices = directorySharingDevices
|
||||
|
||||
try configuration.validate()
|
||||
|
||||
@@ -295,7 +319,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()
|
||||
}
|
||||
|
||||
@@ -304,9 +328,3 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
sema.signal()
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectoryShare {
|
||||
let name: String
|
||||
let path: URL
|
||||
let readOnly: Bool
|
||||
}
|
||||
|
||||
+16
-10
@@ -35,6 +35,12 @@ struct VMDisplayConfig: Codable {
|
||||
var height: Int = 768
|
||||
}
|
||||
|
||||
extension VMDisplayConfig: CustomStringConvertible {
|
||||
var description: String {
|
||||
"\(width)x\(height)"
|
||||
}
|
||||
}
|
||||
|
||||
struct VMConfig: Codable {
|
||||
var version: Int = 1
|
||||
var os: OS
|
||||
@@ -48,10 +54,10 @@ struct VMConfig: Codable {
|
||||
var display: VMDisplayConfig = VMDisplayConfig()
|
||||
|
||||
init(
|
||||
platform: Platform,
|
||||
cpuCountMin: Int,
|
||||
memorySizeMin: UInt64,
|
||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
||||
platform: Platform,
|
||||
cpuCountMin: Int,
|
||||
memorySizeMin: UInt64,
|
||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
||||
) {
|
||||
self.os = platform.os()
|
||||
self.arch = CurrentArchitecture()
|
||||
@@ -105,12 +111,12 @@ struct VMConfig: Codable {
|
||||
let encodedMacAddress = try container.decode(String.self, forKey: .macAddress)
|
||||
guard let macAddress = VZMACAddress.init(string: encodedMacAddress) else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .hardwareModel,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize VZMacAddress using the provided value")
|
||||
forKey: .hardwareModel,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize VZMacAddress using the provided value")
|
||||
}
|
||||
self.macAddress = macAddress
|
||||
|
||||
|
||||
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
|
||||
}
|
||||
|
||||
@@ -132,7 +138,7 @@ struct VMConfig: Codable {
|
||||
mutating func setCPU(cpuCount: Int) throws {
|
||||
if cpuCount < cpuCountMin {
|
||||
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
||||
+ " at minimum (requested \(cpuCount))")
|
||||
+ " at minimum (requested \(cpuCount))")
|
||||
}
|
||||
|
||||
self.cpuCount = cpuCount
|
||||
@@ -141,7 +147,7 @@ struct VMConfig: Codable {
|
||||
mutating func setMemory(memorySize: UInt64) throws {
|
||||
if memorySize < memorySizeMin {
|
||||
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
||||
+ " of memory at minimum (requested \(memorySizeMin))")
|
||||
+ " of memory at minimum (requested \(memorySizeMin))")
|
||||
}
|
||||
|
||||
self.memorySize = memorySize
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Compression
|
||||
import Sentry
|
||||
|
||||
enum OCIError: Error {
|
||||
case ShouldBeExactlyOneLayer
|
||||
@@ -35,8 +36,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()
|
||||
|
||||
@@ -59,25 +60,25 @@ extension VMDirectory {
|
||||
|
||||
// Progress
|
||||
let diskCompressedSize: Int64 = Int64(diskLayers.map {
|
||||
$0.size
|
||||
}
|
||||
.reduce(0) {
|
||||
$0 + $1
|
||||
})
|
||||
$0.size
|
||||
}
|
||||
.reduce(0) {
|
||||
$0 + $1
|
||||
})
|
||||
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
||||
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
||||
let progress = Progress(totalUnitCount: diskCompressedSize)
|
||||
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)
|
||||
}
|
||||
}
|
||||
try filter.finalize()
|
||||
try disk.close()
|
||||
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||
|
||||
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
||||
defaultLogger.appendNewLine("pulling NVRAM...")
|
||||
@@ -92,8 +93,8 @@ 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()
|
||||
}
|
||||
@@ -145,9 +146,9 @@ extension VMDirectory {
|
||||
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,
|
||||
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
|
||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||
layers: layers,
|
||||
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
|
||||
)
|
||||
|
||||
// Manifest
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
struct UninitializedVMDirectoryError: Error {
|
||||
}
|
||||
|
||||
struct AlreadyInitializedVMDirectoryError: Error {
|
||||
}
|
||||
|
||||
struct VMDirectory: Prunable {
|
||||
var baseURL: URL
|
||||
|
||||
@@ -28,6 +22,10 @@ struct VMDirectory: Prunable {
|
||||
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)
|
||||
@@ -43,7 +41,7 @@ struct VMDirectory: Prunable {
|
||||
|
||||
func initialize(overwrite: Bool = false) throws {
|
||||
if !overwrite && initialized {
|
||||
throw AlreadyInitializedVMDirectoryError()
|
||||
throw RuntimeError.VMDirectoryAlreadyInitialized("VM directory is already initialized, preventing overwrite")
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
|
||||
@@ -54,8 +52,13 @@ struct VMDirectory: Prunable {
|
||||
}
|
||||
|
||||
func validate() throws {
|
||||
if !FileManager.default.fileExists(atPath: baseURL.path) {
|
||||
throw RuntimeError.VMDoesNotExist(name: baseURL.lastPathComponent)
|
||||
}
|
||||
|
||||
if !initialized {
|
||||
throw UninitializedVMDirectoryError()
|
||||
throw RuntimeError.VMMissingFiles("VM is missing some of its files (\(configURL.lastPathComponent),"
|
||||
+ " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class VMStorageHelper {
|
||||
return try closure()
|
||||
} catch {
|
||||
if error.isFileNotFound() {
|
||||
throw RuntimeError("source VM \"\(name)\" not found, is it listed in \"tart list\"?")
|
||||
throw RuntimeError.VMDoesNotExist(name: name)
|
||||
}
|
||||
|
||||
throw error
|
||||
@@ -40,14 +40,76 @@ extension Error {
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeError: Error, CustomStringConvertible {
|
||||
let message: String
|
||||
enum RuntimeError : Error {
|
||||
case VMDoesNotExist(name: String)
|
||||
case VMMissingFiles(_ message: String)
|
||||
case VMNotRunning(_ message: String)
|
||||
case VMAlreadyRunning(_ message: String)
|
||||
case NoIPAddressFound(_ message: String)
|
||||
case DiskAlreadyInUse(_ message: String)
|
||||
case FailedToUpdateAccessDate(_ message: String)
|
||||
case PIDLockFailed(_ message: String)
|
||||
case FailedToParseRemoteName(_ message: String)
|
||||
case VMTerminationFailed(_ message: String)
|
||||
case InvalidCredentials(_ message: String)
|
||||
case VMDirectoryAlreadyInitialized(_ message: String)
|
||||
}
|
||||
|
||||
init(_ message: String) {
|
||||
self.message = message
|
||||
}
|
||||
protocol HasExitCode {
|
||||
var exitCode: Int32 { get }
|
||||
}
|
||||
|
||||
var description: String {
|
||||
message
|
||||
extension RuntimeError : CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .VMDoesNotExist(let name):
|
||||
return "the specified VM \"\(name)\" does not exist"
|
||||
case .VMMissingFiles(let message):
|
||||
return message
|
||||
case .VMNotRunning(let message):
|
||||
return message
|
||||
case .VMAlreadyRunning(let message):
|
||||
return message
|
||||
case .NoIPAddressFound(let message):
|
||||
return message
|
||||
case .DiskAlreadyInUse(let message):
|
||||
return message
|
||||
case .FailedToUpdateAccessDate(let message):
|
||||
return message
|
||||
case .PIDLockFailed(let message):
|
||||
return message
|
||||
case .FailedToParseRemoteName(let cause):
|
||||
return "failed to parse remote name: \(cause)"
|
||||
case .VMTerminationFailed(let message):
|
||||
return message
|
||||
case .InvalidCredentials(let message):
|
||||
return message
|
||||
case .VMDirectoryAlreadyInitialized(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RuntimeError : HasExitCode {
|
||||
var exitCode: Int32 {
|
||||
switch self {
|
||||
case .VMNotRunning:
|
||||
return 2
|
||||
case .VMAlreadyRunning:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Customize error description for Sentry[1]
|
||||
//
|
||||
// [1]: https://docs.sentry.io/platforms/apple/guides/ios/usage/#customizing-error-descriptions
|
||||
extension RuntimeError : CustomNSError {
|
||||
var errorUserInfo: [String : Any] {
|
||||
[
|
||||
NSDebugDescriptionErrorKey: description,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Sentry
|
||||
|
||||
class VMStorageOCI: PrunableStorage {
|
||||
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
@@ -39,7 +40,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
// Pre-create intermediate directories (e.g. creates ~/.tart/cache/OCIs/github.com/org/repo/
|
||||
// for github.com/org/repo:latest)
|
||||
try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
withIntermediateDirectories: true)
|
||||
|
||||
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
|
||||
}
|
||||
@@ -53,7 +54,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
var refCounts = Dictionary<URL, UInt>()
|
||||
|
||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
|
||||
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
var result: [(String, VMDirectory, Bool)] = Array()
|
||||
|
||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
|
||||
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -127,7 +128,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
||||
|
||||
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||
reference: Reference(digest: Digest.hash(manifestData)))
|
||||
reference: Reference(digest: Digest.hash(manifestData)))
|
||||
|
||||
// Ensure that host directory for given RemoteName exists in OCI storage
|
||||
let hostDirectoryURL = hostDirectoryURL(digestName)
|
||||
@@ -148,6 +149,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
if !exists(digestName) {
|
||||
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
@@ -159,11 +161,22 @@ class VMStorageOCI: PrunableStorage {
|
||||
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
|
||||
|
||||
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey])
|
||||
let availableCapacityBytes = max(UInt64(attrs.volumeAvailableCapacityForImportantUsage!), UInt64(attrs.volumeAvailableCapacity!))
|
||||
let capacityImportant = attrs.volumeAvailableCapacityForImportantUsage!
|
||||
let capacityAvailable = attrs.volumeAvailableCapacity!
|
||||
let availableCapacityBytes = max(UInt64(capacityImportant), UInt64(capacityAvailable))
|
||||
|
||||
if capacityImportant == 0 || capacityAvailable == 0 {
|
||||
puppy.warning("important capacity \(capacityImportant) bytes, "
|
||||
+ "available capacity is \(capacityAvailable) bytes")
|
||||
}
|
||||
|
||||
// There is a suspicious that occasionally capacity is returned as zero which can't be true.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -171,7 +184,9 @@ class VMStorageOCI: PrunableStorage {
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
||||
try move(digestName, from: tmpVMDir)
|
||||
transaction.finish()
|
||||
}, onCancel: {
|
||||
transaction.finish(status: SentrySpanStatus.cancelled)
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
} else {
|
||||
@@ -180,7 +195,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
if name != digestName {
|
||||
// Create new or overwrite the old symbolic link
|
||||
try link(from: digestName, to: name)
|
||||
try link(from: name, to: digestName)
|
||||
} else {
|
||||
// Ensure that images pulled by content digest
|
||||
// are excluded from garbage collection
|
||||
@@ -189,11 +204,11 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
func link(from: RemoteName, to: RemoteName) throws {
|
||||
if FileManager.default.fileExists(atPath: vmURL(to).path) {
|
||||
try FileManager.default.removeItem(at: vmURL(to))
|
||||
if FileManager.default.fileExists(atPath: vmURL(from).path) {
|
||||
try FileManager.default.removeItem(at: vmURL(from))
|
||||
}
|
||||
|
||||
try FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
|
||||
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
||||
|
||||
try gc()
|
||||
}
|
||||
|
||||
@@ -3,36 +3,36 @@ import Dynamic
|
||||
import Virtualization
|
||||
|
||||
class FullFledgedVNC: VNC {
|
||||
let password: String
|
||||
private let vnc: Dynamic
|
||||
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()
|
||||
}
|
||||
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 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)")!
|
||||
}
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
vnc.stop()
|
||||
// Wait 50 ms.
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
try? stop()
|
||||
}
|
||||
func stop() throws {
|
||||
vnc.stop()
|
||||
}
|
||||
|
||||
deinit {
|
||||
try? stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,33 +2,33 @@ 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()
|
||||
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()
|
||||
}
|
||||
// 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()
|
||||
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()
|
||||
// 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())
|
||||
}
|
||||
let secondLock = try! FileLock(lockURL: url)
|
||||
XCTAssertFalse(try secondLock.trylock())
|
||||
}
|
||||
|
||||
private func temporaryFile() -> URL {
|
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
|
||||
private func temporaryFile() -> URL {
|
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
|
||||
|
||||
FileManager.default.createFile(atPath: url.path, contents: nil)
|
||||
FileManager.default.createFile(atPath: url.path, contents: nil)
|
||||
|
||||
return url
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,31 +5,31 @@ import Network
|
||||
final class MACAddressResolverTests: XCTestCase {
|
||||
func testSingleEntry() throws {
|
||||
let leases = try Leases("""
|
||||
{
|
||||
ip_address=1.2.3.4
|
||||
hw_address=1,00:11:22:33:44:55
|
||||
}
|
||||
""")
|
||||
{
|
||||
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")!))
|
||||
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
|
||||
}
|
||||
""")
|
||||
{
|
||||
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")!))
|
||||
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")!))
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,88 +2,88 @@ import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class RegistryTests: XCTestCase {
|
||||
var registryRunner: RegistryRunner?
|
||||
var registryRunner: RegistryRunner?
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
|
||||
do {
|
||||
registryRunner = try await RegistryRunner()
|
||||
} catch {
|
||||
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
||||
}
|
||||
do {
|
||||
registryRunner = try await RegistryRunner()
|
||||
} catch {
|
||||
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
||||
}
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
try await super.tearDown()
|
||||
|
||||
registryRunner = nil
|
||||
}
|
||||
|
||||
var registry: Registry {
|
||||
registryRunner!.registry
|
||||
}
|
||||
|
||||
func testPushPullBlobSmall() async throws {
|
||||
// Generate a simple blob
|
||||
let pushedBlob = Data("The quick brown fox jumps over the lazy dog".utf8)
|
||||
|
||||
// Push it
|
||||
let pushedBlobDigest = try await registry.pushBlob(fromData: pushedBlob)
|
||||
XCTAssertEqual("sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", pushedBlobDigest)
|
||||
|
||||
// Pull it
|
||||
var pulledBlob = Data()
|
||||
try await registry.pullBlob(pushedBlobDigest) { data in
|
||||
pulledBlob.append(data)
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
try await super.tearDown()
|
||||
// Ensure that both blobs are identical
|
||||
XCTAssertEqual(pushedBlob, pulledBlob)
|
||||
}
|
||||
|
||||
registryRunner = nil
|
||||
func testPushPullBlobHugeInChunks() async throws {
|
||||
// Generate a large enough blob
|
||||
let fh = FileHandle(forReadingAtPath: "/dev/urandom")!
|
||||
let largeBlobToPush = try fh.read(upToCount: 768 * 1024 * 1024)!
|
||||
|
||||
// Push it
|
||||
let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush, chunkSizeMb: 10)
|
||||
|
||||
// Pull it
|
||||
var pulledLargeBlob = Data()
|
||||
try await registry.pullBlob(largeBlobDigest) { data in
|
||||
pulledLargeBlob.append(data)
|
||||
}
|
||||
|
||||
var registry: Registry {
|
||||
registryRunner!.registry
|
||||
}
|
||||
// Ensure that both blobs are identical
|
||||
XCTAssertEqual(largeBlobToPush, pulledLargeBlob)
|
||||
}
|
||||
|
||||
func testPushPullBlobSmall() async throws {
|
||||
// Generate a simple blob
|
||||
let pushedBlob = Data("The quick brown fox jumps over the lazy dog".utf8)
|
||||
func testPushPullManifest() async throws {
|
||||
// Craft a basic config
|
||||
let configData = try OCIConfig().toJSON()
|
||||
let configDigest = try await registry.pushBlob(fromData: configData)
|
||||
|
||||
// Push it
|
||||
let pushedBlobDigest = try await registry.pushBlob(fromData: pushedBlob)
|
||||
XCTAssertEqual("sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", pushedBlobDigest)
|
||||
// Craft a basic layer
|
||||
let layerData = Data("doesn't matter".utf8)
|
||||
let layerDigest = try await registry.pushBlob(fromData: layerData)
|
||||
|
||||
// Pull it
|
||||
var pulledBlob = Data()
|
||||
try await registry.pullBlob(pushedBlobDigest) { buffer in
|
||||
pulledBlob.append(Data(buffer: buffer))
|
||||
}
|
||||
// Craft a basic manifest and push it
|
||||
let manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: configData.count, digest: configDigest),
|
||||
layers: [
|
||||
OCIManifestLayer(mediaType: "application/octet-stream", size: layerData.count, digest: layerDigest)
|
||||
]
|
||||
)
|
||||
let pushedManifestDigest = try await registry.pushManifest(reference: "latest", manifest: manifest)
|
||||
|
||||
// Ensure that both blobs are identical
|
||||
XCTAssertEqual(pushedBlob, pulledBlob)
|
||||
}
|
||||
// Ensure that the manifest pulled by tag matches with the one pushed above
|
||||
let (pulledByTagManifest, _) = try await registry.pullManifest(reference: "latest")
|
||||
XCTAssertEqual(manifest, pulledByTagManifest)
|
||||
|
||||
func testPushPullBlobHugeInChunks() async throws {
|
||||
// Generate a large enough blob
|
||||
let fh = FileHandle(forReadingAtPath: "/dev/urandom")!
|
||||
let largeBlobToPush = try fh.read(upToCount: 768 * 1024 * 1024)!
|
||||
|
||||
// Push it
|
||||
let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush, chunkSizeMb: 10)
|
||||
|
||||
// Pull it
|
||||
var pulledLargeBlob = Data()
|
||||
try await registry.pullBlob(largeBlobDigest) { buffer in
|
||||
pulledLargeBlob.append(Data(buffer: buffer))
|
||||
}
|
||||
|
||||
// Ensure that both blobs are identical
|
||||
XCTAssertEqual(largeBlobToPush, pulledLargeBlob)
|
||||
}
|
||||
|
||||
func testPushPullManifest() async throws {
|
||||
// Craft a basic config
|
||||
let configData = try OCIConfig().toJSON()
|
||||
let configDigest = try await registry.pushBlob(fromData: configData)
|
||||
|
||||
// Craft a basic layer
|
||||
let layerData = Data("doesn't matter".utf8)
|
||||
let layerDigest = try await registry.pushBlob(fromData: layerData)
|
||||
|
||||
// Craft a basic manifest and push it
|
||||
let manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: configData.count, digest: configDigest),
|
||||
layers: [
|
||||
OCIManifestLayer(mediaType: "application/octet-stream", size: layerData.count, digest: layerDigest)
|
||||
]
|
||||
)
|
||||
let pushedManifestDigest = try await registry.pushManifest(reference: "latest", manifest: manifest)
|
||||
|
||||
// Ensure that the manifest pulled by tag matches with the one pushed above
|
||||
let (pulledByTagManifest, _) = try await registry.pullManifest(reference: "latest")
|
||||
XCTAssertEqual(manifest, pulledByTagManifest)
|
||||
|
||||
// Ensure that the manifest pulled by digest matches with the one pushed above
|
||||
let (pulledByDigestManifest, _) = try await registry.pullManifest(reference: "\(pushedManifestDigest)")
|
||||
XCTAssertEqual(manifest, pulledByDigestManifest)
|
||||
}
|
||||
// Ensure that the manifest pulled by digest matches with the one pushed above
|
||||
let (pulledByDigestManifest, _) = try await registry.pullManifest(reference: "\(pushedManifestDigest)")
|
||||
XCTAssertEqual(manifest, pulledByDigestManifest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ final class RemoteNameTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest"))
|
||||
}
|
||||
|
||||
|
||||
func testComplexTag() throws {
|
||||
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: Reference(tag: "1.2.3-RC-1"))
|
||||
|
||||
@@ -22,7 +22,7 @@ final class RemoteNameTests: XCTestCase {
|
||||
)
|
||||
|
||||
XCTAssertEqual(expectedRemoteName,
|
||||
try RemoteName("ghcr.io/a/b@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
|
||||
try RemoteName("ghcr.io/a/b@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
|
||||
}
|
||||
|
||||
func testASCIIOnly() throws {
|
||||
@@ -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,14 +4,14 @@ import XCTest
|
||||
final class URLAbsolutizationTets: XCTestCase {
|
||||
func testNeedsAbsolutization() throws {
|
||||
let url = URL(string: "/v2/some/path?some=query")!
|
||||
.absolutize(URL(string: "https://example.com/v2/")!)
|
||||
.absolutize(URL(string: "https://example.com/v2/")!)
|
||||
|
||||
XCTAssertEqual(url.absoluteString, "https://example.com/v2/some/path?some=query")
|
||||
}
|
||||
|
||||
func testDoesntNeedAbsolutization() throws {
|
||||
let url = URL(string: "https://example.org/v2/some/path?some=query")!
|
||||
.absolutize(URL(string: "https://example.com/v2/")!)
|
||||
.absolutize(URL(string: "https://example.com/v2/")!)
|
||||
|
||||
XCTAssertEqual(url.absoluteString, "https://example.org/v2/some/path?some=query")
|
||||
}
|
||||
|
||||
@@ -2,53 +2,53 @@ import Foundation
|
||||
@testable import tart
|
||||
|
||||
enum RegistryRunnerError: Error {
|
||||
case DockerFailed(exitCode: Int32)
|
||||
case DockerFailed(exitCode: Int32)
|
||||
}
|
||||
|
||||
class RegistryRunner {
|
||||
let containerID: String
|
||||
let registry: Registry
|
||||
let containerID: String
|
||||
let registry: Registry
|
||||
|
||||
static func dockerCmd(_ arguments: String...) throws -> String {
|
||||
let stdoutPipe = Pipe()
|
||||
static func dockerCmd(_ arguments: String...) throws -> String {
|
||||
let stdoutPipe = Pipe()
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
|
||||
proc.arguments = arguments
|
||||
proc.standardOutput = stdoutPipe
|
||||
try proc.run()
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
|
||||
proc.arguments = arguments
|
||||
proc.standardOutput = stdoutPipe
|
||||
try proc.run()
|
||||
|
||||
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
|
||||
proc.waitUntilExit()
|
||||
proc.waitUntilExit()
|
||||
|
||||
if proc.terminationStatus != 0 {
|
||||
throw RegistryRunnerError.DockerFailed(exitCode: proc.terminationStatus)
|
||||
}
|
||||
|
||||
return String(data: stdoutData, encoding: .utf8) ?? ""
|
||||
if proc.terminationStatus != 0 {
|
||||
throw RegistryRunnerError.DockerFailed(exitCode: proc.terminationStatus)
|
||||
}
|
||||
|
||||
init() async throws {
|
||||
// Start container
|
||||
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "5000", "registry:2")
|
||||
.trimmingCharacters(in: CharacterSet.newlines)
|
||||
containerID = container
|
||||
return String(data: stdoutData, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
// Get forwarded port
|
||||
let port = try Self.dockerCmd("inspect", containerID, "--format", "{{(index (index .NetworkSettings.Ports \"5000/tcp\") 0).HostPort}}")
|
||||
.trimmingCharacters(in: CharacterSet.newlines)
|
||||
init() async throws {
|
||||
// Start container
|
||||
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "5000", "registry:2")
|
||||
.trimmingCharacters(in: CharacterSet.newlines)
|
||||
containerID = container
|
||||
|
||||
registry = try Registry(urlComponents: URLComponents(string: "http://127.0.0.1:\(port)/v2/")!,
|
||||
namespace: "vm-image")
|
||||
// Get forwarded port
|
||||
let port = try Self.dockerCmd("inspect", containerID, "--format", "{{(index (index .NetworkSettings.Ports \"5000/tcp\") 0).HostPort}}")
|
||||
.trimmingCharacters(in: CharacterSet.newlines)
|
||||
|
||||
// Wait for the Docker Registry to start
|
||||
while ((try? await registry.ping()) == nil) {
|
||||
try await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
registry = try Registry(urlComponents: URLComponents(string: "http://127.0.0.1:\(port)/v2/")!,
|
||||
namespace: "vm-image")
|
||||
|
||||
// Wait for the Docker Registry to start
|
||||
while ((try? await registry.ping()) == nil) {
|
||||
try await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
_ = try! Self.dockerCmd("kill", containerID)
|
||||
}
|
||||
deinit {
|
||||
_ = try! Self.dockerCmd("kill", containerID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
pytest
|
||||
testcontainers
|
||||
requests
|
||||
bitmath
|
||||
pytest-dependency
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 == ""
|
||||
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
import tempfile
|
||||
import timeit
|
||||
import uuid
|
||||
|
||||
import bitmath
|
||||
import pytest
|
||||
|
||||
amount_to_transfer = bitmath.GB(1)
|
||||
minimal_speed_per_second = bitmath.Mb(100)
|
||||
|
||||
|
||||
class TestOCI:
|
||||
@pytest.mark.dependency()
|
||||
def test_push_speed(self, tart, vm_with_random_disk, docker_registry):
|
||||
start = timeit.default_timer()
|
||||
tart.run(["push", "--insecure", vm_with_random_disk, docker_registry.remote_name(vm_with_random_disk)])
|
||||
stop = timeit.default_timer()
|
||||
|
||||
actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start)
|
||||
assert actual_speed_per_second > minimal_speed_per_second
|
||||
|
||||
@pytest.mark.dependency(depends=["TestOCI::test_push_speed"])
|
||||
def test_pull_speed(self, tart, vm_with_random_disk, docker_registry):
|
||||
start = timeit.default_timer()
|
||||
tart.run(["pull", "--insecure", docker_registry.remote_name(vm_with_random_disk)])
|
||||
stop = timeit.default_timer()
|
||||
|
||||
actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start)
|
||||
assert actual_speed_per_second > minimal_speed_per_second
|
||||
|
||||
@staticmethod
|
||||
def _calculate_speed_per_second(amount_transferred, time_taken):
|
||||
return (amount_transferred / time_taken).best_prefix(bitmath.SI)
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def vm_with_random_disk(tart):
|
||||
vm_name = str(uuid.uuid4())
|
||||
|
||||
# Create a VM (Linux for speed's sake)
|
||||
tart.run(["create", "--linux", vm_name])
|
||||
|
||||
# Populate VM's disk with "amount_to_transfer" of random bytes
|
||||
# to effectively disable Tart's OCI blob compression
|
||||
disk_path = os.path.join(tart.home(), "vms", vm_name, "disk.img")
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tf:
|
||||
tf.write(os.urandom(amount_to_transfer.bytes))
|
||||
tf.close()
|
||||
os.rename(tf.name, disk_path)
|
||||
|
||||
yield vm_name
|
||||
|
||||
tart.run(["delete", vm_name])
|
||||
@@ -0,0 +1,10 @@
|
||||
def test_rename(tart):
|
||||
# Create a Linux VM (because we can create it really fast)
|
||||
tart.run(["create", "--linux", "debian"])
|
||||
|
||||
# Rename that VM
|
||||
tart.run(["rename", "debian", "ubuntu"])
|
||||
|
||||
# Ensure that the VM is now named "ubuntu"
|
||||
stdout, _, = tart.run(["list", "--quiet"])
|
||||
assert stdout == "ubuntu\n"
|
||||
Reference in New Issue
Block a user